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
465 changes: 465 additions & 0 deletions .claude/grade-report.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "squares",
"runtimeExecutable": "python3",
"runtimeArgs": ["server.py"],
"port": 4312
}
]
}
37 changes: 33 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
pull_request:

permissions:
contents: read

Check warning on line 8 in .github/workflows/test.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this read permission from workflow level to job level.

See more on https://sonarcloud.io/project/issues?id=jtn0123_squares_controller&issues=AZ_pWQsAIRElyYEi7psU&open=AZ_pWQsAIRElyYEi7psU&pullRequest=6

jobs:
python:
Expand All @@ -15,19 +15,48 @@
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
# coverage is a CI-only dev dependency; the runtime stays stdlib-only.
- run: python -m pip install coverage==7.15.4
# coverage and mypy are CI-only dev dependencies; the runtime stays
# stdlib-only.
- run: python -m pip install coverage==7.15.4 mypy==2.3.0
- run: python -m mypy
- run: python -m coverage run --source=src -m unittest discover -s test -p 'test_*.py' -v
- run: python -m coverage report --fail-under=75

browser-models:
frontend:
runs-on: ubuntu-latest
strategy:
matrix:
# 20 is the documented floor; 22 is current.
node-version: ["20", "22"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
node-version: ${{ matrix.node-version }}
cache: npm
# --ignore-scripts: typescript is the only dependency and needs no
# lifecycle scripts; this blocks supply-chain install hooks.
- run: npm ci --ignore-scripts
# The strict TypeScript compile IS the type gate; it also emits the
# JS and assembles index.html that the tests and server run against.
- run: npm run build
- run: node --test --experimental-test-coverage test/*.test.mjs

# Stable check name for branch protection. Matrix jobs report per-entry
# names ("frontend (20)"), so pinning protection to them would break
# every time the matrix changes; this gate keeps one fixed contract.
browser-models:
needs: frontend
# always() + an explicit result check: a skipped required job would
# otherwise be reported to branch protection as success.
if: always()
runs-on: ubuntu-latest
steps:
- run: '[ "${{ needs.frontend.result }}" = "success" ]'
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ config.json
.squares/
node_modules/
npm-debug.log*

# Generated by `npm run build` — sources are the .ts files beside them
public/*.js
public/index.html
40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ A local-first browser controller for Twinkly Squares. It keeps the stock
Twinkly firmware and coordinate map, so there is nothing to flash, open, or
solder.

![Version](https://img.shields.io/badge/version-0.11.0-d9ff5b)
![Tests](https://img.shields.io/badge/tests-142%20passing-d9ff5b)
![Version](https://img.shields.io/badge/version-0.12.0-d9ff5b)
![Tests](https://img.shields.io/badge/tests-151%20passing-d9ff5b)
![Types](https://img.shields.io/badge/types-tsc%20strict%20%2B%20mypy%20strict-d9ff5b)
![License](https://img.shields.io/badge/license-MIT-d9ff5b)

## What it can do
Expand Down Expand Up @@ -53,8 +54,9 @@ been physically tested with a 4×3, 768-pixel Twinkly Squares wall: 32×24 at

## Quick start

Requires Python 3.11 or newer. Node 18 or newer is optional; `npm` is only used
as a convenient command runner and test runner.
Requires Python 3.11 or newer and Node 20 or newer. The frontend is strict
TypeScript compiled once by `npm run build`; the Python server then serves the
emitted JavaScript from disk with no Node process at runtime.

```bash
cp config.example.json config.json
Expand All @@ -68,18 +70,23 @@ Edit `config.json` and set the private IPv4 address of your Twinkly controller:
}
```

Then start the app:
Then build the frontend and start the app:

```bash
npm start
npm ci && npm start
```

Or without Node:
`npm start` rebuilds the frontend and launches the server. After a build, the
server can also run on its own:

```bash
python3 server.py
```

While editing frontend code, `npm run watch` recompiles TypeScript on save;
`npm run watch:html` does the same for the markup partials (or run
`npm run build` once after markup edits).

On macOS, you can also double-click `scripts/start.command`.

Open [http://127.0.0.1:4312](http://127.0.0.1:4312). Press `Control-C` to stop;
Expand Down Expand Up @@ -154,7 +161,10 @@ panel targets. See [SECURITY.md](SECURITY.md).
npm test
```

The current suite contains 68 Python and 74 browser tests. It covers HTTP
`npm test` builds the frontend (the strict `tsc` compile is the frontend
type gate) and runs the Python and browser suites against the compiled
output; CI additionally runs strict `mypy` over the backend and a coverage
gate. The suite covers HTTP
routes, device protocol behavior, coordinate mapping, brightness, rotation, state
synchronization, persistence, scheduling, API validation, palettes, zones,
blending, transitions, effects, audio analysis, live-input rendering, media
Expand All @@ -164,10 +174,12 @@ relay telemetry, and safe movie payloads.
## Frame-rate notes

The connected controller advertises 40 FPS but reports a measured 38.46 FPS
clock. Realtime output therefore runs on an independent, deadline-aligned
37.5 FPS relay clock to leave narrow sampling headroom. The UI reports actual
delivery gaps, repeated inputs, and missed deadlines; those host measurements
do not claim that every frame lit physically. Controller-local movies use the
clock. The browser produces frames just under the panel's sustainable rate
(1.5 FPS of headroom), and the relay forwards each fresh frame to the panel
the moment it arrives — one clock end to end, with idle keepalive repeats
only to hold realtime mode open. The UI reports fresh-frame cadence, delivery
gaps, repeats, and missed deadlines; those host measurements do not claim
that every frame lit physically. Controller-local movies use the
controller's integer 38 FPS playback path and remove browser, HTTP, Python
scheduling, and Wi-Fi cadence from ongoing playback.
See [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for the measurements.
Expand All @@ -177,7 +189,9 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for the measurements.
The browser talks only to the local Python server. The server authenticates
directly with the Twinkly controller over HTTP and streams RGB frames over the
controller's local realtime protocol on UDP port 7777. The runtime uses the
Python standard library and native browser APIs.
Python standard library and native browser APIs; TypeScript is a build-time
dev dependency only, and every source file (backend, frontend, and markup
partials) stays under 500 lines.

## WLED inspiration and attribution

Expand Down
33 changes: 33 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "squares-controller",
"version": "0.11.0",
"version": "0.12.0",
"private": false,
"description": "Local-first browser controller for Twinkly Squares",
"license": "MIT",
Expand All @@ -10,10 +10,17 @@
},
"type": "module",
"scripts": {
"start": "python3 server.py",
"test": "python3 -m unittest discover -s test -p 'test_*.py' && node --test test/*.test.mjs"
"start": "npm run build && python3 server.py",
"build": "tsc -p tsconfig.json && node scripts/build_html.mjs",
"watch": "tsc -p tsconfig.json --watch --preserveWatchOutput",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"watch:html": "node scripts/build_html.mjs --watch",
"check": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && python3 -m unittest discover -s test -p 'test_*.py' && node --test test/*.test.mjs"
},
"devDependencies": {
"typescript": "~5.9.2"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
44 changes: 38 additions & 6 deletions public/app.js → public/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// Composition root: wires every feature module together and boots the app.
// All logic lives in the feature modules; this file only sequences them.
import { state } from "./app_state.js";
import { onConnectionLost } from "./net.js";
import {
noteUserGesture,
onConnectionLost,
onControlLost,
toast,
} from "./net.js";
import { stopAnimation, stopMedia } from "./playback.js";
import { render } from "./render_core.js";
import { updateSceneMonitor } from "./monitor.js";
import {
Expand All @@ -21,7 +27,11 @@ import { initializeMediaControls } from "./media_input.js";
import { initializeLiveInputs } from "./live_audio.js";
import { initializeTextStudio } from "./text_studio.js";
import { initializeSceneSaving, renderPresets } from "./scenes.js";
import { initializePlaylists, renderPlaylistDraft } from "./playlists.js";
import {
initializePlaylists,
renderPlaylistDraft,
stopPlaylist,
} from "./playlists.js";
import { initializeMovieBaking } from "./movies_bake.js";
import {
initializeAutomations,
Expand Down Expand Up @@ -73,11 +83,24 @@ renderPlaylistDraft();
// controller answers again.
onConnectionLost(probeStatus);

void loadStatus().then(() => {
void loadMovies().catch(() => {
$("#movieCapacityReadout").textContent = "STORAGE UNAVAILABLE";
});
// Only one tab may drive the wall. Clicks and keypresses mark this tab as
// the human's choice (its frames carry a takeover claim); when another tab
// wins instead, stop every producer loop here rather than fight it.
document.addEventListener("pointerdown", noteUserGesture, {
capture: true,
passive: true,
});
document.addEventListener("keydown", noteUserGesture, {
capture: true,
passive: true,
});
onControlLost(() => {
stopPlaylist();
stopMedia();
stopAnimation();
toast("Another tab took the wall. Click any effect to take it back.", true);
});

void initializeLibrary();
startStateSync();
setInterval(() => {
Expand All @@ -87,3 +110,12 @@ setInterval(() => {
void loadAutomations().catch(() => {});
}, 60_000);
setInterval(() => void loadStreamTelemetry().catch(() => {}), 2_000);

// Boot status last so every listener above is wired before the first
// payload lands; movie capacity is cosmetic and may fail on its own.
await loadStatus();
try {
await loadMovies();
} catch {
$("#movieCapacityReadout").textContent = "STORAGE UNAVAILABLE";
}
Loading