diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6dd3bb0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+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
+ with:
+ # read-only workflow: don't leave the token in .git/config
+ persist-credentials: false
+ - 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 89b7b50..878feb3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,13 +12,9 @@ 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
-uvicorn server.main:app --host 0.0.0.0 --port 8000
-
-# Or: friendly entry point (same server, prints the join URLs)
+# Run the server (equivalently: ./run.sh, run.bat, or the uvicorn line)
python -m server
-
-# Non-technical bootstrap (creates venv, installs, launches): run.sh / run.bat
+uvicorn server.main:app --host 0.0.0.0 --port 8000
# Run the test suite
python tests/test_cuelight.py
@@ -30,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
@@ -122,19 +118,19 @@ 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, problem flag/message, `auto_standby`, `osc_patch_filename`, `showfile_filename`, `show_start_time`, and `last_go_time`. 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).
### Show log
-`server/showlog.py` — `ShowLog` keeps an in-memory list of `{time, event, position, cue, detail}` entries and appends each to `state/showlog.jsonl` (so it survives restarts; loaded back in `__init__`). `StateManager` owns one as `self.log` and records under the lock: button events (`standby_called`/`standby_acked`/`go_called`/`go_acked`, with the current cue sequence), `master_standby`/`master_go`, `cue_advanced`/`cue_jumped`, `osc_fired` (detail = sent/no_reply), lock/pause/rename/join/disconnect/showfile/patch events, flash-check confirmations, `problem_raised`/`problem_cleared` (detail = message / who cleared), and `show_started`. Auto-standby calls carry `detail="auto"`. OSC heartbeat probes are deliberately NOT logged (noise). `GET /api/showlog` serves JSON, `?format=csv` a CSV attachment. `exit_show()` records `show_ended` then `log.rotate()` (archive to `showlog.bak`); `resume_show()` calls `log.restore()`.
+`server/showlog.py` — `ShowLog` keeps an in-memory list of `{time, event, position, cue, detail}` entries and appends each to `state/showlog.jsonl` (so it survives restarts; loaded back in `__init__`). `StateManager` owns one as `self.log` and records under the lock: button events (`standby_called`/`standby_acked`/`go_called`/`go_acked`, with the current cue sequence), `master_standby`/`master_go`, `cue_advanced`/`cue_jumped`, `osc_fired` (detail = sent/no_reply), lock/pause/rename/join/disconnect/showfile/patch events, and flash-check confirmations. Auto-standby calls carry `detail="auto"`. OSC heartbeat probes are deliberately NOT logged (noise). `GET /api/showlog` serves JSON, `?format=csv` a CSV attachment. `exit_show()` records `show_ended` then `log.rotate()` (archive to `showlog.bak`); `resume_show()` calls `log.restore()`.
### Label uniqueness
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
@@ -145,37 +141,37 @@ 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`, `clear_problem` (with `client_id`), `start_show`
+**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`, `raise_problem` (optional `message`, capped at 60 chars server-side), `clear_problem`
+**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`, `problem_changed` (`problem`, `message`), `show_started` (`start_time`)
+**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/showreport` (HTML, `?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|csv|html`), `GET /api/backup_info`, `POST /api/resume_show`, `POST /api/csv/import`, `POST /api/csv/export`
-### Problem signal (position → caller)
+**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 (kept for curl/direct-URL use), checked by `_password_ok()` in main.py; wrong/missing → 401. The caller sends the header from `full_state` (`pwAuth()` in caller.js — the log/report buttons fetch with it and open blob URLs, so the password never appears in a URL); 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.
-Operators raise a PROBLEM flag from the position console (bezel button opens an in-flow panel with big preset buttons NOT READY / PROP ISSUE / NEED SM plus optional free text). `raise_problem` sets `Position.problem = True` and `Position.problem_message` (server caps at `PROBLEM_MESSAGE_MAX = 60` chars); `clear_problem` clears it — sent by the caller (with `client_id`) or by the raising position itself. Both directions are echoed to the position as `problem_changed` and to the caller/observers via `full_state`. Raising is NOT gated on `locked` (operator communication matters most during a hold). OSC positions never carry problems.
+### Flash roll call
-The flag and message are **persisted** in the snapshot (and thus survive reconnects, restarts, and EXIT/resume). Raise/clear are logged as `problem_raised` (detail = message) / `problem_cleared` (detail = "by caller" / "by operator"). Caller UI: solid orange `⚠ PROBLEM` badge (deliberately NON-flashing so it never disturbs the calling rhythm) + orange column outline; tapping the badge opens a readout modal with a Clear button. The position's panel sits in the layout flow (it shrinks the cue buttons rather than covering them) and is auto-closed by an incoming standby/GO. If the position disconnects, the DISCONNECTED badge takes precedence but the orange outline remains.
+`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).
-### Show timer / START SHOW
+### Operator attention
-`start_show` (caller message) sets `AppState.show_start_time` (ISO string, persisted; cleared by EXIT since EXIT resets AppState), logs `show_started` (detail = "restart" on re-press), and broadcasts a `show_started` message to all positions — the position page shows a transient 4s banner (`.show-banner`, `pointer-events: none`, floats over the header strip, never the cue buttons). `AppState.last_go_time` is stamped on every `call_go`/`go_armed` (persisted). Both fields ride in `full_state`; the caller's bottom bar renders a quiet monospace timer from them, with per-device checkboxes in Settings → Show Timer (elapsed / since last GO / clock; stored in `localStorage` `cuelight_timer_prefs`). The START SHOW button confirms before (re)starting.
+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).
-### Post-show report
+### Show clock
-`server/showreport.py` computes a report **on demand** from `manager.log.entries` — nothing is persisted. `compute_report()` matches each `standby_called` to the next `standby_acked` per position (a `go_called` cancels a pending standby → counted as never-acked, mirroring the server's auto-clear), takes deltas between consecutive `master_go` events for time-between-GOs, counts auto vs manual standbys (`detail == "auto"`), sums joins/disconnects, and pairs `problem_raised`/`problem_cleared`. `GET /api/showreport` serves a self-contained HTML page (inline CSS, all user text escaped); `?format=csv` a flat `section,position,cue,metric,value` CSV. Exposed like `/api/showlog` (no password gate). Buttons live in caller Settings → Show Log.
+`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.
-### Operator alerts (pure frontend)
+### Show report
-Position-only, opt-in, in `position.js`: the ALERT bezel button toggles beep+vibration on incoming `standby_called`/`go_called` (two short 880Hz beeps = standby, one long lower beep = GO; `navigator.vibrate` patterns where supported — Android only, iOS relies on the beep). WebAudio requires a user gesture, so the AudioContext is created/resumed on taps while alerts are on (same constraint-driven pattern as keepawake); the toggle tap itself unlocks and plays a confirmation blip. Persisted in `localStorage` (`cuelight_alerts`), off by default. No server involvement.
+`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. Three renderers: `report_to_text()` (plain text), `report_to_csv()` (CSV with formula-injection sanitisation via `_DANGEROUS`/`_safe`), and `report_to_html()` (self-contained dark-theme HTML). Served by `GET /api/showreport` (`?format=txt|csv|html`; password-gated): `txt` → `text/plain`, `csv` → `text/csv` with `Content-Disposition: attachment`, `html` → `text/html`, default → JSON. Opened from Settings → Show Log → "Show report".
-### Flash roll call
+### Cue alert (position, frontend-only)
-`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).
+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
@@ -203,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 11e7459..d9ff984 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -16,23 +16,16 @@ This document walks through every feature and how to use it.
### Starting the server
-**The easy way (no terminal knowledge needed):** double-click or run the
-bootstrap script in the project folder — `run.sh` on macOS/Linux
-(`./run.sh` from a terminal), `run.bat` on Windows. On first run it creates a
-private Python environment and installs everything; after that it just starts
-the server and prints the addresses to open on each device. Keep the window
-open for the whole show.
-
-**The manual way:**
-
```bash
pip install -r requirements.txt
-python -m server
+uvicorn server.main:app --host 0.0.0.0 --port 8000
```
-`python -m server` starts the server on port 8000 and prints the join URLs
-(LAN IP and `cuelight.local`). The equivalent raw command is
-`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
@@ -159,41 +152,28 @@ 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.
-### Sound & vibration alerts (opt-in)
-
-An operator watching the stage can miss a silent light change. The **ALERT**
-button in the bottom bezel turns on audible/tactile alerts for incoming cues:
-
-- **Standby** — two short high beeps (and a double vibration pulse on
- Android).
-- **GO** — one long lower beep (and a long vibration).
-
-The patterns are distinct so you can tell them apart without looking. Off by
-default, per device, and the choice survives page reloads. Turning it on plays
-a small confirmation blip — that first tap also unlocks the phone's audio, so
-alerts work from then on. (iPhones can't vibrate from the browser; the beep is
-the alert there.)
+### Cue alert sounds (optional)
-### Raising a PROBLEM
+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).
-Operators can signal the caller that something is wrong — without leaving
-their post or typing in the dark:
+### Reporting a problem (attention)
-1. Tap **PROBLEM** in the bottom bezel. A panel opens *below* the cue buttons
- (they stay visible and tappable — an incoming standby or GO also closes the
- panel automatically, so a cue can never be blocked by it).
-2. Tap one of the big preset messages — **NOT READY**, **PROP ISSUE**,
- **NEED SM** — or type a short message (60 characters max) and tap
- **RAISE**. A preset is one tap; typing is optional.
-3. The PROBLEM button turns orange while the flag is up. Re-raising replaces
- the message; **CLEAR PROBLEM** (in the panel) withdraws it.
+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.
-On the Caller grid the position's column gets a **solid orange ⚠ PROBLEM
-badge and outline** — deliberately steady, not flashing, so it never disturbs
-the calling rhythm mid-sequence. The caller taps the badge to read the message
-and can clear it from there. The flag survives reconnects and even a server
-restart, and every raise/clear is recorded in the show log with its message.
-Observers see the flag (and message) too.
+- 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)
@@ -298,35 +278,6 @@ and show log. Operators just reload their phones (or wait for auto-reconnect)
and land back in their seats. Starting a *new* show after an EXIT overwrites the
backup at the next EXIT.
-### START SHOW and the show timer
-
-At the top of show, press **START SHOW** in the bottom bar (a confirmation
-guards against mis-taps). This records the wall-clock start time, notes it in
-the show log, and shows every connected operator a brief "SHOW STARTED"
-banner that dismisses itself after a few seconds — it floats over the header
-strip, never the cue buttons. Pressing START SHOW again simply re-records the
-time (with another confirmation and a log entry), for a false start or a
-per-act reset. The start time survives a mid-show server restart; EXIT clears
-it.
-
-Next to the button, a **compact timer** shows — in quiet grey text that never
-competes with cue state — any combination of:
-
-- time since show start (`SHOW 1:23:45`),
-- time since the last GO (`GO +02:10`),
-- the current clock time.
-
-Pick which in **Settings → Show Timer** (first two on by default; the choice
-is per device and persists).
-
-### Problem markers
-
-When an operator raises a **PROBLEM** (see the Position console section), the
-column shows a steady orange **⚠ PROBLEM** badge and outline. Tap the badge to
-read the operator's message and clear the flag. The marker never flashes, so
-it won't be confused with a called standby. If the position also disconnects,
-the DISCONNECTED badge takes over but the orange outline remains.
-
### Settings
The **SETTINGS** button opens a modal with these sections:
@@ -346,12 +297,10 @@ 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 Timer** — checkboxes choosing what the bottom-bar timer displays:
- time since show start, time since last GO, clock time (see *START SHOW and
- the show timer*). Per-device, persisted.
-6. **Show Log** — download the show's event log as CSV, view the raw JSON, or
- generate the **post-show report** (HTML page or CSV — see *Show log &
- post-show report*).
+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*).
@@ -373,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
@@ -747,13 +721,12 @@ is designed for an iPad in landscape (a rotate prompt appears in portrait).
---
-## Show log & post-show report
+## Show log
The server keeps a timestamped log of everything that happens in the show:
standbys and acks (with the operator and the cue they happened on), GOs, master
actions, cue advances and jumps, OSC fire results, lock/unlock, pauses,
-positions joining/leaving, showfile/patch loads, flash-check confirmations,
-problems raised/cleared (with their messages), and START SHOW presses.
+positions joining/leaving, showfile/patch loads, and flash-check confirmations.
Auto-called standbys are marked `auto` so a report distinguishes them from
manual calls.
@@ -769,23 +742,17 @@ 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.
-### Post-show report
-
-After (or during) a show, **Settings → Show Log → Show report** turns the log
-into a readable report — computed on demand from the log, with nothing extra
-stored on the server:
+### Show report
-- **Standby response times** per position (min / average / max) and per cue —
- how quickly each operator acknowledged their standbys.
-- **Standbys never acknowledged** — standbys that were still un-acked when the
- GO fired (or when the show ended).
-- **Time between GOs** — the pace of the show, cue by cue.
-- **Auto vs manual standby counts**, **joins/disconnects per position**, and
- every **problem raised** with its message and how it was cleared.
-
-The report is a self-contained HTML page (`GET /api/showreport`) you can save
-or print, and **Report CSV** (`?format=csv`) downloads the same numbers as a
-flat spreadsheet-friendly CSV.
+**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. Available in three formats: plain text (`?format=txt`) you can save or print,
+CSV (`?format=csv`) for spreadsheet import (cells are sanitised against
+formula-injection), and HTML (`?format=html`) for a self-contained dark-themed
+report. `GET /api/showreport` (no format) returns the data as JSON for tooling.
---
@@ -800,6 +767,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`.
@@ -859,21 +831,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) |
-| `GET /api/showreport` | Post-show report as a self-contained HTML page (`?format=csv` for CSV) |
+| `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\|csv\|html`; default JSON) 🔒 |
| `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
@@ -887,11 +862,8 @@ useful for integration or debugging.
and connect. A director or backup caller can pick **Watch as Observer** on
the join page instead.
6. Before the house opens, run a **flash check** (Settings → Health & Latency →
- Flash all positions) and watch every operator confirm. Operators who want
- audible cues turn on **ALERT** in their bezel.
-7. At the top of show, press **START SHOW** — the clock starts and every
- operator sees a brief notice.
-8. Run the show:
+ Flash all positions) and watch every operator confirm.
+7. Run the show:
- **Manual:** use each column's STANDBY / GO, or arm positions with PRESET and
drive them together with the MASTER buttons.
- **Showfile:** PRESET arming is automatic (including OSC positions); tap
@@ -899,8 +871,6 @@ useful for integration or debugging.
use PREV / JUMP / PAUSE as needed.
- **OSC:** STANDBY on an OSC column probes the device; GO fires the OSC
message. Results (SENT / NO REPLY) show on the button for 2 seconds.
-9. **LOCK** during holds; **RESET ALL** between acts. Operators flag trouble
- with **PROBLEM**; the caller reads and clears the orange badges.
-10. **EXIT** to end the show. Afterwards, download the show log or generate
- the **post-show report** from Settings, or **RESUME SHOW** if the EXIT
- was a mistake.
+8. **LOCK** during holds; **RESET ALL** between acts.
+9. **EXIT** to end the show. Afterwards, download the show log from Settings,
+ or **RESUME SHOW** if the EXIT was a mistake.
diff --git a/README.md b/README.md
index 204755a..76f91c4 100644
--- a/README.md
+++ b/README.md
@@ -8,28 +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.
-## Quick start (no terminal knowledge needed)
+## Quick start (no setup knowledge needed)
-Download the project, then:
+Download/clone this folder onto the machine that will run the server, then:
-- **macOS / Linux:** run `./run.sh` in the project folder
+- **macOS / Linux:** open Terminal in the folder and run `./run.sh`
- **Windows:** double-click `run.bat`
-On first run the script sets up a private Python environment and installs
-everything; after that it just starts the server and prints the addresses to
-open on each device. Keep the window open during the show.
+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`).
-## Install & run manually
+## Manual install / run
```bash
pip install -r requirements.txt
-python -m server
-```
-
-`python -m server` starts the server on port 8000 and prints the join URLs.
-The equivalent raw command is:
-
-```bash
+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
index 4bb88e5..40a81c6 100644
--- a/run.bat
+++ b/run.bat
@@ -1,24 +1,22 @@
@echo off
-rem One-click CueLight launcher for Windows.
-rem Creates a virtualenv on first run, installs dependencies, starts the server.
+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 not installed. Get it from https://www.python.org/downloads/
- echo Make sure to tick "Add Python to PATH" in the installer.
+ 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" (
+if not exist .venv (
echo First run: setting up, this takes a minute...
python -m venv .venv
if errorlevel 1 (echo Failed to create the virtual environment. & pause & exit /b 1)
)
-call .venv\Scripts\activate.bat
-pip install -q -r requirements.txt
+.venv\Scripts\pip install --quiet -r requirements.txt
if errorlevel 1 (echo Failed to install dependencies. Check your internet connection and try again. & pause & exit /b 1)
-python -m server
+.venv\Scripts\python -m server %*
pause
diff --git a/run.sh b/run.sh
index 831587f..927cdd7 100755
--- a/run.sh
+++ b/run.sh
@@ -1,21 +1,20 @@
-#!/bin/sh
-# One-line CueLight launcher for macOS / Linux.
-# Creates a virtualenv on first run, installs dependencies, starts the server.
+#!/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")"
-PY=python3
-command -v "$PY" >/dev/null 2>&1 || PY=python
-if ! command -v "$PY" >/dev/null 2>&1; then
- echo "Python 3 is not installed. Get it from https://www.python.org/downloads/"
+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
+if [ ! -d .venv ]; then
echo "First run: setting up (this takes a minute)…"
- "$PY" -m venv .venv
+ python3 -m venv .venv || { echo "Failed to create the virtual environment."; exit 1; }
fi
-. .venv/bin/activate
-pip install -q -r requirements.txt
-exec python -m server
+./.venv/bin/pip install --quiet -r requirements.txt \
+ || { echo "Failed to install dependencies. Check your internet connection and try again."; exit 1; }
+exec ./.venv/bin/python -m server "$@"
diff --git a/server/__main__.py b/server/__main__.py
index 7d8f760..1e33113 100644
--- a/server/__main__.py
+++ b/server/__main__.py
@@ -1,47 +1,33 @@
-"""Entry point for `python -m server`: start CueLight and print the join URLs.
-
-Meant for non-technical users (see run.sh / run.bat) — equivalent to
-`uvicorn server.main:app --host 0.0.0.0 --port 8000` but with a friendly
-banner showing the addresses to open on the caller and operator devices.
-"""
+"""Run CueLight with `python -m server [port]` — no uvicorn incantation needed."""
from __future__ import annotations
-import socket
import sys
import uvicorn
-HOST = "0.0.0.0"
-PORT = 8000
-
-
-def _get_local_ip() -> str:
- try:
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- s.connect(("8.8.8.8", 80))
- ip = s.getsockname()[0]
- s.close()
- return ip
- except Exception:
- return "127.0.0.1"
+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()
- print()
- print("=" * 56)
- print(" CueLight is starting…")
- print()
- print(f" Caller (this iPad/laptop): http://{ip}:{PORT}")
- print(f" Operators join at: http://{ip}:{PORT}/join")
- print(f" If mDNS works on your LAN: http://cuelight.local:{PORT}")
- print()
- print(" Keep this window open for the whole show.")
- print(" Press Ctrl+C to stop the server.")
- print("=" * 56)
- print()
- sys.stdout.flush() # the banner must land even when stdout is piped
- uvicorn.run("server.main:app", host=HOST, port=PORT, log_level="warning")
+ 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__":
diff --git a/server/main.py b/server/main.py
index 64ad394..25ef84a 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,8 +17,8 @@
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_csv, report_to_html, report_to_text
from .showfile import list_showfiles, load_showfile, save_showfile, validate_showfile
-from .showreport import report_csv, report_html
from .state import StateManager
from .ws import caller_ws_handler, observer_ws_handler, position_ws_handler
@@ -154,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()
@@ -207,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:
@@ -258,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:
@@ -297,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)
@@ -305,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(),
@@ -316,23 +338,32 @@ async def api_showlog(format: str = "json"):
@app.get("/api/showreport")
-async def api_showreport(format: str = "html"):
- """Post-show report computed on demand from the show log; nothing is
- stored server-side. Exposed like /api/showlog (no password gate)."""
+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")
if format == "csv":
return Response(
- report_csv(manager.log.entries),
+ report_to_csv(report),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="showreport.csv"'},
)
- return HTMLResponse(report_html(manager.log.entries))
+ if format == "html":
+ return Response(report_to_html(report), media_type="text/html; 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 baaa4dc..cc6c51b 100644
--- a/server/models.py
+++ b/server/models.py
@@ -53,9 +53,9 @@ class Position:
color: str = ""
# Roll-call state: "none" | "pending" | "confirmed". Transient — not restored from snapshot.
flash: str = "none"
- # Operator-raised problem flag; survives reconnects and restarts (persisted).
- problem: bool = False
- problem_message: str = ""
+ # 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 {
@@ -74,8 +74,8 @@ def to_dict(self) -> dict[str, Any]:
"osc_trust": self.osc_trust,
"color": self.color,
"flash": self.flash,
- "problem": self.problem,
- "problem_message": self.problem_message,
+ "attention": self.attention,
+ "attention_message": self.attention_message,
}
@@ -174,9 +174,11 @@ class AppState:
paused: bool = False
auto_standby: bool = False
osc_patch_filename: str = ""
- # ISO timestamps; "" until START SHOW / the first GO. Cleared by EXIT.
- show_start_time: str = ""
- last_go_time: 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 {
@@ -192,6 +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_start_time": self.show_start_time,
- "last_go_time": self.last_go_time,
+ "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 91bb36c..dbc9b51 100644
--- a/server/persistence.py
+++ b/server/persistence.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+from datetime import datetime
from pathlib import Path
from threading import Timer
@@ -62,8 +63,15 @@ 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", "")
- state.show_start_time = data.get("show_start_time", "")
- state.last_go_time = data.get("last_go_time", "")
+ # last_go_at is deliberately transient; the show clock survives restarts
+ state.show_started_at = data.get("show_started_at", 0.0)
+ if not state.show_started_at and data.get("show_start_time"):
+ # Pre-rename snapshots stored the clock as an ISO string
+ try:
+ state.show_started_at = datetime.fromisoformat(
+ data["show_start_time"]).timestamp()
+ except (TypeError, ValueError):
+ pass
for cid, pdata in data.get("positions", {}).items():
state.positions[cid] = Position(
@@ -77,8 +85,6 @@ def load_state() -> AppState:
latency_ms=0.0,
cue_indicator=pdata.get("cue_indicator", ""),
color=pdata.get("color", ""),
- problem=pdata.get("problem", False),
- problem_message=pdata.get("problem_message", ""),
)
# Showfile is reloaded from disk by the showfile module, not from snapshot
diff --git a/server/showreport.py b/server/showreport.py
index 03a8110..a41a1ba 100644
--- a/server/showreport.py
+++ b/server/showreport.py
@@ -1,8 +1,3 @@
-"""Post-show report, computed on demand from the show log.
-
-Nothing here is persisted: the report is derived from the in-memory log
-entries (which mirror state/showlog.jsonl) every time it is requested.
-"""
from __future__ import annotations
import csv
@@ -11,6 +6,17 @@
from datetime import datetime
from typing import Any
+# Characters that spreadsheet apps interpret as formula prefixes
+_DANGEROUS = ("=", "+", "-", "@", "\t", "\r", "\n")
+
+
+def _safe(value: object) -> object:
+ """Prefix formula-triggering cells with a single quote so they are treated
+ as plain text by Excel, Google Sheets, etc."""
+ if isinstance(value, str) and value[:1] in _DANGEROUS:
+ return "'" + value
+ return value
+
def _parse_time(value: str) -> datetime | None:
try:
@@ -19,241 +25,294 @@ def _parse_time(value: str) -> datetime | None:
return None
-def compute_report(entries: list[dict[str, Any]]) -> dict[str, Any]:
- """Aggregates the log into report data.
-
- Standby latency: each standby_called opens a pending window for that
- position; the next standby_acked closes it. A go_called auto-clears a
- pending standby (server behavior), so it counts as never-acked. A
- re-called standby restarts the window without counting the first one.
- """
- latencies: list[dict[str, Any]] = []
- unacked: list[dict[str, str]] = []
- pending: dict[str, tuple[datetime, str, bool]] = {}
- go_times: list[tuple[datetime, str]] = []
- auto_standbys = 0
- manual_standbys = 0
- joins: dict[str, dict[str, int]] = {}
- problems: list[dict[str, str]] = []
- show_started: list[str] = []
+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", "")
- position = e.get("position", "")
- cue = e.get("cue", "")
- detail = e.get("detail", "")
-
- if event == "standby_called":
- if detail == "auto":
- auto_standbys += 1
- else:
- manual_standbys += 1
- if t is not None:
- pending[position] = (t, cue, detail == "auto")
- elif event == "standby_acked":
- opened = pending.pop(position, None)
- if opened and t is not None:
- latencies.append({
- "position": position,
- "cue": opened[1],
- "ms": (t - opened[0]).total_seconds() * 1000,
- "auto": opened[2],
- })
- elif event == "go_called":
- opened = pending.pop(position, None)
- if opened:
- unacked.append({"position": position, "cue": opened[1]})
- elif event == "master_go" and t is not None:
- go_times.append((t, cue))
- elif event == "position_joined":
- joins.setdefault(position, {"joins": 0, "disconnects": 0})["joins"] += 1
- elif event == "position_disconnected":
- joins.setdefault(position, {"joins": 0, "disconnects": 0})["disconnects"] += 1
- elif event == "problem_raised":
- problems.append({
- "time": e.get("time", ""),
- "position": position,
- "cue": cue,
- "message": detail,
- "cleared": "",
- })
- elif event == "problem_cleared":
- for p in reversed(problems):
- if p["position"] == position and not p["cleared"]:
- p["cleared"] = detail
- break
- elif event == "show_started":
- show_started.append(e.get("time", ""))
-
- # Standbys still pending when the log ends were never acknowledged
- for position, (_, cue, _) in pending.items():
- unacked.append({"position": position, "cue": cue})
-
- per_position: dict[str, dict[str, float]] = {}
- for lat in latencies:
- stats = per_position.setdefault(lat["position"], {"count": 0, "min": 0.0, "max": 0.0, "sum": 0.0})
- ms = lat["ms"]
- if stats["count"] == 0:
- stats["min"] = stats["max"] = ms
- else:
- stats["min"] = min(stats["min"], ms)
- stats["max"] = max(stats["max"], ms)
- stats["count"] += 1
- stats["sum"] += ms
- position_stats = [
- {
- "position": pos,
- "count": int(s["count"]),
- "min_ms": s["min"],
- "avg_ms": s["sum"] / s["count"],
- "max_ms": s["max"],
- }
- for pos, s in per_position.items()
- ]
-
- go_intervals = [
- {"cue": go_times[i][1], "seconds": (go_times[i + 1][0] - go_times[i][0]).total_seconds()}
- for i in range(len(go_times) - 1)
- ]
+ 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 {
- "show_started": show_started,
- "latencies": latencies,
- "position_stats": position_stats,
- "unacked": unacked,
- "go_intervals": go_intervals,
- "auto_standbys": auto_standbys,
- "manual_standbys": manual_standbys,
- "joins": joins,
- "problems": problems,
- "entry_count": len(entries),
+ "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
+ "show": show,
+ "positions": positions,
+ "cue_gaps": cue_gaps,
}
-def _fmt_ms(ms: float) -> str:
- return f"{ms:.0f}"
-
-
-def report_html(entries: list[dict[str, Any]]) -> str:
- r = compute_report(entries)
- esc = html.escape
-
- def table(headers: list[str], rows: list[list[str]], empty: str) -> str:
- if not rows:
- return f'
{esc(empty)}
'
- head = "".join(f"
{esc(h)}
" for h in headers)
- body = "".join(
- "
" + "".join(f"
{esc(c)}
" for c in row) + "
" for row in rows
- )
- return f"
{head}
{body}
"
-
- started = ", ".join(esc(t) for t in r["show_started"]) or "never (START SHOW not pressed)"
- stats_rows = [
- [s["position"], str(s["count"]), _fmt_ms(s["min_ms"]), _fmt_ms(s["avg_ms"]), _fmt_ms(s["max_ms"])]
- for s in sorted(r["position_stats"], key=lambda s: s["position"].lower())
- ]
- latency_rows = [
- [lat["position"], lat["cue"] or "—", _fmt_ms(lat["ms"]), "auto" if lat["auto"] else "manual"]
- for lat in r["latencies"]
- ]
- unacked_rows = [[u["position"], u["cue"] or "—"] for u in r["unacked"]]
- go_rows = [[g["cue"] or "—", f"{g['seconds']:.1f}"] for g in r["go_intervals"]]
- join_rows = [
- [pos, str(c["joins"]), str(c["disconnects"])]
- for pos, c in sorted(r["joins"].items(), key=lambda kv: kv[0].lower())
- ]
- problem_rows = [
- [p["time"], p["position"], p["cue"] or "—", p["message"] or "(no message)",
- (f"cleared {p['cleared']}" if p["cleared"] else "not cleared")]
- for p in r["problems"]
+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 _fmt_seconds(value: float | None) -> str:
+ """Seconds with unit, or a bare em-dash when there is no value."""
+ return "—" if value is None else f"{value}s"
+
+
+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 {_fmt_seconds(s['standby_ack_avg_s'])}"
+ f", max {_fmt_seconds(s['standby_ack_max_s'])})"
+ )
+ lines.append(
+ f" GOs {s['gos']} (acked {s['go_acks']}"
+ f", avg {_fmt_seconds(s['go_ack_avg_s'])}"
+ f", max {_fmt_seconds(s['go_ack_max_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)
+
+
+def _str(v: object) -> str:
+ """Render a value as a string, using '—' for None."""
+ return "—" if v is None else str(v)
+
+
+def report_to_csv(report: dict[str, Any]) -> str:
+ """Renders the report as a CSV attachment safe against formula-injection.
+
+ Columns: section, label, metric, value.
+ Every cell is passed through _safe() to neutralise spreadsheet formula prefixes.
+ """
+ show = report["show"]
+ buf = io.StringIO()
+ writer = csv.writer(buf)
- body = f"""
-
CueLight Show Report
-
Show started: {started} · {r["entry_count"]} log entries
-
-
Standby response per position
-
Time from a standby being called to the operator acknowledging it.