From 61a74b84c768cbcfed3c77ce1a83e5377a0911d0 Mon Sep 17 00:00:00 2001 From: SALhik <123875913+SALhik@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:34:22 +0800 Subject: [PATCH 1/9] Add security hardening, CI, and show-ops features Security hardening: - Gate POST /api/_test_reset behind CUELIGHT_TEST_MODE (never live in prod) - Require the join password on mutating endpoints + show log/report when set - Enforce label uniqueness on position reconnect, not just first join CI: GitHub Actions running ruff + pytest on Python 3.10 and 3.12. Show-ops features: - Operator attention signal (raise a flag + short message to the caller, works while locked; caller banner sits above the lock overlay) - Opt-in cue alert sounds/vibration on the position console - Show clock/timer (START SHOW, elapsed + since-last-GO, configurable) - On-demand show report computed from the log (nothing stored) - One-command launcher: run.sh / run.bat / python -m server --- .github/workflows/ci.yml | 27 +++ CLAUDE.md | 37 ++++- FEATURES.md | 91 +++++++++- README.md | 19 ++- run.bat | 20 +++ run.sh | 19 +++ server/__main__.py | 34 ++++ server/main.py | 43 ++++- server/models.py | 12 ++ server/persistence.py | 2 + server/showreport.py | 167 +++++++++++++++++++ server/state.py | 65 +++++++- server/ws.py | 16 ++ static/caller.html | 35 ++++ static/css/caller.css | 62 +++++++ static/css/common.css | 4 + static/css/position.css | 108 ++++++++++++ static/js/caller.js | 170 ++++++++++++++++++- static/js/editor.js | 30 +++- static/js/observer.js | 27 ++- static/js/position.js | 145 ++++++++++++++++ static/observer.html | 3 + static/position.html | 24 ++- tests/test_cuelight.py | 349 +++++++++++++++++++++++++++++++++++++++ 24 files changed, 1469 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 run.bat create mode 100755 run.sh create mode 100644 server/__main__.py create mode 100644 server/showreport.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66be07b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.10 is the documented minimum, 3.12 the development default + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: python -m pip install -r requirements.txt ruff pytest + - name: Lint + run: ruff check . + - name: Test + run: python -m pytest tests/test_cuelight.py -v diff --git a/CLAUDE.md b/CLAUDE.md index e4293e6..2c3a91f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ CueLight is a browser-based theatre cue light system. A Python server runs on on # Install dependencies pip install -r requirements.txt -# Run the server +# Run the server (equivalently: ./run.sh, run.bat, or the uvicorn line) +python -m server uvicorn server.main:app --host 0.0.0.0 --port 8000 # Run the test suite @@ -25,7 +26,7 @@ python -m pytest tests/test_cuelight.py::TestButtonStateMachine -v python -m pytest tests/test_cuelight.py::TestButtonStateMachine::test_standby_go_ack_cycle -v ``` -There is no build step, no linter configured, no type checker configured. The frontend is vanilla JS. +There is no build step and no type checker configured. Ruff is the linter (`ruff.toml`; run `ruff check .`). CI (`.github/workflows/ci.yml`) runs ruff and the test suite on Python 3.10 and 3.12. The frontend is vanilla JS. ## Architecture @@ -117,7 +118,7 @@ Colors are stored in `Position.color` (a hex string like `"#5b8def"`), persisted ### Persistence -`persistence.py` writes `state/snapshot.json` on every state change, debounced at 100ms. `save_state()` serializes immediately (so the timer thread never reads live state) and only the disk write is deferred. On startup, `load_state()` restores positions (marked disconnected), lock, password, cue index, color, `auto_standby`, `osc_patch_filename`, and `showfile_filename`. The showfile and OSC patch contents are **not** stored in the snapshot — the lifespan calls `_restore_files()` in main.py to reload both from `showfiles/` and `patches/` by the stored filenames (patch first, so showfile arming includes OSC positions). `restore_showfile()` keeps the persisted cue index (clamped to the cue count) instead of resetting to 0, so a server restart mid-show resumes at the right cue. OSC positions are filtered out of the snapshot on write. +`persistence.py` writes `state/snapshot.json` on every state change, debounced at 100ms. `save_state()` serializes immediately (so the timer thread never reads live state) and only the disk write is deferred. On startup, `load_state()` restores positions (marked disconnected), lock, password, cue index, color, `auto_standby`, `osc_patch_filename`, `showfile_filename`, and `show_started_at` (`last_go_at` and attention are deliberately transient). The showfile and OSC patch contents are **not** stored in the snapshot — the lifespan calls `_restore_files()` in main.py to reload both from `showfiles/` and `patches/` by the stored filenames (patch first, so showfile arming includes OSC positions). `restore_showfile()` keeps the persisted cue index (clamped to the cue count) instead of resetting to 0, so a server restart mid-show resumes at the right cue. OSC positions are filtered out of the snapshot on write. **EXIT and resume:** the EXIT button calls `wipe_state()`, which cancels any pending debounced write and renames the snapshot to `state/snapshot.bak` (not deletes). `GET /api/backup_info` reports whether a backup exists; `POST /api/resume_show` calls `StateManager.resume_show()`, which promotes the backup back via `restore_backup()`, keeps the current caller connection, closes live position sockets (their devices auto-reconnect and merge into the restored state), restores the show log, and then the endpoint reloads patch+showfile via `_restore_files()`. The caller UI offers resume on the post-EXIT screen and in Settings ("Previous Show" section). @@ -129,7 +130,7 @@ Colors are stored in `Position.color` (a hex string like `"#5b8def"`), persisted Labels are unique (case-insensitive). Enforced at four points: - `POST /api/check_label` — checked by join page before navigating -- `register_position()` in state.py — returns `"duplicate"` if a new client_id uses a taken label (includes OSC labels) +- `register_position()` in state.py — returns `"duplicate"` if the label is taken by any *other* client_id (new joins and reconnects alike; includes OSC labels) - `rename_position()` in state.py — returns `False` if the new name conflicts - `load_patch()` in state.py — skips devices whose name collides with an existing human label - Caller-side rename modal also validates client-side before sending @@ -140,20 +141,38 @@ Three endpoints: `/ws/caller`, `/ws/position`, and `/ws/observer`. On connect, t **Observers are read-only:** any number may connect (`observer_ws` dict in StateManager); they receive every `full_state` the caller gets, built by `get_full_state_for_observer()` which blanks `password`. Everything an observer sends is ignored. If `password_enabled`, the handshake password is checked and rejected with `role_rejected`. `register_caller`/`unregister_caller` push a full_state to observers so they see `caller_connected` flip; the observer page then offers a manual TAKE OVER button that simply navigates to `/` (a vacant caller seat accepts any client_id). `full_state` includes `caller_connected` and `auto_standby` fields. -**Caller messages (client → server):** `standby`, `go`, `standby_armed`, `go_armed`, `reset_armed`, `toggle_arm`, `rename`, `set_color`, `lock`, `exit`, `set_password`, `load_showfile`, `unload_showfile`, `jump_to_cue`, `prev_cue`, `pause`, `set_auto_standby`, `flash_all`, `clear_flash`, `remove_position`, `load_patch`, `unload_patch` +**Caller messages (client → server):** `standby`, `go`, `standby_armed`, `go_armed`, `reset_armed`, `toggle_arm`, `rename`, `set_color`, `lock`, `exit`, `set_password`, `load_showfile`, `unload_showfile`, `jump_to_cue`, `prev_cue`, `pause`, `set_auto_standby`, `flash_all`, `clear_flash`, `remove_position`, `load_patch`, `unload_patch`, `clear_attention`, `start_show_clock`, `clear_show_clock` -**Position messages (client → server):** `ack_standby`, `ack_go`, `ack_flash`, `rename`, `disconnect`, `pong` +**Position messages (client → server):** `ack_standby`, `ack_go`, `ack_flash`, `raise_attention`, `clear_attention`, `rename`, `disconnect`, `pong` -**Server → position messages:** `joined`, `standby_called`, `go_called`, `flash`, `state_reset`, `lock_changed`, `label_changed`, `color_changed`, `cue_info`, `caller_disconnected`, `show_ended`, `removed`, `join_rejected`, `ping`, `health` +**Server → position messages:** `joined`, `standby_called`, `go_called`, `flash`, `state_reset`, `lock_changed`, `label_changed`, `color_changed`, `cue_info`, `caller_disconnected`, `show_ended`, `removed`, `join_rejected`, `ping`, `health`, `attention_cleared`, `show_started` **Server → caller messages:** `role_assigned`, `role_rejected`, `full_state`, `osc_result`, `ping`, `error` -**HTTP API additions:** `GET /api/patches` (list), `GET /api/patch/{filename}`, `POST /api/patch/{filename}` (validate+save), `GET /api/showlog` (`?format=csv`), `GET /api/backup_info`, `POST /api/resume_show`, `POST /api/csv/import`, `POST /api/csv/export` +**HTTP API additions:** `GET /api/patches` (list), `GET /api/patch/{filename}`, `POST /api/patch/{filename}` (validate+save), `GET /api/showlog` (`?format=csv`), `GET /api/showreport` (`?format=txt`), `GET /api/backup_info`, `POST /api/resume_show`, `POST /api/csv/import`, `POST /api/csv/export` + +**HTTP auth:** when a join password is set, the mutating endpoints (`POST /api/showfile/{f}`, `POST /api/patch/{f}` including `_probe_test`, `POST /api/resume_show`) and `GET /api/showlog` / `GET /api/showreport` require it — `X-CueLight-Password` header or `?password=` query param, checked by `_password_ok()` in main.py; wrong/missing → 401. The caller sends it from `full_state` (`pwAuth()`/`pwQuery()` in caller.js); the editor prompts on the first 401 and keeps it in `sessionStorage` (`authFetch()` in editor.js). `POST /api/_test_reset` answers 404 unless the `CUELIGHT_TEST_MODE=1` env var is set — it must never be callable in production. ### Flash roll call `flash_all` sets `Position.flash = "pending"` on every connected human position and sends them a `flash` message (OSC and disconnected positions are reset to `"none"`); the position shows a blinking overlay until tapped, which sends `ack_flash` → `flash = "confirmed"`. `clear_flash` resets all. `flash` is transient — serialized in `to_dict()` but never restored from the snapshot. The caller UI lives in Settings (Health list shows waiting/here markers and re-renders on every `full_state` while the modal is open). +### Operator attention + +Positions can flag the caller: `raise_attention {message}` sets `Position.attention` + `attention_message` (truncated to `ATTENTION_MESSAGE_MAX`, human positions only) — **deliberately not blocked by the lock**, in either direction: raising gets through during a hold (the position's lock overlay has its own report button), and the caller's banner sits above the lock overlay (`z-index: 116` > lock's 115) so reports can be read and cleared without unlocking (`clear_attention` is not lock-gated server-side). Cleared by the operator (`clear_attention` from the position) or the caller (`clear_attention {client_id}`); a caller clear pushes `attention_cleared` to the operator ("seen" feedback). Like `flash`, attention is transient: serialized in `to_dict()` but never restored from the snapshot. Caller/observer UI: `⚠ ATTENTION` badge on the column (takes precedence over OSC/DISCONNECTED badges) plus an orange banner row per report with the message inline (`renderAttention()`); the caller's rows have CLEAR buttons, the observer's don't. Logged as `attention_raised` (detail = message) / `attention_cleared` (detail = caller|operator). + +### Show clock + +`AppState.show_started_at` (epoch; persisted so a mid-show restart keeps the clock; EXIT clears) and `AppState.last_go_at` (epoch of the most recent GO — stamped in `call_go` and `go_armed`; transient, not restored). Caller messages `start_show_clock` / `clear_show_clock`; starting logs `show_started` and broadcasts a `show_started` message to positions (they show a brief toast). Both fields ride in `full_state`; the caller renders a ticking clock strip in the bottom bar (1s interval), correcting device clock drift with an offset measured from heartbeat `ping` timestamps (`serverClockOffset`). Which segments show (time of day / elapsed / since-last-GO) is a device-local preference in `localStorage` (`cuelight_clock_prefs`), edited in Settings → Show Clock. + +### Show report + +`server/showreport.py` — `build_report(entries)` computes a post-show summary from the show log **on demand, storing nothing**: show start (prefers the `show_started` event, else first entry) / end / duration, event totals, per-position standby/GO counts with ack latency avg/max (pairing each `*_called` with the next `*_acked` per position), OSC fire and attention counts, and gaps between consecutive `master_go`s. `report_to_text()` renders it as plain text. Served by `GET /api/showreport` (`?format=txt`; password-gated), opened from Settings → Show Log → "Show report". + +### Cue alert (position, frontend-only) + +The position bezel's ALERT toggle (`cuelight_alert_mode` in `localStorage`, off by default) plays WebAudio beeps on `standby_called`/`go_called` (double 880Hz for standby, single 660Hz for GO) plus `navigator.vibrate` where supported. The AudioContext is created/resumed on the toggle tap or the first tap after load — WebAudio needs a user gesture and must not use secure-context-only APIs. + ### Auto-standby `AppState.auto_standby` (persisted, off by default, toggled via `set_auto_standby`). In `go_armed`, when `_advance_cue()` actually advances (returns True) and the option is on, the newly-armed positions get standby immediately: humans with idle standby → `CALLED` + `standby_called` (logged with `detail="auto"`); armed OSC positions get a probe batch. Jump/prev never auto-call. @@ -180,3 +199,5 @@ Server pings each position every 1s. Position echoes `pong` with the timestamp, Tests are in `tests/test_cuelight.py`. They test via real WebSocket connections to a running server (integration tests, not mocks). The test harness starts a uvicorn server on port 8001 in a background thread, runs async test methods using `asyncio.run()`, and tears down after. Tests use the `websockets` library to connect as caller/positions and assert on the JSON messages received. Always clean `state/snapshot.json` (plus `snapshot.bak`, `showlog.jsonl`, `showlog.bak`) before test runs to avoid state leakage — the suite's `_clean_state()` does this in setUp/tearDown of the module. + +The test module sets `CUELIGHT_TEST_MODE=1` at import so `/api/_test_reset` (used by every test's setUp) exists; the server runs in a thread of the test process, so it sees the variable. diff --git a/FEATURES.md b/FEATURES.md index bd25420..3a77080 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -21,6 +21,12 @@ pip install -r requirements.txt uvicorn server.main:app --host 0.0.0.0 --port 8000 ``` +No command-line experience needed: `./run.sh` (macOS/Linux) or double-clicking +`run.bat` (Windows) does all of the above — the first run creates a private +virtualenv and installs dependencies, then every run starts the server and +prints the URLs to open. `python -m server [port]` is the equivalent once +dependencies are installed. + The server listens on all interfaces on port 8000. Find the host machine's LAN IP (Settings shows it, or `/api/info` returns it) and share `http://:8000` with the other devices. @@ -146,6 +152,29 @@ watches the confirmations tick in live (see *Settings → Health & Latency*). An incoming standby or GO dismisses the overlay automatically, so a real cue can never be blocked by it. +### Cue alert sounds (optional) + +Operators in a dark wing aren't always staring at the phone. The **ALERT** +button in the bottom bezel (off by default, per device, survives reloads) +adds a short double beep on STANDBY and a single lower beep on GO, plus +vibration on devices that support it (Android). The first tap after enabling +arms the audio (browsers require a touch before a page may make sound). + +### Reporting a problem (attention) + +The **⚠** button in the bottom bezel lets an operator flag the caller without +leaving their post: tap it and either pick a quick preset (**NOT READY**, +**TECH PROBLEM**, **NEED HELP**) or type a short message and tap **RAISE ⚠**. +The button pulses orange while the report is up. On the Caller, the position's +column shows a pulsing ⚠ ATTENTION badge and the message appears in an orange +banner above the grid. + +- Tap ⚠ again to **withdraw** the report yourself. +- When the caller clears it, the operator sees "✓ Seen by caller". +- **Attention works even while the system is LOCKED** — the lock overlay has + its own "⚠ REPORT PROBLEM" button, because a safety report must never be + frozen out by a hold. + ### Screen dimming (running mode) Backstage must be dark, and a phone at normal brightness glows like a lantern. @@ -268,10 +297,12 @@ The **SETTINGS** button opens a modal with these sections: the editor's Patches tab. See *OSC outbound*. 4. **Network** — the server host/address to share with operators, including the `cuelight.local` mDNS name when it's active. -5. **Show Log** — download the show's event log as CSV, or view the raw JSON - (see *Show log*). -6. **Previous Show** — appears only when an EXIT backup exists; resumes it. -7. **Security** — the password toggle and field (see *Password protection*). +5. **Show Log** — download the show's event log as CSV, view the raw JSON, or + open the **show report** (see *Show log*). +6. **Show Clock** — choose what the bottom-bar clock strip displays: time of + day, elapsed since show start, time since last GO (see *Show clock*). +7. **Previous Show** — appears only when an EXIT backup exists; resumes it. +8. **Security** — the password toggle and field (see *Password protection*). ### Join info / QR code @@ -291,6 +322,31 @@ When a showfile is loaded and the current cue targets a position that isn't connected, a warning banner appears on the Caller (e.g. "Cue 3: Fly not connected"), so you know before firing the cue. +### Operator attention reports + +When an operator raises **⚠** on their console (see *Reporting a problem*), +the Caller sees it two ways at once: the position's column header shows a +pulsing **⚠ ATTENTION** badge (and an orange top edge), and an orange banner +above the grid lists each raised report with its message inline — readable at +a glance, no tapping required. Each row has a **CLEAR** button; clearing tells +that operator their report was seen. The banner sits **above the LOCK +overlay**, so the caller can read and clear reports during a hold without +unlocking. Observers see the same badge and banner (read-only). Reports and +their clears are recorded in the show log. + +### Show clock + +The centre of the bottom bar is a live clock strip. Tap it to open the +**Show Clock** dialog and hit **START SHOW** — the start time is recorded in +the show log, every position briefly sees "⏱ Show clock started", and the +strip begins counting. The clock survives a server restart mid-show; EXIT +clears it. (Tapping the strip again offers *Clear clock…* for a mis-tap.) + +What the strip shows is configurable in **Settings → Show Clock** (per +device): time of day, elapsed time since show start, and time since the last +GO — any combination. Times are computed against the server's clock, so an +iPad with a drifting clock still reads true. + ### Position colors Each position is auto-assigned a color from an 8-color palette when it joins @@ -686,6 +742,16 @@ manual calls. It doubles as a debugging trail: if a cue was missed, the log shows exactly when the standby went out and when (or whether) it was acknowledged. +### Show report + +**Settings → Show Log → Show report** opens a post-show summary computed **on +demand from the log — nothing extra is stored**. It includes the show's start +/ end / duration, totals (standbys, GOs, master GOs, cue advances, attention +reports), per-position acknowledge statistics (how many standbys/GOs each +operator received and their average / worst time-to-ack), OSC fire counts, +and the time between consecutive master GOs. Opens as plain text you can save +or print; `GET /api/showreport` returns the same data as JSON for tooling. + --- ## Password protection @@ -699,6 +765,11 @@ Restrict who can join a show. - **QR convenience:** when join info is shown with a password set, the QR code embeds the password (`/join?pw=…`) so scanning auto-fills it. - **Disable:** toggle the password off in Settings; the field is cleared. +- **What it protects:** joining as a Position or Observer, plus the write APIs + once a password is set — saving showfiles/patches from the editor (which + prompts for the password on first save and remembers it for the session), + resuming a previous show, and viewing/downloading the show log. The Caller + sends the password automatically. Verification happens via `POST /api/check_password`. @@ -758,20 +829,24 @@ useful for integration or debugging. | `POST /api/check_label` | Check a label is free before joining | | `GET /api/showfiles` | List available showfile names | | `GET /api/showfile/{filename}` | Fetch a showfile's JSON | -| `POST /api/showfile/{filename}` | Validate and save a showfile | +| `POST /api/showfile/{filename}` | Validate and save a showfile 🔒 | | `POST /api/csv/import` | Convert CSV text to showfile cues (or row errors) | | `POST /api/csv/export` | Convert showfile JSON to downloadable CSV | | `GET /api/patches` | List available OSC patch names | | `GET /api/patch/{filename}` | Fetch a patch's JSON | -| `POST /api/patch/{filename}` | Validate and save a patch (or probe-test with `_probe_test`) | -| `GET /api/showlog` | Show event log as JSON (`?format=csv` for CSV download) | +| `POST /api/patch/{filename}` | Validate and save a patch (or probe-test with `_probe_test`) 🔒 | +| `GET /api/showlog` | Show event log as JSON (`?format=csv` for CSV download) 🔒 | +| `GET /api/showreport` | Post-show report from the log (`?format=txt` for plain text) 🔒 | | `GET /api/backup_info` | Whether an EXIT backup exists (plus showfile/position count) | -| `POST /api/resume_show` | Restore the EXIT backup into the running server | +| `POST /api/resume_show` | Restore the EXIT backup into the running server 🔒 | | `GET /api/qr?password=…` | PNG QR code for the join URL | | `WS /ws/caller` | Caller real-time channel | | `WS /ws/position` | Position real-time channel | | `WS /ws/observer` | Observer real-time channel (read-only mirror) | +🔒 = when a join password is set, these require it: `X-CueLight-Password` +header or `?password=` query parameter (wrong or missing → HTTP 401). + --- ## Quick reference: typical show flow diff --git a/README.md b/README.md index 1e618a6..76f91c4 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,24 @@ For a full walkthrough of every feature and how to use it, see [FEATURES.md](FEA Python 3.10 or newer. Python 3.12 is recommended — the project is developed, tested, and built on 3.12. -## Install +## Quick start (no setup knowledge needed) -```bash -pip install -r requirements.txt -``` +Download/clone this folder onto the machine that will run the server, then: -## Run +- **macOS / Linux:** open Terminal in the folder and run `./run.sh` +- **Windows:** double-click `run.bat` + +The first run sets everything up (creates a private virtualenv, installs +dependencies — takes a minute); after that it starts instantly. The launcher +prints the two URLs to open: one for the Caller, one for operators to join. +Pass a port to use something other than 8000 (`./run.sh 9000`). + +## Manual install / run ```bash +pip install -r requirements.txt +python -m server # prints the join URLs, listens on port 8000 +# or the explicit equivalent: uvicorn server.main:app --host 0.0.0.0 --port 8000 ``` diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..e68004c --- /dev/null +++ b/run.bat @@ -0,0 +1,20 @@ +@echo off +rem One-command CueLight launcher (Windows). Double-click or run: run.bat [port] +cd /d "%~dp0" + +where python >nul 2>nul +if errorlevel 1 ( + echo Python 3 is required. Install it from https://www.python.org/downloads/ + echo ^(tick "Add python.exe to PATH" during install^) + pause + exit /b 1 +) + +if not exist .venv ( + echo First run: setting up, this takes a minute... + python -m venv .venv +) + +.venv\Scripts\pip install --quiet -r requirements.txt +.venv\Scripts\python -m server %* +pause diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..922c5a5 --- /dev/null +++ b/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# One-command CueLight launcher (macOS / Linux). +# First run creates a private virtualenv and installs dependencies; +# after that it just starts the server. Usage: ./run.sh [port] +set -e +cd "$(dirname "$0")" + +if ! command -v python3 >/dev/null 2>&1; then + echo "Python 3 is required. Install it from https://www.python.org/downloads/" + exit 1 +fi + +if [ ! -d .venv ]; then + echo "First run: setting up (this takes a minute)…" + python3 -m venv .venv +fi + +./.venv/bin/pip install --quiet -r requirements.txt +exec ./.venv/bin/python -m server "$@" diff --git a/server/__main__.py b/server/__main__.py new file mode 100644 index 0000000..1e33113 --- /dev/null +++ b/server/__main__.py @@ -0,0 +1,34 @@ +"""Run CueLight with `python -m server [port]` — no uvicorn incantation needed.""" +from __future__ import annotations + +import sys + +import uvicorn + +from .main import _get_local_ip, app + + +def main() -> None: + port = 8000 + if len(sys.argv) > 1: + try: + port = int(sys.argv[1]) + except ValueError: + print(f"Ignoring invalid port {sys.argv[1]!r}; using 8000") + ip = _get_local_ip() + banner = "\n".join([ + "", + " CueLight is running.", + f" Caller (stage manager): http://{ip}:{port}/", + f" Operators join at: http://{ip}:{port}/join", + " (devices must be on the same network)", + "", + " Press Ctrl+C to stop.", + "", + ]) + print(banner, flush=True) + uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/server/main.py b/server/main.py index b3d2742..11483fe 100644 --- a/server/main.py +++ b/server/main.py @@ -2,6 +2,7 @@ import io import json +import os import socket import threading from contextlib import asynccontextmanager @@ -16,6 +17,7 @@ from .patch import list_patches, load_patch, save_patch, validate_patch from .persistence import load_state from .showcsv import csv_to_cues, cues_to_csv +from .showreport import build_report, report_to_text from .showfile import list_showfiles, load_showfile, save_showfile, validate_showfile from .state import StateManager from .ws import caller_ws_handler, observer_ws_handler, position_ws_handler @@ -153,6 +155,19 @@ async def apple_touch_icon(): # --- API --- +def _password_ok(request: Request) -> bool: + """Mutating endpoints and the show log honor the join password when one is + set. Clients send it as an X-CueLight-Password header, or ?password= for + plain links that can't set headers (the show log download).""" + attempt = (request.headers.get("x-cuelight-password") + or request.query_params.get("password", "")) + return manager.check_password(attempt) + + +def _password_required() -> JSONResponse: + return JSONResponse({"error": "password required"}, status_code=401) + + @app.get("/api/info") async def api_info(request: Request): ip = _get_local_ip() @@ -206,6 +221,8 @@ async def api_get_showfile(filename: str): @app.post("/api/showfile/{filename}") async def api_save_showfile(filename: str, request: Request): + if not _password_ok(request): + return _password_required() try: require_safe_filename(filename) except ValueError: @@ -257,6 +274,8 @@ async def api_get_patch(filename: str): @app.post("/api/patch/{filename}") async def api_save_patch(filename: str, request: Request): + if not _password_ok(request): + return _password_required() try: data = await request.json() except json.JSONDecodeError: @@ -296,7 +315,9 @@ async def api_backup_info(): @app.post("/api/resume_show") -async def api_resume_show(): +async def api_resume_show(request: Request): + if not _password_ok(request): + return _password_required() ok = await manager.resume_show() if ok: await _restore_files(manager) @@ -304,7 +325,9 @@ async def api_resume_show(): @app.get("/api/showlog") -async def api_showlog(format: str = "json"): +async def api_showlog(request: Request, format: str = "json"): + if not _password_ok(request): + return _password_required() if format == "csv": return Response( manager.log.to_csv(), @@ -314,11 +337,25 @@ async def api_showlog(format: str = "json"): return {"entries": manager.log.entries} +@app.get("/api/showreport") +async def api_showreport(request: Request, format: str = "json"): + """Post-show report, computed on demand from the show log — never stored.""" + if not _password_ok(request): + return _password_required() + report = build_report(manager.log.entries) + if format == "txt": + return Response(report_to_text(report), media_type="text/plain; charset=utf-8") + return report + + # --- Test support --- @app.post("/api/_test_reset") async def test_reset(): - """Reset all server state. For test use only.""" + """Reset all server state. Only answers when CUELIGHT_TEST_MODE=1 (set by + the test harness) — anyone on the LAN could otherwise end a live show.""" + if os.environ.get("CUELIGHT_TEST_MODE") != "1": + return JSONResponse({"error": "not found"}, status_code=404) await manager.exit_show() return {"ok": True} diff --git a/server/models.py b/server/models.py index e978694..cc6c51b 100644 --- a/server/models.py +++ b/server/models.py @@ -53,6 +53,9 @@ class Position: color: str = "" # Roll-call state: "none" | "pending" | "confirmed". Transient — not restored from snapshot. flash: str = "none" + # Operator-raised attention signal. Transient like flash — not restored from snapshot. + attention: bool = False + attention_message: str = "" def to_dict(self) -> dict[str, Any]: return { @@ -71,6 +74,8 @@ def to_dict(self) -> dict[str, Any]: "osc_trust": self.osc_trust, "color": self.color, "flash": self.flash, + "attention": self.attention, + "attention_message": self.attention_message, } @@ -169,6 +174,11 @@ class AppState: paused: bool = False auto_standby: bool = False osc_patch_filename: str = "" + # Show clock: epoch of the caller's START SHOW tap (0 = not started; + # persisted so a restart mid-show keeps the clock). last_go_at is the + # epoch of the most recent GO — transient, not restored. + show_started_at: float = 0.0 + last_go_at: float = 0.0 def to_dict(self) -> dict[str, Any]: return { @@ -184,4 +194,6 @@ def to_dict(self) -> dict[str, Any]: "paused": self.paused, "auto_standby": self.auto_standby, "osc_patch_filename": self.osc_patch_filename, + "show_started_at": self.show_started_at, + "last_go_at": self.last_go_at, } diff --git a/server/persistence.py b/server/persistence.py index 2e1ca0a..ec075f3 100644 --- a/server/persistence.py +++ b/server/persistence.py @@ -62,6 +62,8 @@ def load_state() -> AppState: state.auto_standby = data.get("auto_standby", False) state.osc_patch_filename = data.get("osc_patch_filename", "") state.showfile_filename = data.get("showfile_filename", "") + # last_go_at is deliberately transient; the show clock survives restarts + state.show_started_at = data.get("show_started_at", 0.0) for cid, pdata in data.get("positions", {}).items(): state.positions[cid] = Position( diff --git a/server/showreport.py b/server/showreport.py new file mode 100644 index 0000000..0942e11 --- /dev/null +++ b/server/showreport.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +# Events that pair a "called" with its ack, per position +_PAIRS = { + "standby_called": ("standby_acked", "standby"), + "go_called": ("go_acked", "go"), +} + + +def _parse_time(value: str) -> datetime | None: + try: + return datetime.fromisoformat(value) + except (TypeError, ValueError): + return None + + +def build_report(entries: list[dict[str, Any]]) -> dict[str, Any]: + """Computes a post-show report from the show log. Pure — reads the + entries, stores nothing.""" + show: dict[str, Any] = { + "started": "", + "ended": "", + "duration_s": None, + "standbys_called": 0, + "gos_called": 0, + "master_gos": 0, + "cue_advances": 0, + "attention_raised": 0, + } + positions: dict[str, dict[str, Any]] = {} + # position -> {"standby": ts, "go": ts} of the latest unacked call + pending: dict[str, dict[str, datetime]] = {} + cue_gaps: list[dict[str, Any]] = [] + prev_master_go: tuple[datetime, str] | None = None + + def pos_stats(label: str) -> dict[str, Any]: + if label not in positions: + positions[label] = { + "standbys": 0, "standby_acks": 0, "standby_ack_latencies": [], + "gos": 0, "go_acks": 0, "go_ack_latencies": [], + "attention": 0, "osc_fires": 0, + } + return positions[label] + + first_time: datetime | None = None + last_time: datetime | None = None + show_started: datetime | None = None + + for e in entries: + t = _parse_time(e.get("time", "")) + label = e.get("position", "") + event = e.get("event", "") + if t: + first_time = first_time or t + last_time = t + + if event == "show_started" and t: + show_started = t + elif event == "standby_called" and label: + show["standbys_called"] += 1 + pos_stats(label)["standbys"] += 1 + if t: + pending.setdefault(label, {})["standby"] = t + elif event == "go_called" and label: + show["gos_called"] += 1 + pos_stats(label)["gos"] += 1 + if t: + pending.setdefault(label, {})["go"] = t + elif event in ("standby_acked", "go_acked") and label: + kind = "standby" if event == "standby_acked" else "go" + stats = pos_stats(label) + stats[f"{kind}_acks"] += 1 + called_at = pending.get(label, {}).pop(kind, None) + if t and called_at: + stats[f"{kind}_ack_latencies"].append((t - called_at).total_seconds()) + elif event == "master_go": + show["master_gos"] += 1 + if t: + if prev_master_go: + cue_gaps.append({ + "cue": prev_master_go[1], + "gap_s": round((t - prev_master_go[0]).total_seconds(), 1), + }) + prev_master_go = (t, e.get("cue", "")) + elif event == "cue_advanced": + show["cue_advances"] += 1 + elif event == "attention_raised" and label: + show["attention_raised"] += 1 + pos_stats(label)["attention"] += 1 + elif event == "osc_fired" and label: + pos_stats(label)["osc_fires"] += 1 + + start = show_started or first_time + if start: + show["started"] = start.isoformat(timespec="seconds") + if last_time: + show["ended"] = last_time.isoformat(timespec="seconds") + if start and last_time: + show["duration_s"] = round((last_time - start).total_seconds(), 1) + + # Collapse latency lists into avg/max + for stats in positions.values(): + for kind in ("standby", "go"): + lat = stats.pop(f"{kind}_ack_latencies") + stats[f"{kind}_ack_avg_s"] = round(sum(lat) / len(lat), 2) if lat else None + stats[f"{kind}_ack_max_s"] = round(max(lat), 2) if lat else None + + return { + "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "show": show, + "positions": positions, + "cue_gaps": cue_gaps, + } + + +def _fmt_duration(seconds: float | None) -> str: + if seconds is None: + return "—" + s = int(seconds) + return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}" + + +def report_to_text(report: dict[str, Any]) -> str: + """Renders the report as a plain-text page a stage manager can save or print.""" + show = report["show"] + lines = [ + "CUELIGHT SHOW REPORT", + f"generated {report['generated_at']}", + "", + f" started {show['started'] or '—'}", + f" ended {show['ended'] or '—'}", + f" duration {_fmt_duration(show['duration_s'])}", + f" master GOs {show['master_gos']} cue advances {show['cue_advances']}", + f" standbys {show['standbys_called']} GOs {show['gos_called']}" + f" attention {show['attention_raised']}", + "", + "POSITIONS", + ] + if not report["positions"]: + lines.append(" (none)") + for label, s in report["positions"].items(): + lines.append(f" {label}") + lines.append( + f" standbys {s['standbys']} (acked {s['standby_acks']}" + f", avg {s['standby_ack_avg_s'] if s['standby_ack_avg_s'] is not None else '—'}s" + f", max {s['standby_ack_max_s'] if s['standby_ack_max_s'] is not None else '—'}s)" + ) + lines.append( + f" GOs {s['gos']} (acked {s['go_acks']}" + f", avg {s['go_ack_avg_s'] if s['go_ack_avg_s'] is not None else '—'}s" + f", max {s['go_ack_max_s'] if s['go_ack_max_s'] is not None else '—'}s)" + ) + if s["osc_fires"]: + lines.append(f" OSC fires {s['osc_fires']}") + if s["attention"]: + lines.append(f" attention raised {s['attention']}") + if report["cue_gaps"]: + lines.append("") + lines.append("TIME BETWEEN MASTER GOs") + for g in report["cue_gaps"]: + cue = f"after cue {g['cue']}" if g["cue"] else "(no cue)" + lines.append(f" {cue:<16} {_fmt_duration(g['gap_s'])}") + lines.append("") + return "\n".join(lines) diff --git a/server/state.py b/server/state.py index 85f3a32..3f998ad 100644 --- a/server/state.py +++ b/server/state.py @@ -2,6 +2,7 @@ import asyncio import secrets +import time from typing import Any from fastapi import WebSocket @@ -29,6 +30,7 @@ class StateManager: MAX_POSITIONS = 16 + ATTENTION_MESSAGE_MAX = 120 def __init__(self, initial: AppState | None = None) -> None: self.state = initial or AppState() @@ -108,10 +110,11 @@ async def unregister_observer(self, ws: WebSocket) -> None: async def register_position(self, ws: WebSocket, client_id: str, label: str) -> bool | str: async with self._lock: - if client_id not in self.state.positions: - for p in self.state.positions.values(): - if p.label.lower() == label.lower(): - return "duplicate" + # Uniqueness must hold for reconnects too: a known client_id coming + # back with a changed label may not take another position's label. + for cid, p in self.state.positions.items(): + if cid != client_id and p.label.lower() == label.lower(): + return "duplicate" if client_id in self.state.positions: pos = self.state.positions[client_id] pos.connected = True @@ -202,6 +205,7 @@ async def call_go(self, client_id: str) -> None: if not pos or self.state.locked: return self._clear_transient_osc_results() + self.state.last_go_at = time.time() if pos.type == PositionType.OSC: pos.standby = ButtonState.IDLE pos.armed = False @@ -262,6 +266,7 @@ async def go_armed(self) -> None: if self.state.locked: return self._clear_transient_osc_results() + self.state.last_go_at = time.time() self.log.record("master_go", cue=self._current_cue_seq()) fire_jobs: list[tuple[str, OscDevice, str]] = [] for pos in self.state.positions.values(): @@ -368,6 +373,56 @@ async def clear_flash(self) -> None: self._persist() await self._notify_caller_full_state() + # --- Operator attention --- + + async def raise_attention(self, client_id: str, message: str = "") -> None: + # Deliberately NOT blocked by the lock: an operator reporting a + # problem must get through even during a hold. + async with self._lock: + pos = self.state.positions.get(client_id) + if not pos or pos.type != PositionType.HUMAN: + return + pos.attention = True + pos.attention_message = str(message)[: self.ATTENTION_MESSAGE_MAX] + self.log.record("attention_raised", position=pos.label, + cue=self._current_cue_seq(), detail=pos.attention_message) + self._persist() + await self._notify_caller_full_state() + + async def clear_attention(self, client_id: str, by_caller: bool = False) -> None: + async with self._lock: + pos = self.state.positions.get(client_id) + if not pos or not pos.attention: + return + pos.attention = False + pos.attention_message = "" + self.log.record("attention_cleared", position=pos.label, + detail="caller" if by_caller else "operator") + self._persist() + if by_caller: + # Tell the operator their report was seen + await self._send_position(client_id, {"type": "attention_cleared"}) + await self._notify_caller_full_state() + + # --- Show clock --- + + async def start_show_clock(self) -> None: + async with self._lock: + self.state.show_started_at = time.time() + self.log.record("show_started") + self._persist() + await self._broadcast_positions({"type": "show_started"}) + await self._notify_caller_full_state() + + async def clear_show_clock(self) -> None: + async with self._lock: + if not self.state.show_started_at: + return + self.state.show_started_at = 0.0 + self.log.record("show_clock_cleared") + self._persist() + await self._notify_caller_full_state() + # --- Arm / disarm --- async def toggle_arm(self, client_id: str) -> None: @@ -938,6 +993,8 @@ def get_full_state_for_caller(self) -> dict[str, Any]: "password_enabled": self.state.password_enabled, "password": self.state.password, "osc_patch_filename": self.state.osc_patch_filename, + "show_started_at": self.state.show_started_at, + "last_go_at": self.state.last_go_at, } def get_full_state_for_observer(self) -> dict[str, Any]: diff --git a/server/ws.py b/server/ws.py index d30b70f..fcf5231 100644 --- a/server/ws.py +++ b/server/ws.py @@ -136,6 +136,15 @@ async def _handle_caller_message(ws: WebSocket, manager: StateManager, msg: dict elif msg_type == "remove_position": await manager.remove_position(msg["client_id"]) + elif msg_type == "clear_attention": + await manager.clear_attention(msg["client_id"], by_caller=True) + + elif msg_type == "start_show_clock": + await manager.start_show_clock() + + elif msg_type == "clear_show_clock": + await manager.clear_show_clock() + elif msg_type == "load_patch": from .patch import load_patch try: @@ -205,6 +214,7 @@ async def position_ws_handler(ws: WebSocket, manager: StateManager) -> None: "locked": manager.state.locked, "caller_connected": manager.state.caller_connected, "color": pos.color if pos else "", + "attention": pos.attention if pos else False, } sf = manager.state.showfile if sf and pos: @@ -290,6 +300,12 @@ async def _handle_position_message( elif msg_type == "ack_flash": await manager.ack_flash(client_id) + elif msg_type == "raise_attention": + await manager.raise_attention(client_id, str(msg.get("message", ""))) + + elif msg_type == "clear_attention": + await manager.clear_attention(client_id) + elif msg_type == "rename": await manager.rename_position(client_id, msg["label"]) diff --git a/static/caller.html b/static/caller.html index 23f6fa1..660ad57 100644 --- a/static/caller.html +++ b/static/caller.html @@ -22,6 +22,9 @@
+ +
+
@@ -52,6 +55,8 @@ + +
@@ -112,6 +117,21 @@

Show Log

+ +
+ +

Show Clock

+
+ + +
+
+ + +
+
+ +
+ + +