diff --git a/README.md b/README.md index c360cdc..dd182aa 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,255 @@ # Stack-chan Matchday -A lightweight [Stack-chan](https://github.com/stack-chan/stack-chan) mod plus a -LAN watcher that turn the robot into a World Cup co-watching companion: it -keeps both teams' Kalshi advance-market probabilities on screen, announces -goals and key ESPN live-commentary events with speech, balloons, lights, and a -safe little head dance, and lets you pick the next match from your phone. - -> **Product boundary.** This is a read-only match companion. It does not -> trade, does not read any Kalshi account, and does not give betting advice. -> `position_team` is a manually entered preference used only to choose the -> post-match reaction — the system never sees a real position. ESPN and Kalshi -> endpoints used here are public but unofficial; they can lag behind the TV -> feed and may change without notice. Poll them politely. - -```text -Kalshi public REST ------+ - +--> Python watcher (Mac/LAN host) --HTTP--> Stack-chan mod -ESPN scoreboard/summary -+ | | - | +--> probability bar + flags - +--> fixture discovery +--> balloons / tones / TTS - +--> priority alert queue +--> goal & result celebrations - +--> /setup phone page -``` +[English](README.md) | [简体中文](README.zh-CN.md) + +Stack-chan Matchday is a lightweight [Stack-chan](https://github.com/stack-chan/stack-chan) +mod and Python LAN watcher that together turn a CoreS3 robot into a World Cup +co-watching companion. It shows both teams' Kalshi advance-market +probabilities, follows ESPN scores and live commentary, reacts with speech, +balloons, lights, and safe head movements, and lets you choose the next match +from a phone. + +> [!IMPORTANT] +> This is a read-only match companion. It does not trade, access a Kalshi +> account, or provide betting advice. `position_team` is a manually entered +> preference used only to choose the post-match reaction. Kalshi data comes +> from its public REST API; ESPN data comes from publicly reachable, +> undocumented endpoints that may change or lag behind the broadcast. + +## Features + +- A persistent two-team probability bar with flags and a bottom market ticker. +- Goal, card, substitution, close-miss, match-state, and final-result reactions. +- A phone setup page hosted by Stack-chan, with live Chinese/English switching. +- Double-tap head-touch and Power-button shortcuts for the setup QR code. +- Fixture discovery, adaptive polling, hot configuration reload, and quiet hours. +- Optional LAN TTS; visual feedback and tone patterns still work without it. +- Standalone mode for following up to four active markets without a fixture. + +## System design + +![Stack-chan Matchday system design](docs/images/system-design.png) + +Kalshi and ESPN are read-only inputs to the Python watcher. The watcher sends +fixture options, display commands, and acknowledgements to the Matchday mod. +The phone talks to Stack-chan's own `/setup` page; the device stores a pending +selection, and the watcher validates it, atomically updates the local JSON +configuration, hot-reloads, and acknowledges the device. For speech, +Stack-chan requests `/say?text=...` from the optional LAN TTS server and +receives a 24 kHz mono 16-bit PCM WAV response. + +The phone, watcher host, TTS server, and Stack-chan must be on the same trusted +LAN. The optional watcher-hosted `:8788/setup` page is a local admin fallback, +not the primary QR flow. ## Repository layout -- `mod/` — the device mod (seven JS modules + flag/QR assets). Builds to - ~236KB, fits a standard 256KB `xs` partition; no host source patches - required. -- `host/` — two reviewable git patches for the host build (mod partition for - CoreS3 — required; optional `StackChanCN-24` CJK font) and the font prep - script. -- `tools/` — the Kalshi/ESPN watcher, phone-setup service, LAN TTS server, - match replay, shared i18n helpers, CLI control helper, flag-asset - generator, and tests (stdlib only, no pip installs). -- `config/` — example watchlist and flag-pack definitions. Copy - `kalshi_watchlist.example.json` to `kalshi_watchlist.json` (gitignored) and - edit. +- `mod/` — the device mod, split into small JS modules plus flag and QR assets. +- `host/` — a required CoreS3 partition patch, an optional CJK-font patch, and + the font preparation helper. These are build/resource changes; the upstream + runtime JS/C source remains unchanged. +- `tools/` — the watcher, local setup service, macOS TTS server, replay tool, + serial helper, asset generator, and tests. The default HTTP workflow uses the + Python standard library; serial transport additionally requires `pyserial`. +- `config/` — the example watcher configuration and flag-pack definition. ## Requirements -- M5Stack CoreS3-based Stack-chan (16MB flash) -- [Moddable SDK](https://github.com/Moddable-OpenSource/moddable) + ESP-IDF - (easiest via [xs-dev](https://github.com/HipsterBrown/xs-dev)), Node.js -- A checkout of `stack-chan/stack-chan` (or a fork), branch `dev/v1.0` -- Python 3.10+ on the machine that runs the watcher (macOS `say` powers the - default TTS server; any WAV-over-HTTP TTS works) +- A CoreS3-based Stack-chan with 16 MB flash and a USB data cable. +- Git, Python 3.10+, Node.js 20+ (Node.js 22 is the tested upstream version), + npm, and `xz` on the build computer. +- Moddable SDK and ESP-IDF. The upstream `xs-dev` setup command below installs + and checks them. +- A phone and watcher computer on the same trusted LAN as Stack-chan. +- `qrencode` only when generating the device-specific setup QR. +- macOS only for the included `say`-based TTS server. Other systems can run the + watcher without speech or provide a compatible `/say` WAV service. + +The commands below are written for macOS/Linux shells. Set two absolute paths +once and keep using them throughout the installation: + +```sh +mkdir -p "$HOME/src" +export MATCHDAY_DIR="$HOME/src/stackchan-matchday" +export STACKCHAN_DIR="$HOME/src/stack-chan" +``` ## Install -**1. Host firmware (once).** See [host/README.md](host/README.md): apply the -partition patch (and optionally the CJK font patch), then build and deploy the -stock host. Devices flashed with the earlier `stackchan-kalshi` tooling -already have this layout and font — skip straight to step 2. +### 1. Clone and prepare the upstream build environment + +```sh +git clone https://github.com/xymeow/stackchan-matchday.git "$MATCHDAY_DIR" +git clone https://github.com/stack-chan/stack-chan.git "$STACKCHAN_DIR" + +cd "$STACKCHAN_DIR" +git switch --detach ded5ca94ef50411aec213b85a23d1afe72d4c29e + +cd "$STACKCHAN_DIR/firmware" +npm ci +npm run setup -- --device=esp32 +npm run doctor +``` + +The pinned commit is the tested base for the patches in this repository. Do +not build until `npm run doctor` lists `esp32` as a supported target. See the +upstream [getting-started guide](https://github.com/stack-chan/stack-chan/blob/dev/v1.0/firmware/docs/getting-started.md) +if `xs-dev` reports a platform-specific prerequisite. + +### 2. Patch, build, and flash the host once + +Apply the partition patch on every new CoreS3 host checkout: + +```sh +cd "$STACKCHAN_DIR" +git am "$MATCHDAY_DIR/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch" +``` + +For Chinese labels and balloons, also apply the font patch and prepare a +CJK-capable TTF. Pure-English installations may skip these two commands and +set the watcher language to `en`: + +```sh +git am "$MATCHDAY_DIR/host/patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch" +python3 "$MATCHDAY_DIR/host/prepare_cjk_font.py" "$STACKCHAN_DIR" +``` -**2. Mod.** From the stack-chan checkout's `firmware/` directory: +Build and flash the host: ```sh +cd "$STACKCHAN_DIR/firmware" +export PATH="$PWD/node_modules/.bin:$PATH" +mcconfig -d -m -p esp32:./platforms/m5stackchan_cores3 -t deploy \ + "$PWD/stackchan/manifest_m5stackchan_cores3.json" +``` + +See [host/README.md](host/README.md) for font selection and patch details. + +### 3. Generate the QR, then build and install the mod + +The QR image is a static asset compiled into the mod; it is not generated at +runtime. Use a stable DHCP reservation, IP address, or resolvable mDNS name, +and generate the QR before installing the mod. The UI reads the PNG's natural +dimensions; keep both edges at or below 168 px so the title and URL still fit: + +```sh +export STACKCHAN_HOST=stackchan.local +qrencode -s 4 -m 1 -o "$MATCHDAY_DIR/mod/assets/setup/setup-qr.png" \ + "http://$STACKCHAN_HOST/setup" +file "$MATCHDAY_DIR/mod/assets/setup/setup-qr.png" +``` + +If `file` reports an edge larger than 168 px, regenerate with `-s 3`. Then +build and install the mod: + +```sh +cd "$STACKCHAN_DIR/firmware" npm run mod --target=esp32:./platforms/m5stackchan_cores3 -- -f rgb565be \ - /path/to/stackchan-matchday/mod/manifest.json + "$MATCHDAY_DIR/mod/manifest.json" ``` -`-f rgb565be` is required on CoreS3; without it the flag colors byte-swap. +`-f rgb565be` is required on CoreS3; without it, flag colors are byte-swapped. +Verify the mod from the watcher computer: + +```sh +curl "http://$STACKCHAN_HOST/health" +curl "http://$STACKCHAN_HOST/api/status" +``` -**3. Point speech at your TTS server** (optional but recommended): +### 4. Configure and start the watcher ```sh -launchctl submit -l local.stackchan.tts -- /usr/bin/python3 "$PWD/tools/stackchan_tts_server.py" --host 0.0.0.0 --port 8787 -curl -X POST --data-binary 'tts host :8787' http:///api/command +cp "$MATCHDAY_DIR/config/kalshi_watchlist.example.json" \ + "$MATCHDAY_DIR/config/kalshi_watchlist.json" ``` -The mod streams speech through the host's stock `tts-remote` module and reads -the host's native `tts` preference domain, so changing the IP later is one -command — no rebuild, no reboot. Without a TTS server everything still works; -speech falls back to short tone patterns. +Edit the copied file and check these values: + +- `stackchan_host` matches `$STACKCHAN_HOST` or the device's LAN IP. +- `stackchan_transport` is `http`; the phone setup relay does not work over + serial transport. +- `setup_server.enabled` is `true`. +- Port `8788` is free. Its default `127.0.0.1` binding keeps the optional local + admin page on the watcher computer only. -**4. Watcher.** +Validate the JSON, then keep the watcher running: ```sh -cp config/kalshi_watchlist.example.json config/kalshi_watchlist.json -python3 tools/stackchan_kalshi_watch.py --config config/kalshi_watchlist.json --once --dry-run -python3 tools/stackchan_kalshi_watch.py --config config/kalshi_watchlist.json --watch +python3 -m json.tool "$MATCHDAY_DIR/config/kalshi_watchlist.json" +python3 "$MATCHDAY_DIR/tools/stackchan_kalshi_watch.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" --watch ``` -With `setup_server.enabled: true`, open `http:///setup` on a -phone in the same LAN to pick the next match, your team, and an optional -pregame position; the watcher validates and hot-reloads without restarting. - -**Proactive prompts.** Once per local day after `setup_server.daily_prompt_hour` -(default 10, `-1` disables, quiet hours respected), the robot speaks up on its -own: if fixtures kick off today and none of them is configured, it asks you to -scan the QR and pick one; if the lookahead window has no fixtures at all, it -asks whether you would like to watch some other Kalshi market instead. The -usual pregame prompt (`prompt_minutes_before` ahead of an unconfigured -kickoff) still fires independently. - -**Standalone Kalshi markets.** The setup page can watch any Kalshi event -without a fixture: paste an event link or ticker under *Watch a Kalshi market -directly* and the watcher configures up to its four most-traded markets into -the bottom text ticker with price alerts (probability bar and ESPN commentary -turn off, since arbitrary markets have neither team flags nor a fixture). -Selecting a match later switches everything back. - -**Waking the setup QR on the device.** Double-tap the three-zone touch bar on -top of the head to show the setup QR; while it is visible, tap the top bar -once to hide it. Briefly pressing the physical Power button does the same on -hosts built from `dev/v1.0` commit `ded5ca9` (power-button support) or later. -The mod reads the Si12T top bar's raw 0–3 intensity samples itself with -hysteresis (press ≥ 2, release ≤ 1) instead of the host gesture recognizer, -so capacitive baseline drift on a warm device neither fires false taps nor -wedges the detector. `GET /api/status` → `setup.trigger.touch` exposes the -live intensity/position/tap counters for tuning the thresholds in -`mod/mod.js` without guesswork. - -`mod/assets/setup/setup-qr.png` encodes the device setup URL and is -device-specific; regenerate it for your own address with e.g. -`qrencode -s 4 -m 1 -o mod/assets/setup/setup-qr.png "http:///setup"` -and rebuild the mod. - -## Language - -Set the top-level `language` to `"zh"` or `"en"`. This selects one complete -output language for watcher-generated speech and balloons; Chinese remains the -default for existing configs. - -The phone setup page has a `中文 / English` selector that switches the whole -page — section titles, match names, status lines — and the on-device QR -overlay **immediately** (`POST /api/match-setup/language`), and is persisted -across device reboots. A language picked on the page wins over whatever -language the watcher pushes with its fixture options; watcher speech and -balloons follow the selection once a match is applied with **Start watching** -(the watcher's poll endpoint `GET /api/match-setup/pending` also reports the -device language for future hot-switching). - -You can also preview another language without editing the config file: +The example `KXEXAMPLE-...` tickers are intentional placeholders. Until you +select a live match from the phone or replace them with real open tickers, the +watcher may report them as missing. `--dry-run` only suppresses device writes; +it still queries the public APIs and is not an offline installation check. + +### 5. Optional: enable LAN speech + +In a second terminal on macOS, re-export the repository path and start the +included server in the foreground so errors remain visible: + +```sh +export MATCHDAY_DIR="$HOME/src/stackchan-matchday" +python3 "$MATCHDAY_DIR/tools/stackchan_tts_server.py" --host 0.0.0.0 --port 8787 +``` + +Leave that terminal running. In another terminal, verify it and point the +device at the watcher's LAN address, not `127.0.0.1`: ```sh -python3 tools/stackchan_kalshi_watch.py --config config/kalshi_watchlist.json \ - --language en --once --dry-run -python3 tools/stackchan_match_replay.py --config config/kalshi_watchlist.json \ - --language en +curl "http://127.0.0.1:8787/health" +export STACKCHAN_HOST=stackchan.local +export WATCHER_HOST=192.168.1.20 +curl --request POST --data-binary "tts host $WATCHER_HOST:8787" \ + "http://$STACKCHAN_HOST/api/command" +curl --request POST --data-binary "say Matchday ready" \ + "http://$STACKCHAN_HOST/api/command" ``` -User-facing config values accept either a legacy string or a localized object: +Allow inbound TCP `8787` through the computer firewall. Use `say -v '?'` to +list installed macOS voices; override defaults with +`STACKCHAN_TTS_ZH_VOICE`, `STACKCHAN_TTS_EN_VOICE`, and +`STACKCHAN_TTS_RATE`. Without a reachable TTS server, the mod automatically +falls back to short tone patterns. + +## How to use + +1. **Start the watcher.** Keep the watcher computer awake and the `--watch` + process running. The phone, computer, and Stack-chan must share a trusted + LAN. +2. **Wake the setup QR.** Double-tap the three-zone touch bar on top of + Stack-chan's head. Tap once while the QR is visible to hide it; it also + closes after 90 seconds. A short Power-button press toggles it on the pinned + host firmware. +3. **Scan and choose.** Open the QR on a phone, choose 中文 or English, select a + match, your team (or Neutral), and an optional pregame position (or No + position), then tap **Start watching**. The position is only a manual final + result reaction preference; no account is read. +4. **Wait for confirmation.** The page first says that it is waiting for the + watcher. The watcher validates the ESPN/Kalshi pairing, atomically updates + the local configuration, hot-reloads, and acknowledges the device. No + watcher or device restart is needed. +5. **Watch together.** During the match, Stack-chan updates flags and + probabilities and reacts to score and commentary events with its screen, + face, lights, head, tones, and optional speech. +6. **Watch any market.** If no fixture is available, paste a Kalshi event URL + or ticker into the same page. The event's four most-traded markets appear in + the bottom ticker; fixture-only flags, probability bar, and ESPN commentary + are temporarily disabled. + +Once per local day, the watcher can proactively ask you to scan and choose a +match. Configure this with `setup_server.daily_prompt_hour` (`-1` disables it), +`prompt_minutes_before`, `quiet_hours`, and `lookahead_days`. + +## Configuration and language + +The setup page switches all labels immediately and persists its language on +the device. Applying a match also switches watcher-generated speech and +balloons. The top-level `language` value may be `zh` or `en`. + +User-facing text accepts either a legacy string or a localized object: ```json { @@ -161,104 +268,102 @@ User-facing config values accept either a legacy string or a localized object: } ``` -The same leaf format works for `player_names`, `star_chants`, and custom goal -signal speeches. A legacy string is intentionally used verbatim in either -mode, so old configs never change meaning; use the object form when both -languages are wanted. Missing English names fall back to ESPN's source name, -and missing chants fall back to the built-in English goal sentence. The phone -setup service writes bilingual labels and goal-signal text when switching -matches. - -The LAN TTS server automatically selects Tingting for Chinese text and -Samantha for English text (override them with `STACKCHAN_TTS_ZH_VOICE` and -`STACKCHAN_TTS_EN_VOICE`). The `mac_voice` field is also localizable for the -direct macOS `say` transport. - -## What changed vs. the original stackchan_control mod - -This is the slimmed-down successor of the mod in -[`stackchan-kalshi`](https://github.com/xymeow/stackchan-kalshi) (v0.14.0, -3178-line single file, 1.3MB, five host patch scripts): - -- **No host source patches.** The marquee speech balloon is drawn by the mod - itself; remote TTS uses the host's stock module and native preferences; the - CJK font became one optional, reviewable host commit. The old - `apply_official_*.py` scripts are all retired. -- **No embedded audio.** The 1.1MB MAUD voice pack is gone; every legacy clip - id maps to a tone pattern, and player-specific lines stream from the LAN TTS - server. (A "fat" variant with embedded crowd audio can come back later as an - opt-in.) -- **Watch-focused.** MCP server, image-avatar packs, drawer buttons, and the - decorative top-touch/IMU reactions were dropped; the upstream host's default - mod and the official `image_avatar_lite` / `mcp` mods cover those better. - The top touch bar is still used for one thing that matters here: waking the - setup QR with a double tap. -- **Same wire protocol.** `pkbar`, `balloon temp`, `clip`, `voice`, - `celebrate goal|result`, `light flash`, `setup show`, and the match-setup - endpoints are unchanged, so existing watcher configs keep working; the - watcher also still drives the legacy mod (>= 0.10.0). - -## Command surface - -`POST /api/command` with plain text (`GET /api/help` lists everything): +The same localized leaf format works for `player_names`, `star_chants`, and +custom goal-signal speech. Missing English names fall back to ESPN's source +name. A legacy string is used verbatim in both modes. + +Serial transport is intended for direct command/control only. Install +`pyserial` and configure `stackchan_serial_port` if you choose it; phone setup, +device status detection, and the options/pending/ack relay require HTTP. + +## Troubleshooting + +- **The setup page has no matches:** confirm the watcher is running with setup + enabled and can reach both public APIs. Only open Kalshi events in the + configured `kalshi_series_ticker` that match an ESPN `pre` or `in` fixture + by both team names are listed. +- **The page stays on “waiting for watcher”:** the watcher is stopped, is using + serial transport, failed to bind local port `8788`, or cannot reach the + device on TCP `80`. Check its terminal output first. +- **The QR opens the wrong address:** regenerate + `mod/assets/setup/setup-qr.png` and reinstall the mod. The bitmap is static; + changing the on-screen URL does not rewrite its modules. +- **Chinese renders as boxes:** apply the optional font patch, prepare a CJK + TTF, then rebuild and reflash the host before reinstalling the mod. +- **There is no speech:** check the TTS `/health` response, computer firewall, + and `tts status` through `/api/command`. Visual effects and tone fallback do + not depend on TTS. +- **`stackchan.local` does not resolve:** use the device's LAN IP and regenerate + the QR. Reserve that address in DHCP so the static QR stays valid. +- **Markets show as missing:** the example values are placeholders. Select a + live fixture from the setup page or replace them with open tickers returned + by `python3 "$MATCHDAY_DIR/tools/stackchan_kalshi_watch.py" discover --query QUERY`. + +## Device API + +`GET /api/help` lists the plain-text command surface accepted by +`POST /api/command`: ```text -pkbar es 62 AA151B be 38 EF3340 # persistent top probability bar -balloon temp 8000 西班牙进球了! # marquee balloon, auto-hide -voice favorite-goal 7号球员进球啦 # remote TTS, tone fallback -celebrate goal 170 21 27 # dance + light + voice -celebrate say 170 21 27 姆巴佩进球啦 # speech + dance + light, synchronized -celebrate result win 170 21 27 比赛结束 # optional synchronized result speech -setup show http:///setup -say 你好 # balloon + TTS +pkbar es 62 AA151B be 38 EF3340 +balloon temp 8000 Spain scores! +voice favorite-goal Number seven scores! +celebrate goal 170 21 27 +celebrate say 170 21 27 Goal! +celebrate result win 170 21 27 Full time +setup show http://stackchan.local/setup +say Hello face happy · look 8 -2 · idle look on · light flash 0 85 164 ``` -`GET /api/status` returns mod name/version, probability bar, TTS, power, -network state, and the setup-trigger touch counters. `POST /api/control` -accepts the JSON action form. The watcher-facing match-setup endpoints are -`/api/match-setup` (+ `/options`, `/apply`, `/ack`, `/pending`, -`/language`). +`GET /api/status` reports the mod version, probability bar, TTS, power, +network, and setup-trigger counters. `POST /api/control` accepts JSON actions. +The watcher-facing setup endpoints are `/api/match-setup`, `/options`, +`/apply`, `/ack`, `/pending`, and `/language`. ## Development +Run the complete local test suite from this repository: + ```sh -# All test suites (stdlib unittest) -for t in tools/test_*.py; do python3 "$t"; done -python3 -m tools.test_stackchan_tts_server +cd "$MATCHDAY_DIR" +python3 -m unittest discover -s tools -p 'test_*.py' +``` + +Build a mod archive without installing it from the upstream `firmware/` +directory: -# Build the mod archive without installing (from the stack-chan checkout's firmware/) +```sh +cd "$STACKCHAN_DIR/firmware" mcrun -d -m -p esp32:./platforms/m5stackchan_cores3 -t build -f rgb565be \ - /path/to/stackchan-matchday/mod/manifest.json + "$MATCHDAY_DIR/mod/manifest.json" ``` -To replay the France–Morocco ESPN history (event `760510`) through the same -alert parser, first preview the generated commands. Execution is opt-in and -the continuous watcher must be stopped before it writes to the device: +Replay the France–Morocco ESPN history through the same alert parser. Preview +is the default; stop the continuous watcher before opting into execution: ```sh -python3 tools/stackchan_match_replay.py --config config/kalshi_watchlist.json -python3 tools/stackchan_match_replay.py --config config/kalshi_watchlist.json --language en -python3 tools/stackchan_match_replay.py --config config/kalshi_watchlist.json --execute +python3 "$MATCHDAY_DIR/tools/stackchan_match_replay.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" +python3 "$MATCHDAY_DIR/tools/stackchan_match_replay.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" --language en +python3 "$MATCHDAY_DIR/tools/stackchan_match_replay.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" --execute ``` -The replay reconstructs the score at each commentary entry, waits for each -celebration/TTS cycle to finish, and centers the head with torque and light off -when it completes. - -## Security notes +## Security -The HTTP API is unauthenticated and CORS-open by design — run it only on a -trusted LAN and do not port-forward it. The fallback AP (`StackChan-Matchday` -/ `stackchan`) appears only when the device has no Wi-Fi credentials -(configure Wi-Fi with the official -[web console](https://stack-chan.github.io/web/) over BLE, or change the -constants in `mod/state.js`). +The device HTTP API is unauthenticated and CORS-open by design. Use it only on +a trusted LAN; do not port-forward TCP `80`, `8787`, or `8788`. The fallback AP +(`StackChan-Matchday` / `stackchan`) appears only when the device has no Wi-Fi +credentials. Configure Wi-Fi with the official +[Stack-chan web console](https://stack-chan.github.io/web/) over BLE before +starting the watcher. -## Credits & licenses +## Credits and licenses - [Stack-chan](https://github.com/stack-chan/stack-chan) by Shinya Ishikawa — - Apache-2.0 + Apache-2.0. - Flag PNGs derived from [flag-icons](https://github.com/lipis/flag-icons) — - MIT (`mod/LICENSE-flag-icons.txt`) -- This repository — [MIT](LICENSE) + MIT; see `mod/LICENSE-flag-icons.txt`. +- This repository — [MIT](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..faf3d9a --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,338 @@ +# Stack-chan Matchday + +[English](README.md) | [简体中文](README.zh-CN.md) + +Stack-chan Matchday 是一个轻量的 +[Stack-chan](https://github.com/stack-chan/stack-chan) Mod 与 Python +局域网 watcher,可把 CoreS3 机器人变成世界杯陪看搭子:屏幕持续显示双方在 +Kalshi 晋级市场中的概率,跟随 ESPN 比分与文字直播,并通过语音、气泡、灯光和 +安全幅度的头部动作做出反应;下一场看什么,可以直接用手机选择。 + +> [!IMPORTANT] +> 这是一个只读的比赛陪看工具。它不会交易、不会访问 Kalshi 账户,也不提供投注 +> 建议。`position_team` 只是手动填写的偏好,仅用于选择终场反应。Kalshi 数据来自 +> 其公开 REST API;ESPN 数据来自可公开访问但未正式文档化的接口,可能变更,也 +> 可能落后于电视直播。 + +## 功能 + +- 常驻的双方概率条、球队旗帜与底部市场 ticker。 +- 对进球、红黄牌、换人、险情、比赛状态和终场结果做出反应。 +- 由 Stack-chan 自己托管的手机设置页,可即时切换中英文。 +- 双击头顶触摸条或短按 Power 键唤出设置二维码。 +- 自动发现比赛、自适应轮询、配置热更新与免打扰时段。 +- 可选局域网 TTS;没有 TTS 时,视觉反馈和提示音仍然可用。 +- 没有比赛时,也可单独跟踪一个事件中最多四个活跃市场。 + +## 系统设计 + +![Stack-chan Matchday 系统设计](docs/images/system-design.png) + +Kalshi 和 ESPN 只作为 Python watcher 的只读数据源。watcher 向 Matchday Mod +发送可选比赛、显示命令和设置确认。手机访问的是 Stack-chan 自己的 `/setup` +页面:设备先保存待处理选择,watcher 再完成校验、原子更新本地 JSON 配置、热重载, +最后确认设备。播报时,Stack-chan 主动向可选局域网 TTS 服务请求 +`/say?text=...`,并接收 24 kHz、单声道、16-bit PCM WAV。 + +手机、watcher 电脑、TTS 服务和 Stack-chan 必须处于同一个可信局域网。 +watcher 电脑上的 `:8788/setup` 只是可选的本机管理后备页,不是扫码主流程。 + +## 仓库结构 + +- `mod/` — 设备端 Mod,由多个小型 JS 模块以及旗帜、二维码资源组成。 +- `host/` — CoreS3 必需的分区补丁、可选 CJK 字体补丁和字体准备脚本。这些只是 + 构建/资源层改动,不修改上游 runtime JS/C 源码。 +- `tools/` — watcher、本地设置服务、macOS TTS 服务、比赛回放、串口辅助工具、 + 素材生成器与测试。默认 HTTP 流程只用 Python 标准库;串口模式另需 + `pyserial`。 +- `config/` — watcher 示例配置与旗帜包定义。 + +## 环境要求 + +- 基于 CoreS3、配备 16 MB Flash 的 Stack-chan,以及一根可传数据的 USB 线。 +- 构建电脑上安装 Git、Python 3.10+、Node.js 20+(上游已测试 Node.js 22)、 + npm 和 `xz`。 +- Moddable SDK 与 ESP-IDF;下文的上游 `xs-dev` 命令会负责安装并检查。 +- 手机、watcher 电脑和 Stack-chan 位于同一个可信局域网。 +- 只有生成设备专属二维码时才需要 `qrencode`。 +- 仓库自带的 `say` TTS 服务仅支持 macOS;其他系统可以不启用语音,或自行提供 + 兼容的 `/say` WAV 服务。 + +下列命令适用于 macOS/Linux shell。先设置两个绝对路径,后续安装始终沿用: + +```sh +mkdir -p "$HOME/src" +export MATCHDAY_DIR="$HOME/src/stackchan-matchday" +export STACKCHAN_DIR="$HOME/src/stack-chan" +``` + +## 安装 + +### 1. 克隆仓库并准备上游构建环境 + +```sh +git clone https://github.com/xymeow/stackchan-matchday.git "$MATCHDAY_DIR" +git clone https://github.com/stack-chan/stack-chan.git "$STACKCHAN_DIR" + +cd "$STACKCHAN_DIR" +git switch --detach ded5ca94ef50411aec213b85a23d1afe72d4c29e + +cd "$STACKCHAN_DIR/firmware" +npm ci +npm run setup -- --device=esp32 +npm run doctor +``` + +这个固定 commit 是本仓库补丁测试过的基线。只有在 `npm run doctor` 把 `esp32` +列为 supported target 后,才继续构建。若 `xs-dev` 提示平台相关依赖,请查看上游 +[环境配置说明](https://github.com/stack-chan/stack-chan/blob/dev/v1.0/firmware/docs/getting-started.md)。 + +### 2. 应用补丁并烧录一次 host + +每个新的 CoreS3 host checkout 都必须应用分区补丁: + +```sh +cd "$STACKCHAN_DIR" +git am "$MATCHDAY_DIR/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch" +``` + +要显示中文标签和气泡,还需应用字体补丁并准备一份支持 CJK 的 TTF。纯英文安装 +可以跳过下面两条命令,并把 watcher 的语言设为 `en`: + +```sh +git am "$MATCHDAY_DIR/host/patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch" +python3 "$MATCHDAY_DIR/host/prepare_cjk_font.py" "$STACKCHAN_DIR" +``` + +构建并烧录 host: + +```sh +cd "$STACKCHAN_DIR/firmware" +export PATH="$PWD/node_modules/.bin:$PATH" +mcconfig -d -m -p esp32:./platforms/m5stackchan_cores3 -t deploy \ + "$PWD/stackchan/manifest_m5stackchan_cores3.json" +``` + +字体选择与补丁细节见 [host/README.zh-CN.md](host/README.zh-CN.md)。 + +### 3. 先生成二维码,再构建并安装 Mod + +二维码是编译进 Mod 的静态图片,运行时不会自动重画。请先为设备设置稳定的 DHCP +保留地址、IP 或可解析的 mDNS 名称,然后在安装 Mod 前生成二维码。UI 会读取 PNG +的实际尺寸;请让宽和高都不超过 168 px,以便标题和 URL 仍能完整显示: + +```sh +export STACKCHAN_HOST=stackchan.local +qrencode -s 4 -m 1 -o "$MATCHDAY_DIR/mod/assets/setup/setup-qr.png" \ + "http://$STACKCHAN_HOST/setup" +file "$MATCHDAY_DIR/mod/assets/setup/setup-qr.png" +``` + +如果 `file` 显示任一边超过 168 px,请改用 `-s 3` 重新生成。然后构建并安装 +Mod: + +```sh +cd "$STACKCHAN_DIR/firmware" +npm run mod --target=esp32:./platforms/m5stackchan_cores3 -- -f rgb565be \ + "$MATCHDAY_DIR/mod/manifest.json" +``` + +CoreS3 必须使用 `-f rgb565be`,否则旗帜颜色会发生字节序错位。随后从 watcher +电脑验收 Mod: + +```sh +curl "http://$STACKCHAN_HOST/health" +curl "http://$STACKCHAN_HOST/api/status" +``` + +### 4. 配置并启动 watcher + +```sh +cp "$MATCHDAY_DIR/config/kalshi_watchlist.example.json" \ + "$MATCHDAY_DIR/config/kalshi_watchlist.json" +``` + +编辑复制出的文件,确认: + +- `stackchan_host` 与 `$STACKCHAN_HOST` 或设备局域网 IP 一致。 +- `stackchan_transport` 是 `http`;手机设置中继不支持串口模式。 +- `setup_server.enabled` 是 `true`。 +- 端口 `8788` 未被占用。默认绑定 `127.0.0.1`,因此可选的本机管理页不会暴露到 + 局域网。 + +检查 JSON 后,让 watcher 持续运行: + +```sh +python3 -m json.tool "$MATCHDAY_DIR/config/kalshi_watchlist.json" +python3 "$MATCHDAY_DIR/tools/stackchan_kalshi_watch.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" --watch +``` + +示例中的 `KXEXAMPLE-...` 是有意保留的占位 ticker。在手机选择真实比赛或手动 +换成开放 ticker 之前,watcher 可能报告它们 missing。`--dry-run` 只会阻止写入 +设备,仍会访问公开 API,不能当作离线安装验收。 + +### 5. 可选:启用局域网语音 + +在 macOS 的第二个终端里重新导出仓库路径,再以前台方式启动自带服务,便于直接 +看到错误: + +```sh +export MATCHDAY_DIR="$HOME/src/stackchan-matchday" +python3 "$MATCHDAY_DIR/tools/stackchan_tts_server.py" --host 0.0.0.0 --port 8787 +``` + +保持这个终端运行。在另一个终端中先验收服务,再让设备连接 watcher 电脑的局域网 +地址,而不是 `127.0.0.1`: + +```sh +curl "http://127.0.0.1:8787/health" +export STACKCHAN_HOST=stackchan.local +export WATCHER_HOST=192.168.1.20 +curl --request POST --data-binary "tts host $WATCHER_HOST:8787" \ + "http://$STACKCHAN_HOST/api/command" +curl --request POST --data-binary "say 比赛日准备好了" \ + "http://$STACKCHAN_HOST/api/command" +``` + +请在电脑防火墙中允许 TCP `8787` 入站。用 `say -v '?'` 查看 macOS 已安装的 +声音;可通过 `STACKCHAN_TTS_ZH_VOICE`、`STACKCHAN_TTS_EN_VOICE` 和 +`STACKCHAN_TTS_RATE` 覆盖默认值。TTS 不可达时,Mod 会自动回退到短提示音。 + +## 如何使用 + +1. **先启动 watcher。** 保持 watcher 电脑唤醒,并让 `--watch` 进程持续运行; + 手机、电脑和 Stack-chan 必须在同一个可信局域网。 +2. **摸头唤出设置码。** 双击 Stack-chan 头顶的三段触摸条,屏幕会显示设置二维码; + 显示时轻点一次可关闭,90 秒后也会自动关闭。在固定的 host firmware 上,短按 + Power 键同样可以开关二维码。 +3. **扫码选择比赛。** 用手机打开二维码,选择中文或 English、要看的比赛、支持 + 球队(也可中立)以及可选的赛前持仓(也可“没买”),再点“开始看球”。赛前持仓 + 只是手动的终场反应偏好,系统不会读取账户。 +4. **等待确认。** 页面会先显示“已提交,等待 watcher”。watcher 会校验 ESPN 与 + Kalshi 的双方匹配,原子更新本地配置、热重载并确认设备;无需重启 watcher 或 + Stack-chan。 +5. **开始共看。** 比赛期间,Stack-chan 会更新旗帜和概率,并用屏幕、表情、灯光、 + 头部、提示音与可选语音响应比分和文字直播事件。 +6. **没有球赛也能看盘。** 在同一页面粘贴 Kalshi event URL 或 ticker;该事件中 + 成交最活跃的最多四个市场会显示在底部 ticker,比赛专属的旗帜概率条和 ESPN + 播报会暂时关闭。 + +watcher 还可以每天主动提醒你扫码选比赛。相关配置为 +`setup_server.daily_prompt_hour`(`-1` 关闭)、`prompt_minutes_before`、 +`quiet_hours` 和 `lookahead_days`。 + +## 配置与语言 + +设置页会立即切换全部界面文案,并把语言持久化到设备。应用一场比赛后,watcher +产生的语音和气泡也会切换。顶层 `language` 可以是 `zh` 或 `en`。 + +面向用户的配置文本既可写成旧式字符串,也可写成中英对象: + +```json +{ + "language": "en", + "mac_voice": {"zh": "Tingting", "en": "Samantha"}, + "espn": { + "label": {"zh": "法国 vs 摩洛哥", "en": "France vs Morocco"}, + "team_names": { + "France": {"zh": "法国", "en": "France"} + } + }, + "markets": [{ + "ticker": "KXEXAMPLE-FRA", + "label": {"zh": "法国晋级", "en": "France to advance"} + }] +} +``` + +`player_names`、`star_chants` 和自定义进球信号语音也支持相同格式。缺少英文名时 +会回退到 ESPN 源名称;旧式字符串会在两种语言下原样使用。 + +串口模式只适合直接命令/控制。如果使用它,需要安装 `pyserial` 并配置 +`stackchan_serial_port`;手机设置、设备状态检测和 options/pending/ack 中继都 +依赖 HTTP。 + +## 排障 + +- **设置页没有比赛:** 确认 watcher 正以 setup enabled 状态运行,并能访问两个 + 公开 API。只有同时满足以下条件的比赛才会列出:ESPN 状态为 `pre`/`in`,并且 + 配置的 `kalshi_series_ticker` 中存在能按双方队名匹配的开放 Kalshi 事件。 +- **页面一直停在“等待 watcher”:** watcher 已停止、正在用串口模式、无法绑定 + 本机 `8788`,或无法访问设备 TCP `80`。请先看 watcher 终端日志。 +- **二维码打开了错误地址:** 重新生成 `mod/assets/setup/setup-qr.png` 并重装 + Mod。它是静态图片;仅修改屏幕上的 URL 不会改写二维码模块。 +- **中文显示成方框:** 应用可选字体补丁,准备 CJK TTF,重新构建并烧录 host, + 然后重装 Mod。 +- **没有语音:** 检查 TTS `/health`、电脑防火墙,以及通过 `/api/command` 返回的 + `tts status`。视觉效果和提示音回退不依赖 TTS。 +- **`stackchan.local` 无法解析:** 改用设备局域网 IP 并重新生成二维码;最好在 + DHCP 中为它保留地址。 +- **市场显示 missing:** 示例值只是占位符。请从设置页选择真实比赛,或用 + `python3 "$MATCHDAY_DIR/tools/stackchan_kalshi_watch.py" discover --query 关键词` + 返回的开放 ticker 替换。 + +## 设备 API + +`GET /api/help` 会列出 `POST /api/command` 接受的纯文本命令: + +```text +pkbar es 62 AA151B be 38 EF3340 +balloon temp 8000 西班牙进球了! +voice favorite-goal 7号球员进球啦 +celebrate goal 170 21 27 +celebrate say 170 21 27 进球了! +celebrate result win 170 21 27 比赛结束 +setup show http://stackchan.local/setup +say 你好 +face happy · look 8 -2 · idle look on · light flash 0 85 164 +``` + +`GET /api/status` 会返回 Mod 版本、概率条、TTS、电源、网络和设置触发计数器。 +`POST /api/control` 接受 JSON action。watcher 使用的设置接口为 +`/api/match-setup`、`/options`、`/apply`、`/ack`、`/pending` 和 +`/language`。 + +## 开发 + +在本仓库运行完整测试: + +```sh +cd "$MATCHDAY_DIR" +python3 -m unittest discover -s tools -p 'test_*.py' +``` + +在上游 `firmware/` 目录构建但不安装 Mod archive: + +```sh +cd "$STACKCHAN_DIR/firmware" +mcrun -d -m -p esp32:./platforms/m5stackchan_cores3 -t build -f rgb565be \ + "$MATCHDAY_DIR/mod/manifest.json" +``` + +可通过同一套 parser 回放法国—摩洛哥的 ESPN 历史。默认只预览命令;选择实际执行 +前,请先停止持续运行的 watcher: + +```sh +python3 "$MATCHDAY_DIR/tools/stackchan_match_replay.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" +python3 "$MATCHDAY_DIR/tools/stackchan_match_replay.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" --language en +python3 "$MATCHDAY_DIR/tools/stackchan_match_replay.py" \ + --config "$MATCHDAY_DIR/config/kalshi_watchlist.json" --execute +``` + +## 安全 + +设备 HTTP API 按设计不设认证并开放 CORS。只应在可信局域网使用,不要转发 TCP +`80`、`8787` 或 `8788`。设备没有 Wi-Fi 凭据时才会出现后备 AP +(`StackChan-Matchday` / `stackchan`)。启动 watcher 前,请通过 BLE 使用官方 +[Stack-chan Web Console](https://stack-chan.github.io/web/) 配置 Wi-Fi。 + +## 致谢与许可证 + +- Shinya Ishikawa 的 [Stack-chan](https://github.com/stack-chan/stack-chan) — + Apache-2.0。 +- 旗帜 PNG 来源于 [flag-icons](https://github.com/lipis/flag-icons) — MIT;详见 + `mod/LICENSE-flag-icons.txt`。 +- 本仓库 — [MIT](LICENSE)。 diff --git a/config/kalshi_watchlist.example.json b/config/kalshi_watchlist.example.json index b4e3bb5..662ad30 100644 --- a/config/kalshi_watchlist.example.json +++ b/config/kalshi_watchlist.example.json @@ -26,7 +26,7 @@ "ticker_enabled": true, "display_refresh_seconds": 30, "setup_server": { - "enabled": false, + "enabled": true, "host": "127.0.0.1", "port": 8788, "public_base_url": "", diff --git a/docs/images/system-design.png b/docs/images/system-design.png new file mode 100644 index 0000000..dc54103 Binary files /dev/null and b/docs/images/system-design.png differ diff --git a/host/README.md b/host/README.md index 8e1ee03..26bd73d 100644 --- a/host/README.md +++ b/host/README.md @@ -1,40 +1,83 @@ -# Host firmware changes +# Host firmware build changes -The matchday mod runs on an unmodified [stack-chan](https://github.com/stack-chan/stack-chan) -host **codebase** — no source patches. Two build-level additions are still -needed, shipped here as reviewable git patches instead of edit scripts: +[English](README.md) | [简体中文](README.zh-CN.md) -| Patch | Why | -| --- | --- | -| `patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch` | The stock CoreS3 partition table has no `xs` (type 0x40) partition, so **no mod can install at all**. This reserves 2MB at `0xDF0000` on the 16MB flash. Required. | -| `patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch` | Compiles a 24px monochrome GB2312 font (`StackChanCN-24`) into the host so balloons and labels render Chinese. Optional — without it the mod falls back to `OpenSans-Regular-24` and CJK text will not render. | +Stack-chan Matchday uses the official +[`stack-chan/stack-chan`](https://github.com/stack-chan/stack-chan) host runtime. +It does not change the runtime JS/C source, but a CoreS3 build still needs one +partition-manifest patch so a mod can be installed. A second resource patch is +available for Chinese text. -Apply to a checkout of `stack-chan` (branch `dev/v1.0`): +| Patch | Required? | Purpose | +| --- | --- | --- | +| `patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch` | Yes | Adds a 2 MB `xs` partition at `0xDF0000` on 16 MB CoreS3 flash. Without a type `0x40` / subtype `1` partition, `mcrun` has nowhere to install a mod. | +| `patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch` | Chinese UI only | Builds a 24 px monochrome `StackChanCN-24` resource. Without it, the mod falls back to `OpenSans-Regular-24`, which does not render CJK text. | + +The patches are tested against upstream commit +`ded5ca94ef50411aec213b85a23d1afe72d4c29e`. Pin that commit so the installation +remains reproducible even if `dev/v1.0` moves. + +## 1. Prepare the upstream checkout ```sh -cd /path/to/stack-chan -git am /path/to/stackchan-matchday/host/patches/*.patch +mkdir -p "$HOME/src" +export MATCHDAY_DIR="$HOME/src/stackchan-matchday" +export STACKCHAN_DIR="$HOME/src/stack-chan" + +git clone https://github.com/stack-chan/stack-chan.git "$STACKCHAN_DIR" +cd "$STACKCHAN_DIR" +git switch --detach ded5ca94ef50411aec213b85a23d1afe72d4c29e + +cd "$STACKCHAN_DIR/firmware" +npm ci +npm run setup -- --device=esp32 +npm run doctor ``` -If you applied the font patch, prepare the (uncommitted) TTF before building: +Continue only after `npm run doctor` lists `esp32` as a supported target. + +## 2. Apply the required partition patch + +Use a clean checkout and apply only patch 1 first: ```sh -python3 /path/to/stackchan-matchday/host/prepare_cjk_font.py /path/to/stack-chan +cd "$STACKCHAN_DIR" +git am "$MATCHDAY_DIR/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch" ``` -macOS Arial Unicode is the default source and reproduces the original look; -pass `--ttf /path/to/NotoSansSC-Regular.ttf` (or any CJK-capable TTF) on other -systems. The TTF is never committed because system fonts are not -redistributable. +## 3. Optional: add the Chinese font -Then build and deploy the host once, from the checkout's `firmware/` directory: +Chinese is the example configuration's default language, so Chinese users +should apply patch 2. Pure-English installations can skip this section and set +the watcher language to `en`. ```sh -npm ci +cd "$STACKCHAN_DIR" +git am "$MATCHDAY_DIR/host/patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch" +python3 "$MATCHDAY_DIR/host/prepare_cjk_font.py" "$STACKCHAN_DIR" +``` + +On macOS the helper uses Arial Unicode by default. On other systems, or to use +a different typeface, pass any CJK-capable TTF explicitly: + +```sh +python3 "$MATCHDAY_DIR/host/prepare_cjk_font.py" "$STACKCHAN_DIR" \ + --ttf /absolute/path/to/NotoSansSC-Regular.ttf +``` + +The TTF stays uncommitted because system-font licenses usually do not permit +redistribution. If patch 2 is present but `StackChanCN.ttf` has not been +prepared, the host build will fail rather than silently omit Chinese glyphs. + +## 4. Build and deploy the host + +```sh +cd "$STACKCHAN_DIR/firmware" export PATH="$PWD/node_modules/.bin:$PATH" -mcconfig -d -m -p esp32:./platforms/m5stackchan_cores3 -t deploy "$PWD/stackchan/manifest_m5stackchan_cores3.json" +mcconfig -d -m -p esp32:./platforms/m5stackchan_cores3 -t deploy \ + "$PWD/stackchan/manifest_m5stackchan_cores3.json" ``` -Devices that were flashed with the earlier stackchan-kalshi tooling already -have this exact partition layout and font, so the mod installs on them -directly — no host reflash needed. +After this one-time host flash, return to the root README to generate the +device-specific QR and install the Matchday mod. Rebuilding only the mod does +not require reflashing the host. diff --git a/host/README.zh-CN.md b/host/README.zh-CN.md new file mode 100644 index 0000000..216d2a7 --- /dev/null +++ b/host/README.zh-CN.md @@ -0,0 +1,79 @@ +# Host firmware 构建改动 + +[English](README.md) | [简体中文](README.zh-CN.md) + +Stack-chan Matchday 使用官方 +[`stack-chan/stack-chan`](https://github.com/stack-chan/stack-chan) host +runtime,不修改 runtime JS/C 源码。不过 CoreS3 构建仍需一个分区 manifest +补丁,才能为 Mod 提供安装位置;另有一个用于中文显示的可选资源补丁。 + +| 补丁 | 是否必需 | 用途 | +| --- | --- | --- | +| `patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch` | 必需 | 在 16 MB CoreS3 Flash 的 `0xDF0000` 添加 2 MB `xs` 分区。没有 type `0x40` / subtype `1` 分区,`mcrun` 无处安装 Mod。 | +| `patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch` | 仅中文界面需要 | 构建 24 px 单色 `StackChanCN-24` 资源。没有它时,Mod 会回退到无法显示 CJK 的 `OpenSans-Regular-24`。 | + +这些补丁以固定上游 commit +`ded5ca94ef50411aec213b85a23d1afe72d4c29e` 为测试基线。请 pin 这个 commit, +避免 `dev/v1.0` 移动后影响复现。 + +## 1. 准备上游 checkout + +```sh +mkdir -p "$HOME/src" +export MATCHDAY_DIR="$HOME/src/stackchan-matchday" +export STACKCHAN_DIR="$HOME/src/stack-chan" + +git clone https://github.com/stack-chan/stack-chan.git "$STACKCHAN_DIR" +cd "$STACKCHAN_DIR" +git switch --detach ded5ca94ef50411aec213b85a23d1afe72d4c29e + +cd "$STACKCHAN_DIR/firmware" +npm ci +npm run setup -- --device=esp32 +npm run doctor +``` + +只有当 `npm run doctor` 把 `esp32` 列为 supported target 后,才继续。 + +## 2. 应用必需的分区补丁 + +请使用干净 checkout,并先只应用补丁 1: + +```sh +cd "$STACKCHAN_DIR" +git am "$MATCHDAY_DIR/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch" +``` + +## 3. 可选:加入中文字体 + +示例配置默认使用中文,因此中文用户应应用补丁 2。纯英文安装可以跳过本节,并将 +watcher 的语言设为 `en`。 + +```sh +cd "$STACKCHAN_DIR" +git am "$MATCHDAY_DIR/host/patches/0002-Add-optional-StackChanCN-24-GB2312-font-resource.patch" +python3 "$MATCHDAY_DIR/host/prepare_cjk_font.py" "$STACKCHAN_DIR" +``` + +macOS 上,脚本默认使用 Arial Unicode。其他系统或需要更换字体时,可显式传入任意 +支持 CJK 的 TTF: + +```sh +python3 "$MATCHDAY_DIR/host/prepare_cjk_font.py" "$STACKCHAN_DIR" \ + --ttf /absolute/path/to/NotoSansSC-Regular.ttf +``` + +系统字体通常不允许重新分发,因此 TTF 不会提交进仓库。如果已应用补丁 2 却没有 +准备 `StackChanCN.ttf`,host 构建会直接失败,而不是悄悄省略中文字形。 + +## 4. 构建并烧录 host + +```sh +cd "$STACKCHAN_DIR/firmware" +export PATH="$PWD/node_modules/.bin:$PATH" +mcconfig -d -m -p esp32:./platforms/m5stackchan_cores3 -t deploy \ + "$PWD/stackchan/manifest_m5stackchan_cores3.json" +``` + +完成这次 host 烧录后,回到根 README 生成设备专属二维码并安装 Matchday Mod。 +以后仅重新构建 Mod 时,不需要再次烧录 host。 diff --git a/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch b/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch index 9e5d31c..a15086b 100644 --- a/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch +++ b/host/patches/0001-Add-xs-mod-partition-for-M5StackChan-CoreS3.patch @@ -6,8 +6,8 @@ Subject: [PATCH 1/2] Add xs mod partition for M5StackChan CoreS3 The stock CoreS3 partition table has no type-0x40 xs partition, so mods cannot install at all (xsHost.c mounts mods from the first 0x40/1 partition). Reserve 2MB at 0xDF0000 on the 16MB flash; the factory app -partition stays 0xDE0000. This matches devices already flashed by the -earlier stackchan-kalshi tooling, so existing units keep their mod slot. +partition stays 0xDE0000. The layout leaves the final 64KB available for +the existing storage partition. Co-Authored-By: Claude Fable 5 --- @@ -37,7 +37,7 @@ index 0000000..9ee530c @@ -0,0 +1,9 @@ +# M5StackChan CoreS3 (16MB flash) with a 2MB `xs` mod partition so user +# applications install with `npm run mod` instead of reflashing the host. -+# Layout matches devices previously flashed with the stackchan-kalshi setup. ++# The final 64KB remains available for the existing storage partition. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x006000, +phy_init, data, phy, 0xF000, 0x001000, @@ -46,4 +46,3 @@ index 0000000..9ee530c +storage, data, spiffs, 0xFF0000, 0x010000, -- 2.50.1 (Apple Git-155) - diff --git a/host/prepare_cjk_font.py b/host/prepare_cjk_font.py index 236304e..00d17b8 100644 --- a/host/prepare_cjk_font.py +++ b/host/prepare_cjk_font.py @@ -9,9 +9,8 @@ Usage: python3 host/prepare_cjk_font.py /path/to/stack-chan [--ttf /path/to/font.ttf] -The default TTF is macOS Arial Unicode, which reproduces the original -Stack-chan Kalshi companion look. Any CJK-capable TTF works, for example -Noto Sans SC. +The default TTF is macOS Arial Unicode, which provides a compact CJK-capable +look for Matchday. Any CJK-capable TTF works, for example Noto Sans SC. """ from __future__ import annotations diff --git a/mod/audio.js b/mod/audio.js index 0e0b20d..d8bc8dc 100644 --- a/mod/audio.js +++ b/mod/audio.js @@ -3,8 +3,7 @@ // // The slim mod ships no audio assets. Dynamic speech streams from the LAN TTS // server (tools/stackchan_tts_server.py) through the host's `tts-remote` -// module; every clip id from the legacy voice pack maps to a short tone -// pattern so existing watcher configs keep working. +// module; every named reaction also has a short tone fallback. import Modules from 'modules' import { asyncWait } from 'stackchan-util' import Timer from 'timer' @@ -23,7 +22,7 @@ const TTS_DEFAULT_PORT = 8787 const TTS_SAMPLE_RATE = 24000 const TTS_VOLUME = 0.7 -// Tone patterns per legacy fan-clip id: [hz, ms] pairs played in order. +// Tone patterns per named reaction: [hz, ms] pairs played in order. const TONE_CLIPS = new Map([ ['ready', [[880, 120]]], ['match-start', [[523, 150], [659, 150], [784, 220]]], @@ -181,7 +180,7 @@ export function executeTtsCommand(ttsCommand) { return { ok: false, text: 'error tts persistence unavailable\n' } } savePreference('port', port, TTS_DOMAIN) - // Hosts patched with the legacy remote-TTS tweak read stackchan/ttsHost. + // Mirror the older preference key for hosts that still read stackchan/ttsHost. savePreference('ttsHost', value) remoteTts = undefined remoteTtsKey = '' diff --git a/mod/commands.js b/mod/commands.js index fdfbffc..75dfa73 100644 --- a/mod/commands.js +++ b/mod/commands.js @@ -1,6 +1,5 @@ // Text command dispatcher shared by HTTP /api/command and /api/control. -// Command strings stay wire-compatible with the legacy stackchan_control MOD -// (minus avatar / top-touch / IMU / MCP, which the matchday mod dropped). +// The stable command strings let watcher and device tooling evolve separately. import { EMOTIONS, MOD_NAME, diff --git a/mod/mod.js b/mod/mod.js index eaa7f71..825cefc 100644 --- a/mod/mod.js +++ b/mod/mod.js @@ -5,10 +5,10 @@ // tones, goal / final-result celebrations, and a phone setup page. Pairs with // the LAN watcher in tools/stackchan_kalshi_watch.py. // -// Designed to run on a stock stack-chan host (dev/v1.0): no host patches, no -// custom partition table. If the host bundles the optional StackChanCN-24 -// font (see host/cjk-font/), balloons and labels render Chinese; otherwise -// they fall back to the stock font. +// Designed for the official stack-chan host (dev/v1.0). CoreS3 needs the +// build-level mod-partition patch in host/; runtime JS/C stays unchanged. If +// the host also bundles the optional StackChanCN-24 resource, balloons and +// labels render Chinese; otherwise they fall back to the stock font. import WiFi from 'wifi' import { AP_PASSWORD, diff --git a/mod/ui.js b/mod/ui.js index 37c7964..1d3fd0f 100644 --- a/mod/ui.js +++ b/mod/ui.js @@ -527,6 +527,11 @@ export function refreshSetupQr(robot) { function createSetupQrEffect(url) { const shortUrl = url.replace(/^https?:\/\//, '') const title = SETUP_QR_TITLE[state.matchSetup.language] ?? SETUP_QR_TITLE.zh + const qrTexture = new Texture('setup-qr.png') + const qrWidth = qrTexture.width + const qrHeight = qrTexture.height + const qrLeft = Math.round((320 - qrWidth) / 2) + const qrTop = 35 + Math.max(0, Math.round((168 - qrHeight) / 2)) return new Container(null, { name: 'MatchSetupQr', left: 0, @@ -545,16 +550,16 @@ function createSetupQrEffect(url) { style: new Style({ font: FONT, color: '#17202a', horizontal: 'center', vertical: 'middle' }), }), new Content(null, { - left: 76, - right: 76, - top: 35, - height: 168, + left: qrLeft, + top: qrTop, + width: qrWidth, + height: qrHeight, skin: new Skin({ - texture: { path: 'setup-qr.png' }, + texture: qrTexture, x: 0, y: 0, - width: 168, - height: 168, + width: qrWidth, + height: qrHeight, }), }), new Label(null, { diff --git a/tools/stackchan_kalshi_watch.py b/tools/stackchan_kalshi_watch.py index 54b3672..d7b5ac5 100755 --- a/tools/stackchan_kalshi_watch.py +++ b/tools/stackchan_kalshi_watch.py @@ -732,7 +732,7 @@ def in_quiet_hours(quiet: QuietHours, now: datetime | None = None) -> bool: def http_json(url: str) -> dict[str, Any]: - req = urllib.request.Request(url, headers={"User-Agent": "stackchan-kalshi-watch/0.1"}) + req = urllib.request.Request(url, headers={"User-Agent": "stackchan-matchday-watch/0.1"}) with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as res: return json.loads(res.read().decode("utf-8")) @@ -2513,7 +2513,7 @@ def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: method="POST", headers={ "Content-Type": "application/json; charset=utf-8", - "User-Agent": "stackchan-kalshi-watch/0.1", + "User-Agent": "stackchan-matchday-watch/0.1", }, ) with STACKCHAN_DEVICE_HTTP_LOCK: diff --git a/tools/test_stackchan_mod_layout.py b/tools/test_stackchan_mod_layout.py index 21c8053..a90fc43 100644 --- a/tools/test_stackchan_mod_layout.py +++ b/tools/test_stackchan_mod_layout.py @@ -26,6 +26,17 @@ def test_probability_updates_keep_setup_qr_modal(self): self.assertIn("keepSetupQrOnTop(robot)", body) + def test_setup_qr_uses_the_generated_texture_dimensions(self): + body = UI_SOURCE.split("function createSetupQrEffect", 1)[1].split( + "export function hideSetupQr", 1 + )[0] + + self.assertIn("new Texture('setup-qr.png')", body) + self.assertIn("width: qrWidth", body) + self.assertIn("height: qrHeight", body) + self.assertNotIn("width: 168", body) + self.assertNotIn("height: 168", body) + def test_balloon_and_ticker_keep_setup_qr_modal(self): balloon = UI_SOURCE.split("export function showBalloon", 1)[1].split( "export function hideBalloon", 1