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.

-{table(["Position", "Acks", "Min (ms)", "Avg (ms)", "Max (ms)"], stats_rows, "No acknowledged standbys.")} - -

Standby response per cue

-{table(["Position", "Cue", "Latency (ms)", "Called"], latency_rows, "No acknowledged standbys.")} - -

Standbys never acknowledged

-{table(["Position", "Cue"], unacked_rows, "None — every standby was acknowledged.")} + def row(*cells: object) -> None: + writer.writerow([_safe(str(c) if not isinstance(c, str) else c) for c in cells]) + + row("section", "label", "metric", "value") + + # Show summary + row("show_summary", "", "generated_at", report["generated_at"]) + row("show_summary", "", "started", show["started"] or "—") + row("show_summary", "", "ended", show["ended"] or "—") + row("show_summary", "", "duration", _fmt_duration(show["duration_s"])) + row("show_summary", "", "standbys_called", show["standbys_called"]) + row("show_summary", "", "gos_called", show["gos_called"]) + row("show_summary", "", "master_gos", show["master_gos"]) + row("show_summary", "", "cue_advances", show["cue_advances"]) + row("show_summary", "", "attention_raised", show["attention_raised"]) + + # Per-position stats + for label, s in report["positions"].items(): + row("position", label, "standbys", s["standbys"]) + row("position", label, "standby_acks", s["standby_acks"]) + row("position", label, "standby_ack_avg_s", _str(s["standby_ack_avg_s"])) + row("position", label, "standby_ack_max_s", _str(s["standby_ack_max_s"])) + row("position", label, "gos", s["gos"]) + row("position", label, "go_acks", s["go_acks"]) + row("position", label, "go_ack_avg_s", _str(s["go_ack_avg_s"])) + row("position", label, "go_ack_max_s", _str(s["go_ack_max_s"])) + if s["osc_fires"]: + row("position", label, "osc_fires", s["osc_fires"]) + if s["attention"]: + row("position", label, "attention_raised", s["attention"]) + + # Cue gaps + for g in report["cue_gaps"]: + cue_label = f"after cue {g['cue']}" if g["cue"] else "(no cue)" + row("cue_gap", cue_label, "gap_s", g["gap_s"]) -

Time between GOs

-

Elapsed time from each master GO to the next, labelled with the cue it fired.

-{table(["Cue", "Seconds to next GO"], go_rows, "Fewer than two master GOs fired.")} + return buf.getvalue() -

Standby calls

-{table(["Manual", "Auto"], [[str(r["manual_standbys"]), str(r["auto_standbys"])]], "")} -

Joins and disconnects

-{table(["Position", "Joins", "Disconnects"], join_rows, "No positions joined.")} +def report_to_html(report: dict[str, Any]) -> str: + """Renders the report as a self-contained HTML page (dark theme, no external deps).""" + show = report["show"] + esc = html.escape -

Problems raised

-{table(["Time", "Position", "Cue", "Message", "Status"], problem_rows, "No problems raised.")} -""" - return f""" - - - - -CueLight Show Report - - -{body} - + def th(*headers: str) -> str: + return "" + "".join(f"{esc(h)}" for h in headers) + "" + + def td(*cells: object) -> str: + return "" + "".join(f"{esc(str(c))}" for c in cells) + "" + + def table(header_row: str, body_rows: list[str]) -> str: + body = "\n".join(body_rows) if body_rows else '(none)' + return f"\n{header_row}\n{body}\n
" + + # Show summary section + summary_rows = [ + td("Generated", report["generated_at"]), + td("Started", show["started"] or "—"), + td("Ended", show["ended"] or "—"), + td("Duration", _fmt_duration(show["duration_s"])), + td("Standbys called", show["standbys_called"]), + td("GOs called", show["gos_called"]), + td("Master GOs", show["master_gos"]), + td("Cue advances", show["cue_advances"]), + td("Attention raised", show["attention_raised"]), + ] + summary_tbl = table(th("Metric", "Value"), summary_rows) + + # Positions section + pos_rows = [] + for label, s in report["positions"].items(): + pos_rows.append(td( + label, + s["standbys"], s["standby_acks"], + _fmt_seconds(s["standby_ack_avg_s"]), _fmt_seconds(s["standby_ack_max_s"]), + s["gos"], s["go_acks"], + _fmt_seconds(s["go_ack_avg_s"]), _fmt_seconds(s["go_ack_max_s"]), + s["osc_fires"], s["attention"], + )) + pos_tbl = table( + th("Position", + "Standbys", "Acked", "SB avg", "SB max", + "GOs", "GO acked", "GO avg", "GO max", + "OSC fires", "Attention"), + pos_rows, + ) + + # Cue gaps section + gap_rows = [ + td(f"after cue {g['cue']}" if g["cue"] else "(no cue)", _fmt_duration(g["gap_s"])) + for g in report["cue_gaps"] + ] + gap_tbl = table(th("Cue", "Gap"), gap_rows) + + css = """ +body{background:#1a1a1a;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; + margin:32px auto;max-width:960px;padding:0 16px} +h1{font-size:22px;color:#fff} +h2{font-size:14px;color:#aaa;margin-top:28px;border-bottom:1px solid #333;padding-bottom:4px} +table{border-collapse:collapse;margin-top:8px;width:100%} +th,td{text-align:left;padding:5px 10px;border-bottom:1px solid #2e2e2e;font-size:13px} +th{color:#888;font-weight:600} +.empty{color:#555;font-style:italic} """ - -def report_csv(entries: list[dict[str, Any]]) -> str: - """Flat 5-column CSV: section,position,cue,metric,value.""" - r = compute_report(entries) - buf = io.StringIO() - writer = csv.writer(buf) - - _DANGEROUS = ("=", "+", "-", "@", "\t", "\r") - - def _safe(row): - return ["'" + v if isinstance(v, str) and v[:1] in _DANGEROUS else v for v in row] - - def write(row): - writer.writerow(_safe(row)) - - write(["section", "position", "cue", "metric", "value"]) - for t in r["show_started"]: - write(["show_started", "", "", "time", t]) - for s in r["position_stats"]: - write(["standby_latency", s["position"], "", "count", s["count"]]) - write(["standby_latency", s["position"], "", "min_ms", _fmt_ms(s["min_ms"])]) - write(["standby_latency", s["position"], "", "avg_ms", _fmt_ms(s["avg_ms"])]) - write(["standby_latency", s["position"], "", "max_ms", _fmt_ms(s["max_ms"])]) - for lat in r["latencies"]: - write(["standby_latency_per_cue", lat["position"], lat["cue"], - "auto" if lat["auto"] else "manual", _fmt_ms(lat["ms"])]) - for u in r["unacked"]: - write(["unacked_standby", u["position"], u["cue"], "", ""]) - for g in r["go_intervals"]: - write(["go_interval", "", g["cue"], "seconds", f"{g['seconds']:.1f}"]) - write(["standby_counts", "", "", "manual", r["manual_standbys"]]) - write(["standby_counts", "", "", "auto", r["auto_standbys"]]) - for pos, c in r["joins"].items(): - write(["join_summary", pos, "", "joins", c["joins"]]) - write(["join_summary", pos, "", "disconnects", c["disconnects"]]) - for p in r["problems"]: - write(["problem", p["position"], p["cue"], p["cleared"] or "not cleared", p["message"]]) - return buf.getvalue() + body = f"""

CueLight Show Report

+

Summary

+{summary_tbl} +

Positions

+{pos_tbl} +

Time Between Master GOs

+{gap_tbl} +""" + return ( + f'\n\n\n' + f'\n' + f'\n' + f"CueLight Show Report\n" + f"\n" + f"\n\n{body}\n\n" + ) diff --git a/server/state.py b/server/state.py index 87a290d..d58e007 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 @@ -13,7 +14,6 @@ ) from .persistence import save_state from .showlog import ShowLog -from .timeutil import now_iso COLOR_PALETTE = [ @@ -28,11 +28,9 @@ ] -PROBLEM_MESSAGE_MAX = 60 - - class StateManager: MAX_POSITIONS = 16 + ATTENTION_MESSAGE_MAX = 120 def __init__(self, initial: AppState | None = None) -> None: self.state = initial or AppState() @@ -112,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 @@ -206,7 +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_time = now_iso() + self.state.last_go_at = time.time() if pos.type == PositionType.OSC: pos.standby = ButtonState.IDLE pos.armed = False @@ -267,8 +266,8 @@ 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()) - self.state.last_go_time = now_iso() fire_jobs: list[tuple[str, OscDevice, str]] = [] for pos in self.state.positions.values(): if pos.armed and pos.connected: @@ -374,55 +373,54 @@ async def clear_flash(self) -> None: self._persist() await self._notify_caller_full_state() - # --- Problem signal (position → caller) --- + # --- Operator attention --- - async def raise_problem(self, client_id: str, message: str = "") -> None: + 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.OSC: + if not pos or pos.type != PositionType.HUMAN: return - pos.problem = True - pos.problem_message = message.strip()[:PROBLEM_MESSAGE_MAX] - self.log.record("problem_raised", position=pos.label, - cue=self._current_cue_seq(), detail=pos.problem_message) + pos.attention = True + pos.attention_message = str(message or "")[: 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._send_position(client_id, { - "type": "problem_changed", - "problem": True, - "message": pos.problem_message, - }) await self._notify_caller_full_state() - async def clear_problem(self, client_id: str, by_caller: bool = False) -> None: + 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.problem: + if not pos or not pos.attention: return - pos.problem = False - pos.problem_message = "" - self.log.record("problem_cleared", position=pos.label, - cue=self._current_cue_seq(), - detail="by caller" if by_caller else "by operator") - self._persist() - await self._send_position(client_id, { - "type": "problem_changed", - "problem": False, - "message": "", - }) + 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 timer --- + # --- Show clock --- - async def start_show(self) -> None: + async def start_show_clock(self) -> None: async with self._lock: - restarted = bool(self.state.show_start_time) - self.state.show_start_time = now_iso() - self.log.record("show_started", detail="restart" if restarted else "") + 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._broadcast_positions({ - "type": "show_started", - "start_time": self.state.show_start_time, - }) await self._notify_caller_full_state() # --- Arm / disarm --- @@ -995,8 +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_start_time": self.state.show_start_time, - "last_go_time": self.state.last_go_time, + "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 5a3400f..09bd3f9 100644 --- a/server/ws.py +++ b/server/ws.py @@ -91,12 +91,6 @@ async def _handle_caller_message(ws: WebSocket, manager: StateManager, msg: dict elif msg_type == "clear_flash": await manager.clear_flash() - elif msg_type == "clear_problem": - await manager.clear_problem(msg["client_id"], by_caller=True) - - elif msg_type == "start_show": - await manager.start_show() - elif msg_type == "toggle_arm": await manager.toggle_arm(msg["client_id"]) @@ -142,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: @@ -211,8 +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 "", - "problem": pos.problem if pos else False, - "problem_message": pos.problem_message if pos else "", + "attention": pos.attention if pos else False, } sf = manager.state.showfile if sf and pos: @@ -298,11 +300,11 @@ async def _handle_position_message( elif msg_type == "ack_flash": await manager.ack_flash(client_id) - elif msg_type == "raise_problem": - await manager.raise_problem(client_id, str(msg.get("message") or "")) + elif msg_type == "raise_attention": + await manager.raise_attention(client_id, str(msg.get("message") or "")) - elif msg_type == "clear_problem": - await manager.clear_problem(client_id, by_caller=False) + 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 6e830c0..660ad57 100644 --- a/static/caller.html +++ b/static/caller.html @@ -22,6 +22,9 @@
+ +
+
@@ -51,8 +54,8 @@ - - + + @@ -110,28 +113,25 @@

Network

-

Show Timer

-
- - -
+

Show Log

- - + + +
+ +

Show Clock

- - + +
- -

Show Log

- - + +
- - + +
+ + + - - -
SYSTEM LOCKED
diff --git a/static/css/caller.css b/static/css/caller.css index 7046b34..deb9f30 100644 --- a/static/css/caller.css +++ b/static/css/caller.css @@ -124,27 +124,12 @@ body { color: var(--osc-accent); } -/* Problem signal: solid (NON-flashing — must not disturb the calling rhythm) - orange badge + column outline; tap the badge to read the message. */ -.position-col .col-header .col-badge.problem-badge { - color: #111; - background: var(--orange-bright); - border-radius: 3px; - cursor: pointer; -} - -.position-col.problem { - box-shadow: inset 0 0 0 2px var(--orange-bright); - border-radius: 4px; +.position-col .col-header .col-badge.attention-badge { + color: var(--attention-bright); } -/* Observer-only: the short problem message shown under the header */ -.position-col .problem-msg { - font-size: 10px; - color: var(--orange-bright); - text-align: center; - padding: 2px 4px; - overflow-wrap: anywhere; +.position-col.attention .col-header { + box-shadow: inset 0 3px 0 var(--attention-bright); } .position-col .col-btn { @@ -303,6 +288,49 @@ body { .warning-banner.visible { display: block; } +/* Operator attention: one row per raised position, message inline so the + caller can read it at a glance without opening anything. Sits above the + lock overlay (115): LOCK freezes cue actions, not attention — the caller + must be able to read and clear reports during a hold. */ +.attention-banner { + background: var(--attention-dim); + display: none; + position: relative; + z-index: 116; +} + +.attention-banner.visible { display: block; } + +.attention-banner .attention-row { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 12px; + font-size: 13px; +} + +.attention-banner .attention-row .who { + color: var(--attention-bright); + font-weight: 700; +} + +.attention-banner .attention-row .msg { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attention-banner .attention-row button { + margin-left: auto; + background: var(--attention-bright); + color: #111; + padding: 4px 12px; + border-radius: 6px; + font-size: 11px; + flex-shrink: 0; +} + /* Bottom bar */ .bottom-bar { background: var(--surface); @@ -335,16 +363,17 @@ body { .bottom-bar .spacer { flex: 1; } -/* Show timer: deliberately quiet — dim monospace text, no color accents, - so it never competes with cue state. */ -.bottom-bar .show-timer { +.bottom-bar .clock-strip { + background: none; color: var(--text-dim); - font-family: monospace; font-size: 13px; - white-space: nowrap; - padding: 0 4px; + font-weight: 700; + font-variant-numeric: tabular-nums; + padding: 8px 12px; } +.bottom-bar .clock-strip.running { color: var(--text); } + .bottom-bar .join-toggle { background: none; color: var(--text-dim); @@ -438,22 +467,6 @@ body { cursor: pointer; } -/* Problem readout modal */ -.modal .problem-message { - background: var(--bg); - border: 1px solid var(--orange-bright); - border-radius: 8px; - padding: 14px; - font-size: 16px; - margin-bottom: 12px; - overflow-wrap: anywhere; -} - -.modal .problem-clear-btn { - background: var(--orange-bright); - color: #111; -} - /* Jump-to-cue modal */ .cue-list { max-height: 300px; diff --git a/static/css/common.css b/static/css/common.css index db2517c..9d27269 100644 --- a/static/css/common.css +++ b/static/css/common.css @@ -19,9 +19,10 @@ --grey: #555; --osc-accent: #2a6a8a; --osc-accent-dim: #1a3a4a; - /* Problem signal: orange — distinct from standby red, go green, preset yellow */ - --orange-dim: #4a2a10; - --orange-bright: #ff9500; + /* Operator attention: orange, distinct from standby red, go green, + preset yellow, and the yellow missing-position warning */ + --attention-bright: #ff9500; + --attention-dim: #4a2f00; } html, body { @@ -63,3 +64,8 @@ input { .flashing { animation: flash 0.5s ease-in-out infinite; } + +/* Utility: hide regardless of the element's own display rule */ +.hidden { + display: none !important; +} diff --git a/static/css/position.css b/static/css/position.css index d63e647..85f6da0 100644 --- a/static/css/position.css +++ b/static/css/position.css @@ -98,11 +98,6 @@ body { font-size: 12px; } -.btn-dim.active { - background: var(--green-dim); - color: var(--green-bright); -} - .label-display { font-weight: 700; font-size: 14px; @@ -122,122 +117,140 @@ body { .health-dot.yellow { background: var(--yellow-bright); } .health-dot.red { background: var(--red-bright); } -/* Problem panel: in the layout flow (between the cue buttons and the bezel), - so opening it compresses STANDBY/GO but never covers them. Big touch - targets for a dark wing. */ -.problem-panel { +.caller-warning { + background: var(--red-dim); + color: var(--red-bright); + text-align: center; + padding: 4px; + font-size: 12px; + font-weight: 700; display: none; - flex-direction: column; - gap: 8px; - padding: 10px; - background: var(--surface); - border-top: 2px solid var(--orange-bright); } -.problem-panel.visible { display: flex; } +.caller-warning.visible { display: block; } + +.lock-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + display: none; + flex-direction: column; + gap: 24px; + align-items: center; + justify-content: center; + z-index: 100; + font-size: 36px; + font-weight: 700; + color: #888; +} -.problem-row { display: flex; gap: 8px; } +.lock-overlay.visible { display: flex; } -.problem-preset { - flex: 1; - padding: 20px 8px; - font-size: 15px; +/* Attention still works during a hold — the report button lives on the lock + overlay itself, and the panel sits above it (but below the dim overlay). */ +.lock-overlay .lock-attention { + background: var(--attention-dim); + color: var(--attention-bright); + padding: 10px 18px; border-radius: 8px; - background: var(--orange-dim); - color: var(--orange-bright); + font-size: 14px; } -.problem-row input { - flex: 1; - min-width: 0; - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - font-size: 15px; +.btn-attention { + background: var(--attention-dim); + color: var(--attention-bright); + padding: 8px 14px; + border-radius: 6px; + font-size: 12px; } -.problem-raise { - padding: 12px 20px; - border-radius: 8px; - background: var(--orange-bright); +.btn-attention.raised { + background: var(--attention-bright); color: #111; - font-size: 15px; + animation: flash 1s ease-in-out infinite; } -.problem-clear { +.attention-panel { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); display: none; - padding: 16px; - border-radius: 8px; - background: var(--border); - color: var(--orange-bright); + align-items: center; + justify-content: center; + z-index: 130; +} + +.attention-panel.visible { display: flex; } + +.attention-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px; + width: min(400px, 92vw); + display: flex; + flex-direction: column; + gap: 12px; +} + +.attention-box .attention-title { font-size: 15px; + font-weight: 700; + color: var(--attention-bright); } -.problem-panel.active .problem-clear { display: block; } +.attention-presets { display: flex; gap: 8px; flex-wrap: wrap; } -.btn-problem { - background: var(--orange-dim); - color: var(--orange-bright); - padding: 8px 16px; - border-radius: 6px; +.preset-chip { + background: var(--border); + color: var(--text); + padding: 10px 12px; + border-radius: 16px; font-size: 12px; } -.btn-problem.active { - background: var(--orange-bright); - color: #111; +.attention-box input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px; + color: var(--text); + font-size: 14px; } -/* Transient "show started" notice: floats over the header strip only, - never the cue buttons; taps pass through. */ -.show-banner { - position: fixed; - top: 8px; - left: 50%; - transform: translateX(-50%); - background: var(--green-dim); - color: var(--green-bright); - border: 1px solid var(--green-bright); - padding: 10px 28px; +.attention-actions { display: flex; gap: 8px; } + +.attention-actions button { + flex: 1; + padding: 12px; border-radius: 8px; - font-size: 16px; - font-weight: 700; - letter-spacing: 0.05em; - z-index: 80; - pointer-events: none; - display: none; + font-size: 13px; + background: var(--border); + color: var(--text); } -.show-banner.visible { display: block; } - -.caller-warning { - background: var(--red-dim); - color: var(--red-bright); - text-align: center; - padding: 4px; - font-size: 12px; - font-weight: 700; - display: none; +.attention-actions #attentionSendBtn, +.attention-actions #attentionWithdrawBtn { + background: var(--attention-bright); + color: #111; } -.caller-warning.visible { display: block; } - -.lock-overlay { +.toast { position: fixed; - inset: 0; - background: rgba(0,0,0,0.85); + bottom: 72px; + left: 50%; + transform: translateX(-50%); + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 10px 18px; + border-radius: 20px; + font-size: 14px; display: none; - align-items: center; - justify-content: center; - z-index: 100; - font-size: 36px; - font-weight: 700; - color: #888; + z-index: 140; } -.lock-overlay.visible { display: flex; } +.toast.visible { display: block; } /* Flash roll call: below the lock overlay so LOCK always wins */ .flash-overlay { diff --git a/static/js/caller.js b/static/js/caller.js index f9615ff..5de7feb 100644 --- a/static/js/caller.js +++ b/static/js/caller.js @@ -38,13 +38,15 @@ password: "", osc_patch_filename: "", auto_standby: false, - show_start_time: "", - last_go_time: "", + show_started_at: 0, + last_go_at: 0, }; + // Server-vs-device clock skew, measured from heartbeat pings, so the show + // clock reads server time even if the iPad's clock drifts. + let serverClockOffset = 0; let reconnectDelay = 500; let lockHoldTimer = null; let renamingClientId = null; - let problemClientId = null; let oscResultTimers = {}; let exited = false; @@ -108,8 +110,8 @@ state.password = msg.password || ""; state.osc_patch_filename = msg.osc_patch_filename || ""; state.auto_standby = !!msg.auto_standby; - state.show_start_time = msg.show_start_time || ""; - state.last_go_time = msg.last_go_time || ""; + state.show_started_at = msg.show_started_at || 0; + state.last_go_at = msg.last_go_at || 0; render(); break; @@ -118,6 +120,7 @@ break; case "ping": + serverClockOffset = Date.now() / 1000 - msg.ts; send({ type: "pong", ts: msg.ts }); break; } @@ -142,8 +145,9 @@ renderGrid(); renderTransport(); renderWarnings(); + renderAttention(); + renderClock(); renderLock(); - renderTimer(); // Keep the roll-call/health list live while Settings is open if (document.getElementById("settingsModal").classList.contains("visible")) { renderHealthList(); @@ -162,19 +166,19 @@ col.className = "position-col"; if (!pos.connected) col.classList.add("disconnected"); if (isOsc) col.classList.add("osc-col"); - if (pos.problem) col.classList.add("problem"); + if (pos.attention) col.classList.add("attention"); col.dataset.clientId = cid; // Header var header = document.createElement("div"); header.className = "col-header"; var badgeHtml; - if (isOsc) { + if (pos.attention) { + badgeHtml = '
⚠ ATTENTION
'; + } else if (isOsc) { badgeHtml = '
OSC
'; } else if (!pos.connected) { badgeHtml = '
DISCONNECTED
'; - } else if (pos.problem) { - badgeHtml = '
⚠ PROBLEM
'; } else { badgeHtml = '
'; } @@ -195,13 +199,6 @@ header.addEventListener("click", function () { openRename(cid, pos.label); }); - var problemBadge = header.querySelector(".problem-badge"); - if (problemBadge) { - problemBadge.addEventListener("click", function (e) { - e.stopPropagation(); - openProblem(cid); - }); - } col.appendChild(header); // Standby @@ -295,6 +292,110 @@ lockOverlay.classList.toggle("visible", state.locked); } + // --- Operator attention banner --- + function renderAttention() { + const banner = document.getElementById("attentionBanner"); + const raised = Object.entries(state.positions).filter(([, p]) => p.attention); + banner.innerHTML = ""; + if (raised.length === 0) { + banner.classList.remove("visible"); + return; + } + raised.forEach(([cid, pos]) => { + const row = document.createElement("div"); + row.className = "attention-row"; + row.innerHTML = + '⚠ ' + escHtml(pos.label) + "" + + (pos.attention_message + ? '' + escHtml(pos.attention_message) + "" + : ""); + const btn = document.createElement("button"); + btn.textContent = "CLEAR"; + btn.addEventListener("click", () => { + send({ type: "clear_attention", client_id: cid }); + }); + row.appendChild(btn); + banner.appendChild(row); + }); + banner.classList.add("visible"); + } + + // --- Show clock --- + const clockStrip = document.getElementById("clockStrip"); + let clockPrefs; + try { + clockPrefs = JSON.parse(localStorage.getItem("cuelight_clock_prefs")) || {}; + } catch (e) { + clockPrefs = {}; + } + clockPrefs = Object.assign({ time: true, elapsed: true, sinceGo: false }, clockPrefs); + + function saveClockPrefs() { + localStorage.setItem("cuelight_clock_prefs", JSON.stringify(clockPrefs)); + } + + function fmtDur(s) { + s = Math.max(0, Math.floor(s)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h > 0 + ? h + ":" + String(m).padStart(2, "0") + ":" + String(sec).padStart(2, "0") + : m + ":" + String(sec).padStart(2, "0"); + } + + function serverNow() { + return Date.now() / 1000 - serverClockOffset; + } + + function renderClock() { + const parts = []; + if (clockPrefs.time) { + parts.push(new Date().toLocaleTimeString([], { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + })); + } + if (clockPrefs.elapsed) { + parts.push(state.show_started_at > 0 + ? "SHOW " + fmtDur(serverNow() - state.show_started_at) + : "SHOW --:--"); + } + if (clockPrefs.sinceGo) { + parts.push(state.last_go_at > 0 + ? "GO +" + fmtDur(serverNow() - state.last_go_at) + : "GO --"); + } + clockStrip.textContent = parts.length ? parts.join(" · ") : "⏱"; + clockStrip.classList.toggle("running", state.show_started_at > 0); + } + + setInterval(renderClock, 1000); + + clockStrip.addEventListener("click", () => { + const started = state.show_started_at > 0; + document.getElementById("clockStatus").textContent = started + ? "Show started at " + + new Date(state.show_started_at * 1000).toLocaleTimeString() + + " — elapsed " + fmtDur(serverNow() - state.show_started_at) + : "Clock not started. START SHOW records the time in the show log and " + + "notifies all positions."; + document.getElementById("startShowBtn").classList.toggle("hidden", started); + document.getElementById("clearClockBtn").classList.toggle("hidden", !started); + document.getElementById("clockModal").classList.add("visible"); + }); + + document.getElementById("startShowBtn").addEventListener("click", () => { + send({ type: "start_show_clock" }); + closeModal("clockModal"); + }); + + document.getElementById("clearClockBtn").addEventListener("click", () => { + if (confirm("Clear the show clock?")) { + send({ type: "clear_show_clock" }); + closeModal("clockModal"); + } + }); + // --- Master buttons --- document.getElementById("masterStandby").addEventListener("click", () => { if (!state.locked) send({ type: "standby_armed" }); @@ -363,7 +464,7 @@ const btn = document.getElementById("resumeShowBtn"); btn.style.display = ""; btn.addEventListener("click", async () => { - const res = await (await fetch("/api/resume_show", { method: "POST" })).json(); + const res = await (await fetch("/api/resume_show", { method: "POST", headers: pwAuth() })).json(); if (res.ok) location.reload(); }); return; @@ -465,76 +566,6 @@ if (e.key === "Enter") document.getElementById("renameSaveBtn").click(); }); - // --- Problem readout --- - function openProblem(clientId) { - problemClientId = clientId; - var pos = state.positions[clientId]; - if (!pos) return; - document.getElementById("problemTitle").textContent = pos.label + " — PROBLEM"; - document.getElementById("problemMessage").textContent = - pos.problem_message || "(no message)"; - document.getElementById("problemModal").classList.add("visible"); - } - - document.getElementById("problemClearCallerBtn").addEventListener("click", () => { - if (problemClientId) { - send({ type: "clear_problem", client_id: problemClientId }); - } - closeModal("problemModal"); - }); - - // --- Show timer + START SHOW --- - const showTimer = document.getElementById("showTimer"); - const TIMER_PREFS_KEY = "cuelight_timer_prefs"; - let timerPrefs = { elapsed: true, sinceGo: true, clock: false }; - try { - var storedPrefs = JSON.parse(localStorage.getItem(TIMER_PREFS_KEY) || "{}"); - ["elapsed", "sinceGo", "clock"].forEach(function (k) { - if (typeof storedPrefs[k] === "boolean") timerPrefs[k] = storedPrefs[k]; - }); - } catch (e) {} - - function saveTimerPrefs() { - localStorage.setItem(TIMER_PREFS_KEY, JSON.stringify(timerPrefs)); - } - - function fmtDuration(ms) { - var s = Math.max(0, Math.floor(ms / 1000)); - var h = Math.floor(s / 3600); - var m = Math.floor((s % 3600) / 60); - var sec = s % 60; - function pad(n) { return (n < 10 ? "0" : "") + n; } - return h > 0 ? h + ":" + pad(m) + ":" + pad(sec) : pad(m) + ":" + pad(sec); - } - - function renderTimer() { - var parts = []; - var now = Date.now(); - if (timerPrefs.elapsed && state.show_start_time) { - var start = Date.parse(state.show_start_time); - if (!isNaN(start)) parts.push("SHOW " + fmtDuration(now - start)); - } - if (timerPrefs.sinceGo && state.last_go_time) { - var lastGo = Date.parse(state.last_go_time); - if (!isNaN(lastGo)) parts.push("GO +" + fmtDuration(now - lastGo)); - } - if (timerPrefs.clock) { - var d = new Date(); - function pad2(n) { return (n < 10 ? "0" : "") + n; } - parts.push(pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds())); - } - showTimer.textContent = parts.join(" · "); - } - - setInterval(renderTimer, 1000); - - document.getElementById("startShowBtn").addEventListener("click", () => { - const prompt = state.show_start_time - ? "Restart the show clock? The start time will be re-recorded." - : "Start the show clock? All positions get a brief notice."; - if (confirm(prompt)) send({ type: "start_show" }); - }); - // --- Settings modal --- document.getElementById("settingsBtn").addEventListener("click", async () => { await populateSettings(); @@ -594,6 +625,11 @@ document.getElementById("autoStandbyToggle").checked = state.auto_standby; + // Show clock display preferences (device-local) + document.getElementById("clockShowTime").checked = clockPrefs.time; + document.getElementById("clockShowElapsed").checked = clockPrefs.elapsed; + document.getElementById("clockShowGo").checked = clockPrefs.sinceGo; + // Patches try { const res = await fetch("/api/patches"); @@ -631,11 +667,6 @@ } } catch (e) {} - // Timer display preferences (local to this device) - document.getElementById("timerElapsedToggle").checked = timerPrefs.elapsed; - document.getElementById("timerSinceGoToggle").checked = timerPrefs.sinceGo; - document.getElementById("timerClockToggle").checked = timerPrefs.clock; - // Password document.getElementById("pwToggle").checked = state.password_enabled; document.getElementById("pwValueField").style.display = state.password_enabled ? "flex" : "none"; @@ -663,6 +694,24 @@ send({ type: "set_auto_standby", enabled: e.target.checked }); }); + document.getElementById("clockShowTime").addEventListener("change", (e) => { + clockPrefs.time = e.target.checked; + saveClockPrefs(); + renderClock(); + }); + + document.getElementById("clockShowElapsed").addEventListener("change", (e) => { + clockPrefs.elapsed = e.target.checked; + saveClockPrefs(); + renderClock(); + }); + + document.getElementById("clockShowGo").addEventListener("change", (e) => { + clockPrefs.sinceGo = e.target.checked; + saveClockPrefs(); + renderClock(); + }); + document.getElementById("loadPatchBtn").addEventListener("click", () => { const filename = document.getElementById("patchSelect").value; if (filename) { @@ -683,7 +732,7 @@ document.getElementById("resumeShowSettingsBtn").addEventListener("click", async () => { if (!confirm("Replace the current show with the previous one?")) return; try { - const res = await (await fetch("/api/resume_show", { method: "POST" })).json(); + const res = await (await fetch("/api/resume_show", { method: "POST", headers: pwAuth() })).json(); if (res.ok) closeModal("settingsModal"); } catch (e) {} }); @@ -698,39 +747,53 @@ }); // --- Show log --- - document.getElementById("downloadLogBtn").addEventListener("click", () => { - window.open("/api/showlog?format=csv"); - }); - - document.getElementById("viewLogBtn").addEventListener("click", () => { - window.open("/api/showlog"); - }); + // Protected endpoints want the join password when one is set; the caller + // already has it from full_state. It goes in a header, never a query + // param — query strings end up in server access logs and browser history. + function pwAuth() { + return state.password_enabled && state.password + ? { "X-CueLight-Password": state.password } + : {}; + } - document.getElementById("showReportBtn").addEventListener("click", () => { - window.open("/api/showreport"); - }); + // The tab must be opened synchronously — Safari's popup blocker only + // allows window.open inside the tap gesture, not after an await. + async function viewAuthed(path) { + const win = window.open("", "_blank"); + if (!win) return; + try { + const res = await fetch(path, { headers: pwAuth() }); + if (!res.ok) throw new Error(res.status); + const text = await res.text(); + win.location = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + } catch (e) { + win.close(); + } + } - document.getElementById("showReportCsvBtn").addEventListener("click", () => { - window.open("/api/showreport?format=csv"); - }); + async function downloadAuthed(path, filename) { + try { + const res = await fetch(path, { headers: pwAuth() }); + if (!res.ok) return; + const url = URL.createObjectURL(await res.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } catch (e) {} + } - // --- Timer display preferences --- - document.getElementById("timerElapsedToggle").addEventListener("change", (e) => { - timerPrefs.elapsed = e.target.checked; - saveTimerPrefs(); - renderTimer(); + document.getElementById("downloadLogBtn").addEventListener("click", () => { + downloadAuthed("/api/showlog?format=csv", "showlog.csv"); }); - document.getElementById("timerSinceGoToggle").addEventListener("change", (e) => { - timerPrefs.sinceGo = e.target.checked; - saveTimerPrefs(); - renderTimer(); + document.getElementById("viewLogBtn").addEventListener("click", () => { + viewAuthed("/api/showlog"); }); - document.getElementById("timerClockToggle").addEventListener("change", (e) => { - timerPrefs.clock = e.target.checked; - saveTimerPrefs(); - renderTimer(); + document.getElementById("showReportBtn").addEventListener("click", () => { + viewAuthed("/api/showreport?format=txt"); }); document.getElementById("pwToggle").addEventListener("change", (e) => { @@ -791,7 +854,7 @@ document.getElementById("closeSettings").addEventListener("click", () => closeModal("settingsModal")); document.getElementById("closeJump").addEventListener("click", () => closeModal("jumpModal")); document.getElementById("closeRename").addEventListener("click", () => closeModal("renameModal")); - document.getElementById("closeProblem").addEventListener("click", () => closeModal("problemModal")); + document.getElementById("closeClock").addEventListener("click", () => closeModal("clockModal")); // Close modals on overlay click document.querySelectorAll(".modal-overlay").forEach((overlay) => { diff --git a/static/js/editor.js b/static/js/editor.js index 74abaab..2c505a8 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -17,6 +17,26 @@ document.getElementById("tab-patches").classList.add("active"); } + // --- Password auth --- + // Saving (and probe-testing) requires the show password when one is set. + // The editor runs standalone, so it asks on the first 401 and remembers + // the answer for the session. + var authPassword = sessionStorage.getItem("cuelight_editor_pw") || ""; + + async function authFetch(url, options) { + options = options || {}; + while (true) { + options.headers = Object.assign({}, options.headers); + if (authPassword) options.headers["X-CueLight-Password"] = authPassword; + var res = await fetch(url, options); + if (res.status !== 401) return res; + var pw = prompt("This show is password-protected. Enter the show password:"); + if (pw === null) return res; + authPassword = pw; + sessionStorage.setItem("cuelight_editor_pw", pw); + } + } + // ========== SHOWFILE EDITOR ========== var cueBody = document.getElementById("cueBody"); var cues = []; @@ -88,7 +108,7 @@ }; }), }; - var res = await fetch("/api/showfile/" + encodeURIComponent(currentFilename), { + var res = await authFetch("/api/showfile/" + encodeURIComponent(currentFilename), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), @@ -98,7 +118,7 @@ loadFileList(); } else { var err = await res.json(); - showStatus("statusMsg", "Errors: " + (err.errors || []).join("; "), true); + showStatus("statusMsg", "Errors: " + (err.errors || [err.error || "save failed"]).join("; "), true); } }); @@ -324,7 +344,7 @@ return dev; }), }; - var res = await fetch("/api/patch/" + encodeURIComponent(currentPatchFilename), { + var res = await authFetch("/api/patch/" + encodeURIComponent(currentPatchFilename), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), @@ -334,7 +354,7 @@ loadPatchList(); } else { var err = await res.json(); - showStatus("patchStatusMsg", "Errors: " + (err.errors || []).join("; "), true); + showStatus("patchStatusMsg", "Errors: " + (err.errors || [err.error || "save failed"]).join("; "), true); } }); @@ -484,7 +504,7 @@ protocol: d.protocol, ping_template: d.ping_template, expect_reply: d.expect_reply, }; - var res = await fetch("/api/patch/_probe_test", { + var res = await authFetch("/api/patch/_probe_test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/static/js/observer.js b/static/js/observer.js index b173028..7b7e5c5 100644 --- a/static/js/observer.js +++ b/static/js/observer.js @@ -98,9 +98,31 @@ renderGrid(); renderTransport(); renderWarnings(); + renderAttention(); renderStatus(); } + function renderAttention() { + const banner = document.getElementById("attentionBanner"); + const raised = Object.values(state.positions).filter((p) => p.attention); + banner.innerHTML = ""; + if (raised.length === 0) { + banner.classList.remove("visible"); + return; + } + raised.forEach((pos) => { + const row = document.createElement("div"); + row.className = "attention-row"; + row.innerHTML = + '⚠ ' + escHtml(pos.label) + "" + + (pos.attention_message + ? '' + escHtml(pos.attention_message) + "" + : ""); + banner.appendChild(row); + }); + banner.classList.add("visible"); + } + function renderGrid() { grid.innerHTML = ""; Object.keys(state.positions).forEach(function (cid) { @@ -110,17 +132,17 @@ col.className = "position-col"; if (!pos.connected) col.classList.add("disconnected"); if (isOsc) col.classList.add("osc-col"); - if (pos.problem) col.classList.add("problem"); + if (pos.attention) col.classList.add("attention"); var header = document.createElement("div"); header.className = "col-header"; var badgeHtml; - if (isOsc) { + if (pos.attention) { + badgeHtml = '
⚠ ATTENTION
'; + } else if (isOsc) { badgeHtml = '
OSC
'; } else if (!pos.connected) { badgeHtml = '
DISCONNECTED
'; - } else if (pos.problem) { - badgeHtml = '
⚠ PROBLEM
'; } else { badgeHtml = '
'; } @@ -131,14 +153,6 @@ '
' + escHtml(pos.cue_indicator) + "
"; col.appendChild(header); - // Read-only: the observer sees the message inline under the header - if (pos.problem && pos.problem_message) { - var msgEl = document.createElement("div"); - msgEl.className = "problem-msg"; - msgEl.textContent = pos.problem_message; - col.appendChild(msgEl); - } - var sbBtn = document.createElement("div"); sbBtn.className = "col-btn btn-standby-caller"; if (isOsc) { diff --git a/static/js/position.js b/static/js/position.js index aa7f616..f69a496 100644 --- a/static/js/position.js +++ b/static/js/position.js @@ -18,10 +18,6 @@ const lockOverlay = document.getElementById("lockOverlay"); const noteDisplay = document.getElementById("noteDisplay"); const flashOverlay = document.getElementById("flashOverlay"); - const showBanner = document.getElementById("showBanner"); - const problemPanel = document.getElementById("problemPanel"); - const problemBtn = document.getElementById("problemBtn"); - const problemInput = document.getElementById("problemInput"); let ws = null; let standbyState = "idle"; @@ -53,9 +49,9 @@ case "joined": document.getElementById("labelDisplay").textContent = msg.label; applyColor(msg.color || ""); + setAttention(!!msg.attention); setStandby(msg.standby); setGo(msg.go); - setProblem(!!msg.problem); toggleLock(msg.locked); callerWarning.classList.toggle("visible", !msg.caller_connected); if (msg.scene) sceneDisplay.textContent = `Scene ${msg.scene}`; @@ -74,25 +70,15 @@ case "standby_called": hideFlash(); - hideProblemPanel(); setStandby("called"); - alertStandby(); + cueAlert("standby"); break; case "go_called": hideFlash(); - hideProblemPanel(); setStandby("idle"); setGo("called"); - alertGo(); - break; - - case "problem_changed": - setProblem(!!msg.problem); - break; - - case "show_started": - showStartBanner(); + cueAlert("go"); break; case "flash": @@ -122,6 +108,15 @@ showNote(msg.note || ""); break; + case "attention_cleared": + setAttention(false); + showToast("✓ Seen by caller"); + break; + + case "show_started": + showToast("⏱ Show clock started"); + break; + case "caller_disconnected": callerWarning.classList.add("visible"); break; @@ -214,81 +209,68 @@ location.href = "/join"; }); - // --- Problem signal --- - let problemActive = false; - let bannerTimer = null; - - function setProblem(active) { - problemActive = active; - problemBtn.classList.toggle("active", active); - problemPanel.classList.toggle("active", active); - if (!active) hideProblemPanel(); - } - - function hideProblemPanel() { - problemPanel.classList.remove("visible"); - } + // --- Running-mode dimming (cycles off → dim → red → off) --- + const dimOverlay = document.getElementById("dimOverlay"); + const dimBtn = document.getElementById("dimBtn"); + const DIM_MODES = ["off", "dim", "red"]; + let dimMode = localStorage.getItem("cuelight_dim_mode") || "off"; + if (DIM_MODES.indexOf(dimMode) === -1) dimMode = "off"; - function raiseProblem(message) { - ws.send(JSON.stringify({ type: "raise_problem", message: (message || "").slice(0, 60) })); - hideProblemPanel(); - problemInput.value = ""; - problemInput.blur(); + function applyDim() { + dimOverlay.classList.toggle("dim", dimMode === "dim"); + dimOverlay.classList.toggle("red", dimMode === "red"); + dimBtn.textContent = + dimMode === "off" ? "DIM" : dimMode === "dim" ? "DIM: ON" : "DIM: RED"; + localStorage.setItem("cuelight_dim_mode", dimMode); } - problemBtn.addEventListener("click", () => { - problemPanel.classList.toggle("visible"); - }); - - problemPanel.querySelectorAll(".problem-preset").forEach((btn) => { - btn.addEventListener("click", () => { - raiseProblem(btn.dataset.msg); - }); + dimBtn.addEventListener("click", () => { + dimMode = DIM_MODES[(DIM_MODES.indexOf(dimMode) + 1) % DIM_MODES.length]; + applyDim(); }); - document.getElementById("problemRaiseBtn").addEventListener("click", () => { - raiseProblem(problemInput.value.trim()); - }); + applyDim(); - document.getElementById("problemClearBtn").addEventListener("click", () => { - ws.send(JSON.stringify({ type: "clear_problem" })); - hideProblemPanel(); - }); + // --- Toast (transient feedback: "seen by caller", show clock) --- + const toast = document.getElementById("toast"); + let toastTimer = null; - // --- Show started notice (transient, non-blocking) --- - function showStartBanner() { - showBanner.classList.add("visible"); - if (bannerTimer) clearTimeout(bannerTimer); - bannerTimer = setTimeout(() => { - showBanner.classList.remove("visible"); - bannerTimer = null; - }, 4000); + function showToast(text) { + toast.textContent = text; + toast.classList.add("visible"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.remove("visible"), 3000); } - // --- Operator alerts: beep + vibration on incoming cues (opt-in) --- - // WebAudio needs a user gesture to start (same constraint as keepawake), - // so the context is created/resumed on taps while alerts are on. - // navigator.vibrate is Android-only; iOS relies on the beep. + // --- Cue alert: optional beep + vibrate on standby/GO (off by default) --- + // WebAudio needs a user gesture to start on iOS; the toggle tap (or the + // first tap on the page when re-enabled from a previous session) arms it. const alertBtn = document.getElementById("alertBtn"); - let alertsOn = localStorage.getItem("cuelight_alerts") === "on"; + let alertOn = localStorage.getItem("cuelight_alert_mode") === "on"; let audioCtx = null; function ensureAudio() { - var AC = window.AudioContext || window.webkitAudioContext; - if (!AC) return; if (!audioCtx) { - try { audioCtx = new AC(); } catch (e) { return; } + const AC = window.AudioContext || window.webkitAudioContext; + if (AC) audioCtx = new AC(); } - if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {}); + if (audioCtx && audioCtx.state === "suspended") { + // resume() is async; callers must wait for it or the first beep + // after enabling alerts lands while the context is still suspended + return audioCtx.resume().catch(() => {}); + } + return Promise.resolve(); } - function beepAt(t, freq, dur) { - var osc = audioCtx.createOscillator(); - var gain = audioCtx.createGain(); + function beep(freq, dur, delay) { + if (!audioCtx) return; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); osc.type = "sine"; osc.frequency.value = freq; + const t = audioCtx.currentTime + delay; gain.gain.setValueAtTime(0.0001, t); - gain.gain.exponentialRampToValueAtTime(0.4, t + 0.01); + gain.gain.exponentialRampToValueAtTime(0.3, t + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); osc.connect(gain); gain.connect(audioCtx.destination); @@ -296,76 +278,101 @@ osc.stop(t + dur + 0.05); } - function vibrate(pattern) { + function cueAlert(kind) { + if (!alertOn) return; + ensureAudio().then(() => { + if (!audioCtx || audioCtx.state !== "running") return; + if (kind === "standby") { + beep(880, 0.15, 0); + beep(880, 0.15, 0.22); + } else { + beep(660, 0.25, 0); + } + }); if (navigator.vibrate) { - try { navigator.vibrate(pattern); } catch (e) {} + try { navigator.vibrate(kind === "standby" ? [200, 100, 200] : 300); } catch (e) {} } } - function alertStandby() { - if (!alertsOn) return; - vibrate([120, 90, 120]); - if (audioCtx && audioCtx.state === "running") { - var t = audioCtx.currentTime; - beepAt(t, 880, 0.12); // two short beeps = standby - beepAt(t + 0.22, 880, 0.12); - } + function applyAlert() { + alertBtn.textContent = alertOn ? "ALERT: ON" : "ALERT"; + alertBtn.classList.toggle("active", alertOn); + localStorage.setItem("cuelight_alert_mode", alertOn ? "on" : "off"); } - function alertGo() { - if (!alertsOn) return; - vibrate([400]); - if (audioCtx && audioCtx.state === "running") { - beepAt(audioCtx.currentTime, 587, 0.45); // one long beep = GO - } + alertBtn.addEventListener("click", () => { + alertOn = !alertOn; + if (alertOn) ensureAudio(); + applyAlert(); + }); + + // Persistent, not { once: true }: iOS suspends the AudioContext on audio + // interruptions (calls, Siri) and resume() outside a user gesture can be + // rejected, so every tap must be able to re-arm it. + document.addEventListener("pointerdown", () => { + if (alertOn) ensureAudio(); + }); + + applyAlert(); + + // --- Attention: report a problem to the caller --- + const attentionBtn = document.getElementById("attentionBtn"); + const attentionPanel = document.getElementById("attentionPanel"); + const attentionTitle = document.getElementById("attentionTitle"); + const attentionPresets = document.getElementById("attentionPresets"); + const attentionInput = document.getElementById("attentionInput"); + const attentionSendBtn = document.getElementById("attentionSendBtn"); + const attentionWithdrawBtn = document.getElementById("attentionWithdrawBtn"); + let attentionRaised = false; + + function setAttention(raised) { + attentionRaised = raised; + attentionBtn.classList.toggle("raised", raised); } - function applyAlerts() { - alertBtn.textContent = alertsOn ? "ALERT: ON" : "ALERT"; - alertBtn.classList.toggle("active", alertsOn); - localStorage.setItem("cuelight_alerts", alertsOn ? "on" : "off"); + function openAttentionPanel() { + attentionTitle.textContent = attentionRaised + ? "Report sent — waiting for caller" + : "Report a problem to the caller"; + attentionPresets.classList.toggle("hidden", attentionRaised); + attentionInput.classList.toggle("hidden", attentionRaised); + attentionSendBtn.classList.toggle("hidden", attentionRaised); + attentionWithdrawBtn.classList.toggle("hidden", !attentionRaised); + attentionPanel.classList.add("visible"); } - alertBtn.addEventListener("click", () => { - alertsOn = !alertsOn; - applyAlerts(); - if (alertsOn) { - // This tap is the unlock gesture; a soft blip confirms sound works. - // resume() is async, so chain the confirmation beep off its promise. - ensureAudio(); - Promise.resolve(audioCtx && audioCtx.resume ? audioCtx.resume() : null).then(function () { - if (audioCtx && audioCtx.state === "running") { - beepAt(audioCtx.currentTime, 880, 0.08); - } - }); - } - }); + function closeAttentionPanel() { + attentionPanel.classList.remove("visible"); + } - document.addEventListener("touchend", function () { if (alertsOn) ensureAudio(); }, true); - document.addEventListener("click", function () { if (alertsOn) ensureAudio(); }, true); + function raiseAttention(message) { + ws.send(JSON.stringify({ type: "raise_attention", message: message || "" })); + setAttention(true); + attentionInput.value = ""; + closeAttentionPanel(); + } - applyAlerts(); + attentionBtn.addEventListener("click", openAttentionPanel); + document.getElementById("lockAttentionBtn").addEventListener("click", openAttentionPanel); + document.getElementById("attentionCancelBtn").addEventListener("click", closeAttentionPanel); - // --- Running-mode dimming (cycles off → dim → red → off) --- - const dimOverlay = document.getElementById("dimOverlay"); - const dimBtn = document.getElementById("dimBtn"); - const DIM_MODES = ["off", "dim", "red"]; - let dimMode = localStorage.getItem("cuelight_dim_mode") || "off"; - if (DIM_MODES.indexOf(dimMode) === -1) dimMode = "off"; + attentionPresets.querySelectorAll(".preset-chip").forEach((chip) => { + chip.addEventListener("click", () => raiseAttention(chip.textContent)); + }); - function applyDim() { - dimOverlay.className = "dim-overlay" + (dimMode === "off" ? "" : " " + dimMode); - dimBtn.textContent = - dimMode === "off" ? "DIM" : dimMode === "dim" ? "DIM: ON" : "DIM: RED"; - localStorage.setItem("cuelight_dim_mode", dimMode); - } + attentionSendBtn.addEventListener("click", () => { + raiseAttention(attentionInput.value.trim()); + }); - dimBtn.addEventListener("click", () => { - dimMode = DIM_MODES[(DIM_MODES.indexOf(dimMode) + 1) % DIM_MODES.length]; - applyDim(); + attentionWithdrawBtn.addEventListener("click", () => { + ws.send(JSON.stringify({ type: "clear_attention" })); + setAttention(false); + closeAttentionPanel(); }); - applyDim(); + attentionPanel.addEventListener("click", (e) => { + if (e.target === attentionPanel) closeAttentionPanel(); + }); connect(); })(); diff --git a/static/observer.html b/static/observer.html index 63e6fb8..6c4f3d5 100644 --- a/static/observer.html +++ b/static/observer.html @@ -23,6 +23,9 @@
+ +
+
diff --git a/static/position.html b/static/position.html index d6ba93c..7219487 100644 --- a/static/position.html +++ b/static/position.html @@ -17,36 +17,41 @@
- -
-
- - - -
-
- - -
- -
Caller disconnected — waiting…
- +
-
SHOW STARTED
FLASH CHECK
TAP TO CONFIRM
-
LOCKED
+
+ LOCKED + +
+
+
+
Report a problem to the caller
+
+ + + +
+ +
+ + + +
+
+
+
diff --git a/tests/test_cuelight.py b/tests/test_cuelight.py index 789604f..3d2c3ea 100644 --- a/tests/test_cuelight.py +++ b/tests/test_cuelight.py @@ -10,6 +10,7 @@ import asyncio import json +import os import tempfile import threading import time @@ -22,6 +23,10 @@ from websockets.asyncio.client import connect import websockets.exceptions +# The reset endpoint only exists when this is set; the server runs in a thread +# of this process, so it sees the variable. +os.environ["CUELIGHT_TEST_MODE"] = "1" + PROJECT_ROOT = Path(__file__).resolve().parent.parent STATE_SNAPSHOT = PROJECT_ROOT / "state" / "snapshot.json" TEST_PORT = 8001 @@ -141,6 +146,20 @@ async def recv_settled_probe(cws, client_id: str, timeout: float = 3.0): raise AssertionError(f"probe for {client_id} did not settle within {timeout}s") +async def recv_full_state_where(ws, pred, timeout: float = 2.0): + """Waits for a full_state matching pred (other messages may interleave).""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time()) + except asyncio.TimeoutError: + break + msg = json.loads(raw) + if msg.get("type") == "full_state" and pred(msg): + return msg + raise AssertionError(f"expected full_state not received within {timeout}s") + + async def drain(ws, count: int = 20, timeout: float = 0.3): msgs = [] for _ in range(count): @@ -2448,168 +2467,241 @@ async def run(): asyncio.run(run()) -class TestProblemSignal(CueLightTestCase): - """A position can raise a PROBLEM flag (with an optional short message) - that marks its column on the caller grid until cleared.""" +class TestHttpHardening(CueLightTestCase): + """The test-reset gate and password auth on mutating HTTP endpoints.""" - def _get_log(self) -> dict: - return json.loads(urllib_request.urlopen(f"{HTTP_URL}/api/showlog", timeout=2).read()) + VALID_SHOWFILE = json.dumps({ + "show_name": "PW Test", "version": 1, + "cues": [{"sequence": 1, "scene": "1.1", + "targets": [{"position": "LX", "cue_number": "1"}]}], + }).encode() - def test_raise_reaches_caller_and_echoes_to_position(self): + def _http(self, method: str, path: str, body: bytes | None = None, + headers: dict | None = None): + req = urllib_request.Request( + f"{HTTP_URL}{path}", + data=body, + headers={"Content-Type": "application/json", **(headers or {})}, + method=method, + ) + try: + resp = urllib_request.urlopen(req, timeout=2) + return resp.status, resp.read() + except HTTPError as e: + return e.code, e.read() + + def _enable_password(self, password: str = "secret"): + async def run(): + cws, _, _ = await connect_caller() + await cws.send(json.dumps( + {"type": "set_password", "enabled": True, "password": password})) + await recv_type(cws, "full_state") + await cws.close() + asyncio.run(run()) + + def test_test_reset_absent_without_env(self): + os.environ.pop("CUELIGHT_TEST_MODE", None) + try: + status, _ = self._http("POST", "/api/_test_reset", b"") + self.assertEqual(status, 404) + finally: + os.environ["CUELIGHT_TEST_MODE"] = "1" + + def test_showfile_save_requires_password(self): + self._enable_password() + path = "/api/showfile/_test_pw_tmp.json" + try: + status, _ = self._http("POST", path, self.VALID_SHOWFILE) + self.assertEqual(status, 401) + status, _ = self._http("POST", path, self.VALID_SHOWFILE, + {"X-CueLight-Password": "wrong"}) + self.assertEqual(status, 401) + status, _ = self._http("POST", path, self.VALID_SHOWFILE, + {"X-CueLight-Password": "secret"}) + self.assertEqual(status, 200) + finally: + (PROJECT_ROOT / "showfiles" / "_test_pw_tmp.json").unlink(missing_ok=True) + + def test_patch_save_and_probe_require_password(self): + self._enable_password() + status, _ = self._http("POST", "/api/patch/_test_pw_tmp.json", b"{}") + self.assertEqual(status, 401) + status, _ = self._http("POST", "/api/patch/_probe_test", b"{}") + self.assertEqual(status, 401) + + def test_showlog_requires_password(self): + self._enable_password() + status, _ = self._http("GET", "/api/showlog") + self.assertEqual(status, 401) + # The download link can't set headers, so a query param also works + status, body = self._http("GET", "/api/showlog?password=secret") + self.assertEqual(status, 200) + self.assertIn("entries", json.loads(body)) + + def test_resume_requires_password(self): + self._enable_password() + status, _ = self._http("POST", "/api/resume_show", b"") + self.assertEqual(status, 401) + + def test_endpoints_open_when_no_password(self): + try: + status, _ = self._http( + "POST", "/api/showfile/_test_pw_tmp.json", self.VALID_SHOWFILE) + self.assertEqual(status, 200) + finally: + (PROJECT_ROOT / "showfiles" / "_test_pw_tmp.json").unlink(missing_ok=True) + status, _ = self._http("GET", "/api/showlog") + self.assertEqual(status, 200) + + +class TestAttention(CueLightTestCase): + def test_raise_and_clear_by_caller(self): async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - await pws.send(json.dumps({"type": "raise_problem", "message": "PROP ISSUE"})) - echo = await recv_type(pws, "problem_changed") - self.assertTrue(echo["problem"]) - self.assertEqual(echo["message"], "PROP ISSUE") + await pws.send(json.dumps({"type": "raise_attention", "message": "mic 3 dead"})) + msg = await recv_full_state_where( + cws, lambda m: m["positions"].get("p1", {}).get("attention")) + self.assertEqual(msg["positions"]["p1"]["attention_message"], "mic 3 dead") - state = await recv_type(cws, "full_state") - self.assertTrue(state["positions"]["p1"]["problem"]) - self.assertEqual(state["positions"]["p1"]["problem_message"], "PROP ISSUE") + await cws.send(json.dumps({"type": "clear_attention", "client_id": "p1"})) + await recv_type(pws, "attention_cleared") + msg = await recv_full_state_where( + cws, lambda m: not m["positions"].get("p1", {}).get("attention")) + self.assertEqual(msg["positions"]["p1"]["attention_message"], "") await pws.close() await cws.close() asyncio.run(run()) - def test_message_capped_at_60_chars(self): + def test_operator_can_withdraw(self): async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - await pws.send(json.dumps({"type": "raise_problem", "message": "x" * 200})) - state = await recv_type(cws, "full_state") - self.assertEqual(len(state["positions"]["p1"]["problem_message"]), 60) + await pws.send(json.dumps({"type": "raise_attention", "message": "wait"})) + await recv_full_state_where( + cws, lambda m: m["positions"].get("p1", {}).get("attention")) + await pws.send(json.dumps({"type": "clear_attention"})) + await recv_full_state_where( + cws, lambda m: not m["positions"].get("p1", {}).get("attention")) await pws.close() await cws.close() asyncio.run(run()) - def test_caller_clears_problem(self): + def test_attention_works_while_locked(self): async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - await pws.send(json.dumps({"type": "raise_problem", "message": "NEED SM"})) - raised = await recv_type(pws, "problem_changed") - self.assertTrue(raised["problem"]) - await recv_type(cws, "full_state") - - await cws.send(json.dumps({"type": "clear_problem", "client_id": "p1"})) - echo = await recv_type(pws, "problem_changed") - self.assertFalse(echo["problem"]) - state = await recv_type(cws, "full_state") - self.assertFalse(state["positions"]["p1"]["problem"]) - self.assertEqual(state["positions"]["p1"]["problem_message"], "") + await cws.send(json.dumps({"type": "lock", "locked": True})) + await recv_full_state_where(cws, lambda m: m["locked"]) + await pws.send(json.dumps({"type": "raise_attention", "message": "help"})) + msg = await recv_full_state_where( + cws, lambda m: m["positions"].get("p1", {}).get("attention")) + self.assertEqual(msg["positions"]["p1"]["attention_message"], "help") await pws.close() await cws.close() asyncio.run(run()) - def test_operator_cancels_own_problem(self): + def test_caller_can_clear_attention_while_locked(self): + # Pins behavior the caller UI depends on: the banner sits above the + # lock overlay, so clearing must work server-side during a hold. async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - await pws.send(json.dumps({"type": "raise_problem", "message": ""})) - await recv_type(cws, "full_state") - await pws.send(json.dumps({"type": "clear_problem"})) - state = await recv_type(cws, "full_state") - self.assertFalse(state["positions"]["p1"]["problem"]) + await pws.send(json.dumps({"type": "raise_attention", "message": "help"})) + await recv_full_state_where( + cws, lambda m: m["positions"].get("p1", {}).get("attention")) + await cws.send(json.dumps({"type": "lock", "locked": True})) + await recv_full_state_where(cws, lambda m: m["locked"]) + + await cws.send(json.dumps({"type": "clear_attention", "client_id": "p1"})) + await recv_type(pws, "attention_cleared") + msg = await recv_full_state_where( + cws, lambda m: not m["positions"].get("p1", {}).get("attention")) + self.assertTrue(msg["locked"]) await pws.close() await cws.close() asyncio.run(run()) - def test_problem_survives_reconnect_and_snapshot(self): + def test_message_truncated(self): async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - await pws.send(json.dumps({"type": "raise_problem", "message": "NOT READY"})) - await recv_type(pws, "problem_changed") - await pws.close() - await asyncio.sleep(0.3) - - # Snapshot persisted the flag - data = json.loads(STATE_SNAPSHOT.read_text()) - self.assertTrue(data["positions"]["p1"]["problem"]) - self.assertEqual(data["positions"]["p1"]["problem_message"], "NOT READY") - - # Reconnecting with the same client_id keeps the flag - pws2, joined = await connect_position("p1", "LX") - self.assertTrue(joined["problem"]) - self.assertEqual(joined["problem_message"], "NOT READY") + await pws.send(json.dumps({"type": "raise_attention", "message": "x" * 500})) + msg = await recv_full_state_where( + cws, lambda m: m["positions"].get("p1", {}).get("attention")) + self.assertLessEqual(len(msg["positions"]["p1"]["attention_message"]), 120) - await pws2.close() + await pws.close() await cws.close() asyncio.run(run()) - def test_raise_and_clear_logged_with_message(self): + +class TestShowClock(CueLightTestCase): + def test_start_show_clock_notifies_positions(self): async def run(): - cws, _, _ = await connect_caller() + cws, _, state = await connect_caller() + self.assertEqual(state.get("show_started_at"), 0) pws, _ = await connect_position("p1", "LX") await drain(cws) - await pws.send(json.dumps({"type": "raise_problem", "message": "flat broken"})) - await recv_type(cws, "full_state") - await cws.send(json.dumps({"type": "clear_problem", "client_id": "p1"})) - await recv_type(cws, "full_state") - await asyncio.sleep(0.2) - - entries = self._get_log()["entries"] - raised = next(e for e in entries if e["event"] == "problem_raised") - self.assertEqual(raised["position"], "LX") - self.assertEqual(raised["detail"], "flat broken") - cleared = next(e for e in entries if e["event"] == "problem_cleared") - self.assertEqual(cleared["detail"], "by caller") + await cws.send(json.dumps({"type": "start_show_clock"})) + await recv_type(pws, "show_started") + msg = await recv_full_state_where(cws, lambda m: m.get("show_started_at", 0) > 0) + self.assertGreater(msg["show_started_at"], 0) await pws.close() await cws.close() asyncio.run(run()) - def test_observer_sees_problem(self): + def test_clear_show_clock(self): async def run(): cws, _, _ = await connect_caller() + await cws.send(json.dumps({"type": "start_show_clock"})) + await recv_full_state_where(cws, lambda m: m.get("show_started_at", 0) > 0) + await cws.send(json.dumps({"type": "clear_show_clock"})) + await recv_full_state_where(cws, lambda m: m.get("show_started_at", 1) == 0) + await cws.close() + asyncio.run(run()) + + def test_go_records_last_go_at(self): + async def run(): + cws, _, state = await connect_caller() + self.assertEqual(state.get("last_go_at"), 0) pws, _ = await connect_position("p1", "LX") await drain(cws) - ows = await connect(f"{WS_URL}/ws/observer") - await ows.send(json.dumps({"client_id": "obs1"})) - await recv_type(ows, "role_assigned") - - await pws.send(json.dumps({"type": "raise_problem", "message": "NEED SM"})) - deadline = time.time() + 2.0 - seen = None - while time.time() < deadline: - raw = await asyncio.wait_for(ows.recv(), timeout=deadline - time.time()) - msg = json.loads(raw) - if msg.get("type") == "full_state" and msg["positions"].get("p1", {}).get("problem"): - seen = msg - break - self.assertIsNotNone(seen, "observer never saw the problem flag") - self.assertEqual(seen["positions"]["p1"]["problem_message"], "NEED SM") + await cws.send(json.dumps({"type": "go", "client_id": "p1"})) + msg = await recv_full_state_where(cws, lambda m: m.get("last_go_at", 0) > 0) + self.assertGreater(msg["last_go_at"], 0) - await ows.close() await pws.close() await cws.close() asyncio.run(run()) class TestShowReport(CueLightTestCase): - """/api/showreport computes a report from the show log on demand.""" - - def _get(self, path: str) -> tuple[str, str]: - resp = urllib_request.urlopen(f"{HTTP_URL}{path}", timeout=2) - return resp.read().decode(), resp.headers.get("Content-Type", "") + def _get(self, path: str): + try: + resp = urllib_request.urlopen(f"{HTTP_URL}{path}", timeout=2) + return resp.status, resp.read(), resp.headers + except HTTPError as e: + return e.code, e.read(), e.headers - def test_report_html_includes_latency_and_problems(self): + def test_report_counts_and_latency(self): async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") @@ -2619,151 +2711,154 @@ async def run(): await recv_type(pws, "standby_called") await asyncio.sleep(0.15) await pws.send(json.dumps({"type": "ack_standby"})) - await recv_type(cws, "full_state") - await pws.send(json.dumps({"type": "raise_problem", "message": "PROP ISSUE"})) - await recv_type(cws, "full_state") - await asyncio.sleep(0.2) - - body, ctype = self._get("/api/showreport") - self.assertIn("text/html", ctype) - self.assertIn("CueLight Show Report", body) - self.assertIn("LX", body) - self.assertIn("PROP ISSUE", body) + await recv_full_state_where( + cws, lambda m: m["positions"]["p1"]["standby"] == "acked") + await cws.send(json.dumps({"type": "go", "client_id": "p1"})) + await recv_type(pws, "go_called") + await pws.send(json.dumps({"type": "ack_go"})) + await recv_full_state_where( + cws, lambda m: m["positions"]["p1"]["go"] == "idle") await pws.close() await cws.close() asyncio.run(run()) - def test_report_counts_unacked_and_auto_standbys(self): + status, body, _ = self._get("/api/showreport") + self.assertEqual(status, 200) + report = json.loads(body) + lx = report["positions"]["LX"] + self.assertEqual(lx["standbys"], 1) + self.assertEqual(lx["standby_acks"], 1) + self.assertGreaterEqual(lx["standby_ack_avg_s"], 0.05) + self.assertLess(lx["standby_ack_avg_s"], 3) + self.assertEqual(lx["gos"], 1) + self.assertEqual(lx["go_acks"], 1) + self.assertEqual(report["show"]["standbys_called"], 1) + self.assertEqual(report["show"]["gos_called"], 1) + + def test_report_txt_format(self): async def run(): cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - - # Standby → GO without an ack = never acknowledged await cws.send(json.dumps({"type": "standby", "client_id": "p1"})) await recv_type(pws, "standby_called") - await cws.send(json.dumps({"type": "go", "client_id": "p1"})) - await recv_type(pws, "go_called") - await asyncio.sleep(0.2) - - body, _ = self._get("/api/showreport?format=csv") - lines = body.strip().splitlines() - self.assertEqual(lines[0].strip(), "section,position,cue,metric,value") - self.assertTrue( - any(ln.startswith("unacked_standby,LX") for ln in lines), - f"no unacked_standby row for LX:\n{body}", - ) - self.assertTrue( - any(ln.startswith("standby_counts,,,manual,1") for ln in lines), - f"manual standby not counted:\n{body}", - ) - await pws.close() await cws.close() asyncio.run(run()) - def test_report_go_intervals(self): + status, body, headers = self._get("/api/showreport?format=txt") + self.assertEqual(status, 200) + self.assertIn("text/plain", headers.get("Content-Type", "")) + text = body.decode() + self.assertIn("LX", text) + + def test_report_password_gated(self): async def run(): cws, _, _ = await connect_caller() - pws, _ = await connect_position("p1", "LX") - await drain(cws) - await pws.send(json.dumps({"type": "rename", "label": "LX"})) - - await cws.send(json.dumps({"type": "toggle_arm", "client_id": "p1"})) + await cws.send(json.dumps( + {"type": "set_password", "enabled": True, "password": "secret"})) await recv_type(cws, "full_state") - await cws.send(json.dumps({"type": "go_armed"})) - await recv_type(pws, "go_called") - await asyncio.sleep(0.3) - await cws.send(json.dumps({"type": "toggle_arm", "client_id": "p1"})) - await recv_type(cws, "full_state") - await cws.send(json.dumps({"type": "go_armed"})) - await recv_type(pws, "go_called") - await asyncio.sleep(0.2) + await cws.close() + asyncio.run(run()) - body, _ = self._get("/api/showreport?format=csv") - go_rows = [ln for ln in body.splitlines() if ln.startswith("go_interval,")] - self.assertEqual(len(go_rows), 1, f"expected one GO interval:\n{body}") - seconds = float(go_rows[0].split(",")[4]) - self.assertGreater(seconds, 0.1) + status, _, _ = self._get("/api/showreport") + self.assertEqual(status, 401) + status, _, _ = self._get("/api/showreport?password=secret") + self.assertEqual(status, 200) + def test_report_csv_format(self): + async def run(): + cws, _, _ = await connect_caller() + pws, _ = await connect_position("p1", "LX") + await drain(cws) + await cws.send(json.dumps({"type": "standby", "client_id": "p1"})) + await recv_type(pws, "standby_called") await pws.close() await cws.close() asyncio.run(run()) + status, body, headers = self._get("/api/showreport?format=csv") + self.assertEqual(status, 200) + self.assertIn("text/csv", headers.get("Content-Type", "")) + self.assertIn("attachment", headers.get("Content-Disposition", "")) + text = body.decode() + # Must have a header row and at least one data row + lines = [ln for ln in text.splitlines() if ln.strip()] + self.assertGreater(len(lines), 1) + self.assertIn("section", lines[0]) + self.assertIn("LX", text) -class TestShowTimer(CueLightTestCase): - """START SHOW records the wall-clock start, notifies positions, persists.""" - - def test_start_show_notifies_positions_and_caller(self): + def test_report_html_format(self): async def run(): - cws, _, state = await connect_caller() - self.assertEqual(state["show_start_time"], "") + cws, _, _ = await connect_caller() pws, _ = await connect_position("p1", "LX") await drain(cws) - - await cws.send(json.dumps({"type": "start_show"})) - notice = await recv_type(pws, "show_started") - self.assertTrue(notice["start_time"]) - - state = await recv_type(cws, "full_state") - self.assertEqual(state["show_start_time"], notice["start_time"]) - + await cws.send(json.dumps({"type": "standby", "client_id": "p1"})) + await recv_type(pws, "standby_called") await pws.close() await cws.close() asyncio.run(run()) - def test_start_show_persisted_and_restart_rerecords(self): + status, body, headers = self._get("/api/showreport?format=html") + self.assertEqual(status, 200) + self.assertIn("text/html", headers.get("Content-Type", "")) + text = body.decode() + self.assertIn("", text) + self.assertIn("CueLight Show Report", text) + self.assertIn("LX", text) + + def test_report_csv_injection_sanitized(self): + """A position label starting with '=' must be prefixed with ' in CSV output.""" async def run(): cws, _, _ = await connect_caller() - await cws.send(json.dumps({"type": "start_show"})) - first = (await recv_type(cws, "full_state"))["show_start_time"] - await asyncio.sleep(0.3) - - data = json.loads(STATE_SNAPSHOT.read_text()) - self.assertEqual(data["show_start_time"], first) - + # Label starts with '=' — the classic spreadsheet formula trigger + pws, _ = await connect_position("p1", "=SUM(A1)") + await drain(cws) + await cws.send(json.dumps({"type": "standby", "client_id": "p1"})) + await recv_type(pws, "standby_called") await asyncio.sleep(0.05) - await cws.send(json.dumps({"type": "start_show"})) - second = (await recv_type(cws, "full_state"))["show_start_time"] - self.assertNotEqual(first, second) - - await asyncio.sleep(0.2) - log = json.loads(urllib_request.urlopen(f"{HTTP_URL}/api/showlog", timeout=2).read()) - starts = [e for e in log["entries"] if e["event"] == "show_started"] - self.assertEqual(len(starts), 2) - self.assertEqual(starts[1]["detail"], "restart") - + await pws.send(json.dumps({"type": "ack_standby"})) + await recv_full_state_where( + cws, lambda m: m["positions"]["p1"]["standby"] == "acked") + await pws.close() await cws.close() asyncio.run(run()) - def test_exit_clears_show_start_time(self): - async def run(): - cws, _, _ = await connect_caller() - await cws.send(json.dumps({"type": "start_show"})) - await recv_type(cws, "full_state") - await cws.send(json.dumps({"type": "exit"})) - await asyncio.sleep(0.4) + status, body, _ = self._get("/api/showreport?format=csv") + self.assertEqual(status, 200) + text = body.decode() + # The raw label must not appear unescaped; the safe form must be present + self.assertNotIn(",=SUM(A1),", text) + self.assertIn("'=SUM(A1)", text) - cws2, _, state = await connect_caller("c2") - self.assertEqual(state["show_start_time"], "") - await cws2.close() - asyncio.run(run()) - def test_go_records_last_go_time(self): +class TestReconnectLabelUniqueness(CueLightTestCase): + def test_reconnect_with_conflicting_label_rejected(self): async def run(): - cws, _, state = await connect_caller() - self.assertEqual(state["last_go_time"], "") - pws, _ = await connect_position("p1", "LX") - await drain(cws) - - await cws.send(json.dumps({"type": "go", "client_id": "p1"})) - await recv_type(pws, "go_called") - state = await recv_type(cws, "full_state") - self.assertTrue(state["last_go_time"]) + pws1, msg1 = await connect_position("p1", "LX") + self.assertEqual(msg1["type"], "joined") + pws2, msg2 = await connect_position("p2", "SND") + self.assertEqual(msg2["type"], "joined") + await pws1.close() + await asyncio.sleep(0.2) + # p1 comes back claiming p2's label (differing only in case) + ws = await connect(f"{WS_URL}/ws/position") + await ws.send(json.dumps({"client_id": "p1", "label": "snd"})) + msg = json.loads(await ws.recv()) + self.assertEqual(msg["type"], "join_rejected") + await ws.close() + await pws2.close() + asyncio.run(run()) - await pws.close() - await cws.close() + def test_reconnect_with_own_label_still_accepted(self): + async def run(): + pws1, _ = await connect_position("p1", "LX") + await pws1.close() + await asyncio.sleep(0.2) + pws1b, msg = await connect_position("p1", "lx") + self.assertEqual(msg["type"], "joined") + await pws1b.close() asyncio.run(run())