Repository navigation
App Specification
Snapshot of
docs/app/spec.mdat commit707b4a0, preserved before the docs were compacted. History only: the current specs are in the repository.
Status: agreed (2026-10-18), P0–P6 accepted (2026-10-19) · Branch: main · Date: 2026-10-19
The ReactPod Config App is a configuration tool for coaches and trainers, separate from the firmware. It connects to a ReactPod set, shows the block types and programs, lets the user build new programs visually, simulates them, keeps them on the device and pushes them to the pods.
It is developed as a web app (PWA) first and later packaged as a native Android / iOS app. Everything the app may do on a pod is defined by docs/firmware/config-api.md. Program format and block semantics: docs/firmware/programs.md.
| # | Feature |
|---|---|
| F1 | Connect to a central over BLE (Web Bluetooth) or USB serial (Web Serial) |
| F2 | Read the pod: info, list, get of all program files and the menu |
| F3 | Show the block types (palette) and the library (/prog/lib/*, read-only) |
| F4 | Show existing programs as a block graph (one column, automatic order) |
| F5 | Visual editor: create and edit programs (blocks, ports, parameters, variables, menu LEDs) |
| F6 | Live validation while editing (TypeScript port of tools/validate_programs.py) |
| F7 | Simulator: all pods of the program on screen, current block highlighted, run / pause / step |
| F8 | Local storage of programs (offline), JSON import / export |
| F9 |
Push changed files to the pod, update /programs.json, run validate on the pod |
| F10 | Menu editor: order of programs, 18-LED ring picker for each program's menu pattern |
| F11 | Restore defaults: put the shipped programs back on the pod |
| F12 | i18n from the start: English (primary, fallback) and German |
| F13 | Offline PWA prepared (installable once hosted), mobile first |
- Live control (
status,select,start,press,results, MaxLoop override), results display. - Native apps (the architecture prepares for them, see §3).
- Editing library fragments (
/prog/lib/*is read-only). - Cloud storage / accounts (later a remote database may replace the local store, see §6). Planned as the community platform: docs/platform/spec.md (§5.4 app integration). The platform is on hold (ADR 0007): new app features are designed without it; the existing platform client (preview) stays in place and keeps working.
- Firmware OTA (not planned, ADR 0006).
Users: coaches and trainers (customers), not developers. They use a phone in a sports hall. The UI must be usable with one hand and must not lose data by accident.
| Platform | Editor / simulator / local store | BLE | USB serial |
|---|---|---|---|
| Android Chrome (PWA) | ✅ | ✅ | – (desktop only in v1) |
| Desktop Chrome / Edge | ✅ | ✅ | ✅ |
| iPhone / iPad Safari (PWA) | ✅ (offline only) | ❌ no Web Bluetooth | ❌ |
| Native app (later, Capacitor) | ✅ | ✅ native | – |
Web Bluetooth and Web Serial need HTTPS (or localhost). The app is a PWA (manifest,
service worker, offline cache, relative base path). It is published on GitHub Pages:
https://runningjoke.github.io/reactionButton/ , built and deployed by
.github/workflows/app.yml on every push
to main that touches app/ or firmware/data/. Development runs on localhost.
| Concern | Choice | Reason |
|---|---|---|
| Language | TypeScript (strict) | shared types for program format, API messages |
| UI | React 19 + Vite | large ecosystem, works unchanged inside Capacitor |
| Native shell (later) | Capacitor | same web code becomes the Android / iOS app |
| BLE | @capacitor-community/bluetooth-le |
one API: Web Bluetooth in the browser, native BLE in the app |
| USB serial | Web Serial API (own small adapter) | desktop testing |
| Block graph | own column view (HTML cards + SVG arrows) | compact on phones, native scrolling, no canvas library needed |
| State | Zustand | small, no boilerplate |
| Local store | IndexedDB via idb
|
offline, large enough, behind an interface |
| i18n |
i18next + react-i18next
|
standard, lazy language files |
| Icons | lucide-react |
consistent line icons, tree-shakeable |
| Styling | plain CSS with design tokens (CSS variables), CSS modules | no heavy UI kit, easy theming |
| PWA |
vite-plugin-pwa (Workbox) |
offline cache, install prompt |
| Tests | Vitest (unit), Playwright (later, e2e on the simulated pod) |
All platform access sits behind interfaces (§4.2), so the native app replaces adapters and nothing else.
app/
├── public/ icons, manifest assets
├── src/
│ ├── app/ shell, routing, navigation, theme
│ ├── i18n/ de.json, en.json, setup
│ ├── model/ program format: types, block type definitions, checks, formatting
│ │ ├── blockTypes.ts ports, fields, defaults per block type (app-owned copy)
│ │ ├── program.ts ProgramFile, FragmentFile, Block, ProgramIndex types
│ │ ├── colors.ts, limits.ts colours, store limits, path rules, name → path
│ │ ├── validate.ts port of tools/validate_programs.py (+ warnings), issue codes
│ │ └── format.ts writes files in the style of firmware/data/ (one block per line)
│ ├── pod/ everything about talking to a pod
│ │ ├── transport/ Transport interface, BleTransport (capacitor BLE plugin), WebSerialTransport
│ │ ├── simulated/ SimulatedPod (config API in memory) + SimulatedTransport
│ │ ├── utf8.ts, crc32.ts byte counting, UTF-8 safe splitting, line decoder, CRC-32
│ │ ├── protocol.ts request / response types, PodError
│ │ ├── client.ts PodClient: one request at a time, ids, timeouts, resend on "overflow"
│ │ ├── files.ts chunked get / put, UTF-8 byte offsets, CRC check both ways
│ │ ├── session.ts sessions per set (#46, list + active one): connect, reconnect, info / status / list, read-only role, log
│ │ ├── sync.ts compare local vs pod, push / restore plan, push execution
│ │ └── syncActions.ts sync for the UI: states of the connected set, keep pod / mine, push, delete
│ ├── store/ local persistence (Storage interface + IndexedDB adapter), workspace (local programs + menu)
│ ├── results/ athletes, training sessions, reports (#47): report model of #41, IndexedDB "reactpod-results",
│ │ ReportFeed (`report` events → store → `ack`), statistics, CSV
│ ├── platform/ typed client of the platform API, sign-in settings, upload queue (#48)
│ ├── editor/ column view + layout, editing operations (ops.ts), undo / redo, inspector, fields, palette, program settings
│ ├── programs/ catalog, sources (pod / shipped / local), program actions (new, edit copy, export, delete)
│ ├── pages/ one component per screen (Pod, Programs, program view, Editor, Menu, Settings)
│ ├── components/ LED ring + LED ring picker, sheet, modal
│ ├── simulator/ engine.ts (TS port of the block semantics), seeded random, useSimulation (real-time loop)
│ └── defaults/ shipped programs, bundled from ../firmware/data at build time (raw text)
└── docs/
└── acceptance-tests.md acceptance criteria and test procedures on real pods
flowchart TB
UI["UI (React): editor, simulator, menu, pod screen"] --> M["model: program types, validation"]
UI --> S["store: Storage interface"]
UI --> P["pod: PodClient, sync"]
UI --> SIM["simulator engine"]
SIM --> M
P --> T["Transport interface"]
T --> BLE["BLE adapter (capacitor-community/bluetooth-le)"]
T --> SER["Web Serial adapter"]
T --> FAKE["Simulated pod (in-memory, implements config-api.md)"]
S --> IDB["IndexedDB adapter (later: native FS / remote DB)"]
interface Transport { // byte/line level
connect(): Promise<void>; // shows the platform device chooser
disconnect(): Promise<void>;
send(line: string): Promise<void>; // one JSON object + "\n", chunked to MTU−3 (BLE)
onLine(cb: (line: string) => void): void; // complete lines; non-JSON lines are logs (serial)
onDisconnect(cb: () => void): void;
readonly kind: "ble" | "serial" | "simulated";
readonly mtu?: number;
}
interface PodClient { // API level, on top of Transport
request<T>(op: string, args?: object): Promise<T>; // serialised, id-matched, timeout, retry on "overflow"
info(); list(); getFile(path); putFile(path, text); deleteFile(path); validate(path);
}
interface Storage { // local programs, swappable for a remote DB later
listPrograms(); saveProgram(p); deleteProgram(id);
getMenu(); saveMenu(paths); // the local menu (§6)
getSyncState(setId); saveSyncState(state); // per set, in IndexedDB "meta" as sync:<setId>
}The simulated pod implements the API from config-api.md in memory (files, generation, CRC, chunking, error codes). It is used for development, unit tests and demos without hardware.
Floating tab bar on phones with five tabs (Pod, Programs, Editor, Simulate, Results; Menu is linked from the Pod page, Settings is the gear icon in the header), side rail with all seven on wide screens (≥ 900 px). Icon first, short label below. Look and styling: app/DESIGN.md.
| Tab | Icon | Content |
|---|---|---|
| Pod | pod (puck) | set id, firmware, free space, generation; file list with sync state; push, restore defaults. Not connected: a Connect button that opens the connection dialog |
| Programs | list | local programs + programs read from the pod + library (read-only); create, duplicate, import, export |
| Editor | workflow | graph of one program (§7) |
| Simulate | play | pods + graph + controls (§8) |
| Menu | circle-dot | order of programs in /programs.json, LED ring picker per program |
| Results | trophy | athletes on this phone; per athlete statistics (counted reports only), training sessions → reports → single values, discard / count again, CSV export (one row per measurement); link to a platform athlete profile. The Pod tab gets a Training section: who trains now, the live report, continue / repeat / stop (only with the pod features reports / remote, #41 / #42) |
| Settings | settings | language, platform (preview: server, sign-in by mail code or token, club, upload queue), about, advanced (delete files, see §9.4) |
UX rules:
- Touch targets ≥ 44 px. Primary action in the thumb zone (bottom right floating button).
- Every editing action works without drag and drop too (bottom sheets with selects), because drawing wires on a small screen is error prone.
- State shown by colour + icon + text (never colour alone).
- The connection state is always visible (small status chip in the header). Tapping the chip opens the connection dialog: connect (Bluetooth / USB / demo pod) or, when connected, the pod and Disconnect.
- When the BLE connection is not possible, the app explains how to get the central into the program menu (hold button during the blue boot animation).
type ProgramDoc = {
id: string; // local uuid
path: string; // target path on the pod, e.g. "/prog/sprint.json"
json: ProgramJson; // exactly what goes to the pod (format of programs.md)
origin: "local" | "pod" | "default"; // made here, or a copy of a pod / shipped program
updatedAt: number;
};
type SyncState = { // per pod set (setId), remembered after each sync
setId: string;
files: Record<string /*path*/, { crc: string; size: number }>; // last known common state
};- Layout is not stored on the pod (keeps files small). The block order of the column view is computed every time a program is opened (§7.1), so there are no node positions to keep.
- Paths: generated from the program name. Slug ≤ 20 characters so that
/prog/<slug>.jsonstays ≤ 31 characters. Allowed characters as in config-api §4. -
Local programs are always editable. Shipped and pod programs are read-only in the app;
Edit makes a local copy with the same path (origin
pod/default), which replaces the file on the pod at push (decision 10). If a local copy of that path exists, Edit opens it. Duplicate makes a new program with its own name and path. Library files are never local. - A new program's path must not collide with a local, pod or shipped path.
- Local menu (decision 11): one menu, the set being built. Until the user changes it, the menu tab shows the connected pod's menu, else the shipped one. Changing the menu pattern of a program that is not local makes a local copy (same path).
- Every change is saved to IndexedDB at once; undo / redo (100 steps) lives in memory per open program.
- Import / export: a single program
.json(v1). A whole set as a.zip(programs.json+prog/…, the same layout asfirmware/data/): Export set on the Programs tab writes the local menu, its programs with their includes and every local program. Import set takes the menu and the programs (library parts are ignored); local programs with the same path are replaced after a confirmation. A single top folder in the archive is accepted.
Proposed redesign for untrained users and coaches (simple step view, switches for optional features, game wizard, expert mode): editor-ux.md. Not implemented yet.
- All blocks in one column, one card per block. Header: icon + type colour + block name + type.
Body: the key parameters on one wrapping line (e.g.
3000 ms,RUNS >= MAX_LOOPS). - Order: flow order from the start block. Loops are ignored for the order, the rest is placed topologically, first port first: the main path reads straight down and a short branch sits between its fork and the block where it rejoins.
- A block with one connection to the block directly below just continues downwards (no arrow).
- Every other connection is an arrow beside the column: it leaves from a port row of the card
(
timeout → CentralMiss) and enters the header of the target. Arrows going down run on the right, loops back up on the left. A block with a choice (several ports) has all its ports as arrows, also the one to the block below. Arrows into the same target share a lane. - The fallback rule of the firmware is visible: if a port is unconnected and the block
has
next, a "→ next" hint is shown on the card. - The start block has a flag icon. Blocks without any target (program ends) get an end marker.
- Blocks unreachable from
startare dimmed. - Targets that no loaded file defines (a library part viewed on its own) are dashed "external" cards.
- The view scrolls natively; tapping a card selects it, its arrows are highlighted, and the block sheet shows its details.
/prog/lib/* is read-only. A program can use a fragment in two ways:
| Mode | What happens | Graph |
|---|---|---|
| Preset (include) |
include: ["/prog/lib/loop.json"] is kept. The program links to its blocks (e.g. EnterProgram → EndLoop) |
fragment blocks shown greyed with a lock and the file name ("Set variable · loop"). No frame: a preset like loop has blocks at the start and the end of a program, a frame would span the whole graph |
| Template (copy) | the fragment's blocks and variables are copied into the program; name clashes get a suffix | normal editable blocks |
Existing programs that use includes open in preset mode. "Convert to editable copy" turns all presets into templates (flattens the program). New programs are self-contained by default.
On push, preset fragments the pod does not have are uploaded too (from the bundled defaults).
| Action | Touch | Desktop |
|---|---|---|
| Add block | floating button or "add block after" in the inspector → palette sheet (grouped: Light, Wait, Input, Logic, Remote, Stats, library as copy) | same, palette in the side panel |
| Edit parameters | tap node → inspector bottom sheet | inspector side panel |
| Connect port | inspector: per port a "go to" select with all block names (+ "new block…") | same as touch |
| Delete / duplicate / rename | inspector actions | inspector actions, Delete key |
| Set start block | inspector | inspector |
| Undo / redo | header buttons | Ctrl+Z / Ctrl+Y |
Renaming a block updates every port that points to it. With a block selected, a new block is
inserted after it (selected → new → the old next, decision 12); otherwise it is added at the end,
unconnected (the first block of an empty program becomes the start). "New block…" in a port
select adds a block and connects that port to it. Own blocks that a preset continues at (e.g.
EnterProgram for loop) cannot be renamed or deleted. Renaming a variable updates every block
that reads or writes it. Changing the block type keeps the fields and ports the new type has.
A block that continues straight down through a port other than next (e.g. a button with only
timeout) shows the port name on the card, so the column view never hides which exit is taken.
-
value fields (number or variable): a number field with a toggle to choose a variable
from the program's variables + built-ins (
REQUIRED_PERIPHERALS,NODE_COUNT). -
Colours: swatches. Fields sent to peripherals (
remotePress.color,colorAfter,hitColor,avoidColor) offer only the 8 named colours. Centralled.coloralso offers a free colour picker (#RRGGBB). -
LED indices (
led.leds,menu[].leds): the 18-LED ring picker (tap LEDs on a ring, "all" shortcut). The same component is used in the menu editor. -
Metric names (
record,summary.metric): free text with suggestions from the metrics already used (defaultreaction). - Program header: name, description, number of peripherals (0–7, more cannot be entered), variables table, start block, menu pattern.
Runs on every change. It mirrors tools/validate_programs.py: known types, required
fields, unique names over program + includes, all targets exist, start exists, colours
valid (named only towards peripherals), value fields are numbers or known variables,
path length, file size ≤ 16384 bytes, block types supported by the connected pod
(info.blockTypes). Problems are listed in a sheet, and tapping one selects the block.
Errors block the push. Warnings (e.g. "program ends here", unreachable blocks) do not.
The simulator runs the program in the browser with the same semantics as the firmware
(src/BlockManager/BlockManager.cpp and the block classes):
- Instant blocks (
led,setVariable,if,summary, …) chain in the same pass, waiting blocks (delay,button,remote*) wait. Fallback tonextfor unconnected ports. No target = program ends. Same limit of steps per pass as the firmware (MAX_STEPS_PER_PASS), so an endless loop of instant blocks behaves like on the pod. -
REQUIRED_PERIPHERALSandNODE_COUNTcome fromperipherals. Pod ids as in programs.md §1.3. - Button rules: only presses that start after the block was entered count.
pressedon the edge,short/longon release (shortMs). -
remoteMultiPress: random draw of avoid pods incl. the central,syncStartMs,avoidHit.
- All pods of the program side by side (central +
peripheralspods, wraps on phones): each pod is an 18-LED ring with its id, and a big round button in the middle. -
Tap / click a pod = press its button (press and hold = long press, for
short/long). - The graph (compact) with the current block highlighted, and the edge just taken animated.
- Panels: variables (live values, changed ones flash), metrics (reaction times measured
from "lit" to the simulated press), log of entered blocks (like the firmware
trace).
| Control | Effect |
|---|---|
| Run / pause | simulated time runs in real time (speed ×0.25 … ×4) |
| Step | runs until the next block is entered, then pauses (instant blocks one by one) |
| Skip wait | ends the current waiting block through its timeout / time-out port (no waiting for a delay) |
| Reset | back to start, variables to initial values |
| Seed | random numbers come from a seeded generator, so a run can be repeated exactly |
| Breakpoint | tap a block in the graph → pause when entered |
In step mode a waiting block stays current until the user presses a pod or uses "skip wait".
The simulator models logic, not radio timing. Reaction times shown are user click times, not pod measurements.
-
src/simulator/engine.tsfollows programs.md §7 rule by rule: one engine pass per simulated ms, 32 steps per pass,entertakes times and random values, unconnected ports fall back tonext, unknown variables read 0,uint8_tpod ids,timeoutMs: 0means no timeout forbuttonbut at once for the remote blocks, 20 ms button debounce. - The set always has the program's
peripheralsconnected. Peripherals behave likePeripheralRole: an armed pod lights (at once or atsyncStartMs), a press showscolorAfterand reports the reaction time; the central then resets it. Presses of earlier activations are dropped when a block polls them, like on the pod. -
Step runs the current pass until the next block is entered. A block waiting for a press stays
current ("waiting"); pods that light by themselves (
syncStartMs) are lit by the step, because that is no wait for the user. While paused, a press or release is handled at once (one step). -
Skip wait jumps the simulated clock to the end of a delay or timeout. A block that waits
without a timeout (
button,remotePress,remoteMultiPress) leaves through itstimeoutport (fallback rules apply); abuttonalready pressed keeps waiting for the release. - The log has every entered block and the
RESULT/SUMMARYlines. Copy trace gives the block events in the format of the pod'strace on(AT-M5). - Entry points: the Simulate tab (program picker: own programs and the menu), the play button in the editor and in the program view. Programs with errors are not simulated.
- User picks BLE or USB. BLE: device chooser filtered by the service UUID. The pod
advertises as
ReactPod <set name>orReactPod <last 4 digits of setId>(firmware ≥ 2.2). - Subscribe to Response (indications, firmware ≥ 2.1), request MTU 517 (native) / use what the browser negotiates.
-
info→ checkfirmware,linkProtocol,blockTypes, limits (maxRequest,maxGetChunk). BLE: writeinfo.mtu − 3bytes per write (firmware ≥ 2.7, else 20). -
status→ role.list→ files with size + CRC, menu, generation. - Files whose CRC differs from the local copy are read with chunked
get.
Optional info fields (missing = older firmware, the app keeps the old behaviour):
| Field | Firmware | Use in the app |
|---|---|---|
setName |
2.2 | shown as the set's name; Pod tab → rename (setName) |
programFormat |
2.3 | warning if the pod is newer than the app's block types (PROGRAM_FORMAT) |
features: validateInline |
2.4 |
PodClient.validate(path, data) dry run, for push (P5) |
features: listMeta |
2.5 | program names in the Pod tab file list |
pinRequired |
2.6 | PIN asked before the first write (auth), once per connection |
mtu |
2.7 | BLE write size |
Writes only go to a central. The app reads status.role after connecting. Over USB a
peripheral can be connected and inspected (read-only), but push, restore and delete are
disabled.
USB serial: 115200 baud, lines that do not start with { are firmware log (shown in a
debug console in Settings → Advanced). The app must not toggle DTR/RTS (risk R1, §11).
Using the remembered SyncState (last common CRC) the app can tell who changed a file:
| Local vs pod | State | Default action |
|---|---|---|
| same CRC | in sync | – |
| local changed, pod = last sync | local newer | push |
| pod changed, local = last sync | pod newer | take over |
| both changed / never synced | conflict | user chooses "keep pod" or "keep mine" (no diff view, decision 9) |
| only local | new | push |
| only on pod | pod only | take over (shown in the list) |
- Validate locally. Errors stop the push.
- Build the plan: changed / new programs + missing preset fragments, then
/programs.jsonlast (config-api §7). Show the plan ("3 files will be uploaded"). - Upload each file: chunks ≤ 2048 characters at character boundaries, offsets in UTF-8
bytes, CRC-32 on the final chunk.
overflow→ resend that request.busy→ stop and explain. -
validateeach program in the menu, map errors back to the block in the editor. - Save the new
SyncState. Tell the user to start any program once with all pods on, so the set syncs.
Push never deletes (decision 8). Files on the pod that the app does not know stay.
Implementation notes (src/pod/sync.ts):
- A pod file counts as in sync when its CRC equals the app's formatted text or its JSON is equal (key order and formatting ignored), so a pod copy that was never edited is not a change.
- The last common CRC is remembered per set and path: after every upload, on "take over" / "keep pod", on "keep mine" (the pod's current CRC, so the local file wins) and when a local copy is made from a pod program. An untouched copy of a shipped program uses the shipped file.
- The menu is the set being built (decision 11): a saved local menu that differs from the pod's is pushed, unless the pod's menu changed since the last sync (then conflict). "Keep pod" for the menu forgets the local menu, so the Menu tab follows the pod again.
- With
features: validateInline(firmware ≥ 2.4) every program is checked by the pod's loader (dry run) before any program or the menu is written; library parts are uploaded first, because the dry run reads includes from the store. Without it,validateruns after the upload only. - Menu entries the pod does not have are uploaded from the shipped programs, missing presets from the shipped library. The plan lets the user deselect programs; conflicts and pod-newer files are never pushed and are listed as skipped.
- The sync state is saved after each file, so an interrupted push (busy, lost connection, wrong PIN) shows the right states afterwards; pushing again finishes it.
- With
info.pinRequiredthe push dialog asks for the PIN (auth) before the first write.
- Removing a program from the menu is a normal action (the file stays on the pod).
-
Deleting files on the pod is only in Settings → Advanced: select files, then confirm in a
dialog that names the files and needs an extra tap on a "hold to delete" button.
Library and menu (
/programs.json) cannot be deleted. -
Restore defaults (Pod tab): uploads the shipped programs + library and the default
/programs.json. User programs on the pod are not deleted, only removed from the menu. Confirmation dialog. The defaults are bundled with the app from../firmware/dataat build time.
Several sets at once are on hold (decision 2026-10-29). The hardware test on a phone showed only one set and no way to switch, so Add another set was removed. It needs more investigation before it comes back (#46).
-
Sessions. One session per connected set (
src/pod/session.ts): transport, PodClient,info,status,list, files and log. The sessions form a list (usePodSessions) with one active session, which the Pod, Programs and Sync pages read throughusePodSession. One set at a time:connectPodends every existing session before it opens the new one. The list stays as the structure for several sets later. -
Reconnect. A BLE link that drops is opened again while the page lives, without the device
chooser: first try after 1 s, the delay doubles per failed try up to 30 s. The set keeps its
card; its state is Reconnecting. When the link is up again the app runs
info,status,list, thenreports→ store →ack(the reports the pod kept while the link was down, seeresults/podLink.ts). Files come from the cache. A user disconnect ends the session without a retry. Serial and the demo pod end the session on a drop. -
After a reload the app knows no set. Each set is picked again with one tap; the pod still holds
its unacknowledged reports and sends them with
reports. -
Training view (route
/training, linked from the Pod page; the header keeps the Pod tab highlighted). One card for the connected set:- set name, state (
status.state: MENU, DISCOVERY, CONFIRM, RUN, PAUSED, ERROR, or connecting / reconnecting / not connected), Waiting for you while acommunicateblock waits; - athlete: same list and assignment as the Training section of the Pod tab;
- last report: values and statistics, link to the session;
- while a block waits: Continue, Repeat, Stop (
command) and one number input per editable variable ofstatus.waiting.editable(setVariables, range checked before sending); -
Pause / Resume (
pause/resume) in RUN or while the phone holds a pause; - Disconnect ends it.
- set name, state (
-
Feature detection (
info.features, a missing field means the old behaviour): withoutreportsthe card says that the firmware cannot send results; withoutremotethere are no commands; withoutpausethere is no pause button; withoutliveBle(firmware < 2.12) the card notes that Bluetooth goes off during a game. -
Status is read again on
stateandreportevents and after each command. -
Wake lock. While the training view is open the app requests a screen wake lock
(
navigator.wakeLock, screen). Without the API nothing happens; a refused request changes nothing. Known PWA limit: the screen can still lock, and a hidden tab or a sleeping phone suspends the page and drops BLE. The sessions reconnect while the page lives; reports sent in the meantime wait on the pod until the reconnect fetches them. -
Demo pod. Each connection to the demo pod gets a set of its own:
5f3a91c2(no name),9b2e7d40"Halle 2",c71f0a8e"Halle 3",3e6d9b15"Halle 4". While a demo report waits the demo game is in RUN;stopends it (MENU).
-
Upload (
platform/upload.ts) sends every report the platform does not have yet, grouped per session, asPOST /training/upload. It follows the auto-upload setting (or Upload now). -
Queued deletions (
PlatformDelete[], kept in IndexedDB) are repeated when the phone is online and signed in: right after a sign-in, at app start, when a deletion is queued, and when the connection returns. This does not depend on the auto-upload setting. A deletion is kept while the platform cannot be reached; 404 means it is gone already. -
Deleting a linked athlete on the phone (
results/athleteDeletion.ts): the athlete with all sessions and reports is removed here. The platform gets:- the self-delete (
DELETE /v1/orgs/{orgId}/me/athletes/{id}) when the signed-in user claimed the profile (the ids come from/v1/meat sign-in,claimedAthleteIds), or - the coach delete (
DELETE /v1/orgs/{orgId}/athletes/{id}) otherwise. A 403 there falls back to the self-delete, in case the claim is newer than the last sign-in. Queued uploads of the athlete go with its sessions, so they are never sent.
- the self-delete (
-
Tombstones. The local ids of deleted athletes are kept (
deletedAthletes). A report that a pod sends later for such an athlete (itsathletefield) is acknowledged on the pod (ack) but neither stored nor uploaded. Reports without an athlete tag are unaffected. -
Pods. If the deleted athlete is the one set on a connected pod with
remote, the app sendssession {athlete: ""}, so the pod stops tagging reports with them.
- All UI text in
src/i18n/{en,de}.json. English is the primary language and the fallback for missing keys. Language from the browser, switchable in Settings. Block type names and JSON field names stay technical (button,timeoutMs), and every one gets a translated label and help text. - Design: minimal, functional, icon driven. Neutral surfaces, one accent colour, LED colours only where they mean LED colours. Light and dark theme (system setting). Design tokens in one CSS file, so a brand theme can be dropped in later.
- Accessibility: contrast AA, focus visible, every icon button has a label (also for screen readers).
| # | Risk | Mitigation |
|---|---|---|
| R1 | Opening the port with Web Serial may set DTR/RTS and reset the ESP32-C3 (config-api "Serial specifics") | test early on hardware (AT-S1), if needed set signals explicitly after open, document in acceptance tests |
| R2 | Several centrals in range all advertise "ReactPod Config", so the user can't pick the right set | solved by CR-1 (firmware 2.2): ReactPod <xxxx> / set name |
| R3 | Simulator semantics drift from the firmware | engine unit tests use the shipped programs; acceptance test compares a stepped run with the pod's trace
|
| R4 | Browser-negotiated BLE MTU is small (23) → slow upload | chunking works with any MTU, show progress; measure in AT-B3 |
| R6 | Firmware indicates on the Response characteristic, browsers / Android subscribe for notifications → BLE responses never arrive | solved by CR-0 (firmware 2.1): indications only. Firmware 2.0 still times out over BLE |
| R5 | Two pods of a set edited separately → only one store survives (config-api §6) | show setId + generation, hint after push to let the set sync |
| # | Topic | Decision |
|---|---|---|
| O1 | Manual node positions | none: the column view computes the block order (§7.1) |
| O2 | Hosting | GitHub Pages (runningjoke.github.io/reactionButton), deployed from main by GitHub Actions. Own domain later |
| O3 | Languages | English (primary, fallback) and German |
| O4 | Long press in the simulator | press and hold = held button, release = release (shortMs decides) |
| O5 | Number of peripherals | the editor prevents more than 7. No other warning |
| O6 | Write target | only a central. Peripherals are read-only in the app |
| 4 | Library |
/prog/lib/* read-only, used as preset (include) or template (copy) |
| 5 | Layout on the pod | none, automatic order in one column |
| 7 | Block type definitions | app keeps its own copy, separate from the firmware |
| 8 | Push | update only, never delete unless the user asks |
| 9 | Conflicts | simple choice "keep pod" / "keep mine", no diff |
| 10 | Editing a shipped / pod program | local copy with the same path; replaces the pod file at push |
| 11 | Menu before push exists | one local menu, starting from the pod's (or the shipped) menu |
| 12 | Where a new block goes | after the selected block (rewired), else at the end unconnected |
| Phase | Content | Done when | Status |
|---|---|---|---|
| P0 | Scaffold: Vite + React + TS, PWA, i18n, theme, navigation |
npm run build + npm test pass, production build works offline from localhost, language switch works |
done |
| P1 | Model + validation + simulated pod + PodClient + file transfer | unit tests: all firmware/data/ programs parse and validate; get/put with chunking and CRC against the simulated pod |
done |
| P2 | Transports: Web Serial, BLE | AT-S*, AT-B* pass on hardware | USB done (AT-S1–S4 pass), BLE ready for test with firmware 2.7 (AT-B*) |
| P3 | Programs list, read-only graph view with auto layout, library browser | all shipped programs display correctly | done |
| P4 | Editor + menu editor + LED ring picker | a new program can be created on a phone without drag and drop | done |
| P5 | Sync + push + validate + restore defaults | AT-P* pass on hardware | done (accepted 2026-10-19) |
| P6 | Simulator | AT-M* pass, stepped run matches pod trace | done (accepted 2026-10-19); AT-M1–M4 also covered by unit tests |
| P7 | Athletes, result history, platform upload (#47, #48 app side) | AT-R* pass | done (AT-R* passed 2026-10-26); real pods need reports / remote (#41, #42) for live results |
| P8 | Block builder for first-timers and coaches (editor-ux.md): format 2 in model + simulator, step sheet, step list, game wizard, add a step by intent, program sheet, Adjust, expert mode | AT-E* pass | done (AT-E* passed 2026-10-26) |
| P9 | Training view and reconnect (#46): BLE reconnect with reports fetch and ack, training view /training (live state, communicate controls, pause / resume), wake lock (§9.5). Several sets at once: on hold |
AT-L* pass (multi-set tests on hold) | ready for test |
Acceptance criteria and test procedures on real pods: docs/acceptance-tests.md.
Firmware / API changes the app needs are requested as GitHub issues in this repository
(label firmware): what is missing, why the app needs it, a proposed API, acceptance. The app
must keep working without the change (check info, missing field → old behaviour). The earlier
change requests CR-0 … CR-7 are all done and archived in
../docs/archive/changerequest/config-app-api.md.
cd app
npm install
npm run dev # http://localhost:5173 (Web Bluetooth / Web Serial work on localhost)
npm test # unit tests (Vitest)
npm run build # type check + production build with service worker → dist/
npm run preview # serve dist/ on http://localhost:4173, e.g. to test offline mode
npm run icons # redraw public/icons/* (scripts/make-icons.mjs)
node scripts/screenshots.mjs <dir> # screenshots of the preview build (local Chrome, playwright-core)
node scripts/offline-check.mjs # AT-U1: preview build loads offlineDeployment: merging to main runs the Pages workflow (npm ci, npm test, npm run build,
upload dist/). It can also be started by hand (Actions → "Config app (GitHub Pages)" → Run).
Routing uses the URL hash (#/programs), so the build runs from any path, offline and later
inside the Capacitor webview. A new app version is only activated after the user confirms
(update prompt), so an edit in progress is never reloaded away.
For users and coaches
Repository and workflow
Firmware
- Firmware Overview
- Firmware Architecture
- Firmware Link Protocol
- Firmware Config API
- Firmware Programs
- Firmware Testing
- Firmware Timing
- Firmware Host Syntax Check
Config App
Community platform (on hold)
Decisions (ADRs)
- Decisions
- ADR 0001 Repo Layout and Workflow
- ADR 0002 Versions and Firmware Delivery
- ADR 0003 Community Platform
- ADR 0004 Platform Architecture
- ADR 0005 Live Results over BLE
- ADR 0006 No OTA
- ADR 0007 Platform on Hold
Archive