Sitelet https://github.com/rec/showco/commit/c6fe097
Skip to content

Commit c6fe097

Browse files
committed
Add showCo lighting cues with Go, Back, and persistent state
1 parent 5a52e6c commit c6fe097

15 files changed

Lines changed: 691 additions & 8 deletions

‎doc/README.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Open showCo on the show network at the configured address and port. The Health p
1212

1313
| Page | Use it for |
1414
| --- | --- |
15+
| Lighting cues | Named lighting looks, Go/Back, current/next cue position, and live lyte state. |
1516
| Performance | Manual song cues, large marker and pause/resume controls, recording destination and capacity, stream state, pinned inputs, dimming, and optional screen-awake mode. |
1617
| Set list | Prepare and reorder songs, notes, and expected durations. |
1718
| Soundcheck | Save measured checks and explicit playback, lighting, and stream confirmations. |
@@ -35,7 +36,7 @@ Recording-input checks cover channels currently recording and flag silence or cl
3536

3637
Open **Performance** for large controls that advance the set list, mark a moment, or pause/resume recording. Moment markers use the label `performance moment`. The page shows recording state, destination, remaining capacity, stream state, and connection age. Pin important inputs and enable dimming for this browser. Screen-awake mode is opt-in and reports whether the browser and connection support it. Controls are disabled when status is unavailable; an action with an unknown outcome is never automatically retried.
3738

38-
Enable **Performance lock** from any page before a show. It blocks web requests for cable and lighting tests, calibration, recorder shutdown, new sessions, track names, stereo grouping, mutable attributes, noise-floor changes, key labels, profile reloads, set-list replacement, soundcheck, and recovery restarts. Manual cues, ordinary markers, pause/resume, and recovery verification remain available. An older tab receives the same server-side rejection. To unlock, select **Confirm unlock** and press **Unlock protected actions**; submit any previously rejected action again yourself.
39+
Enable **Performance lock** from any page before a show. It blocks web requests for cable and lighting tests, calibration, recorder shutdown, new sessions, track names, stereo grouping, mutable attributes, noise-floor changes, key labels, profile reloads, set-list and lighting-cue replacement, soundcheck, and recovery restarts. Manual cues, ordinary markers, pause/resume, and recovery verification remain available. An older tab receives the same server-side rejection. To unlock, select **Confirm unlock** and press **Unlock protected actions**; submit any previously rejected action again yourself.
3940

4041
Lock state persists in `~/.local/state/showco/performance.json`. If that file cannot be read, protected actions remain blocked until an explicit unlock can be saved successfully. The lock does not affect separate CLI or deployment commands, and it never starts or stops services itself. Rehearsal uses temporary in-memory lock and incident state and observes services when status is requested.
4142

@@ -47,6 +48,43 @@ On **Performance**, **Start next song** sends `song start: TITLE` to recs. Skip
4748

4849
Repeated requests carrying the same list revision are rejected. If recs may have accepted a marker but its reply was lost, the cue remains pending through restart. Resolve it explicitly: keep the cue without resending, with delivery marked unverified, or retry knowing that a duplicate marker is possible. The recs API cannot guarantee exactly-once delivery across a lost reply.
4950

51+
### Lighting cues
52+
53+
showCo owns the ordered lighting cue list, Go/Back, and cue position. lyte owns
54+
lighting execution through its existing `select_animation` RPC. Open **Lighting
55+
cues**, expand **Edit lighting cues**, give each cue a name and an installation
56+
look, then save. Look suggestions come from lyte. Cues can repeat the same look.
57+
Saving resets the cursor without changing the lights; it requires performance
58+
protection to be unlocked. Unsaved edits survive polling; stale saves are rejected.
59+
60+
**Go** selects the first or next cue. **Back** selects the previous cue; at the
61+
first cue it is unavailable. Neither end wraps. Both controls cut immediately
62+
and remain available under performance lock. Song markers and lighting cues are
63+
independent manual actions. There are no timed cues, automatic resends, or changes
64+
to recording or streaming. This Go button is unrelated to the `showco go`
65+
deployment command.
66+
67+
Current cue means the last selection acknowledged by lyte, or a pending cue
68+
explicitly kept by the operator. It does not prove physical output. The page
69+
also displays live active/queued looks, blackout, and test overrides. MIDI or
70+
another client can change lighting without changing showCo's cue position.
71+
Reconnecting only reads status. Production state persists in
72+
`~/.local/state/showco/lighting.json`, including unresolved selections. A restart
73+
restores the cursor without selecting a look.
74+
75+
A lost or unsuccessful reply leaves the selection pending and blocks further
76+
Go/Back. Inspect the live look, then explicitly keep the pending cue, keep the
77+
previous position, or retry. Keeping either position sends nothing; retrying may
78+
restart an already-running look. Saving pending state must succeed before sending
79+
any selection, so a disk failure prevents that new cue while existing lighting
80+
continues.
81+
82+
The existing `showco run --rehearsal` mode supplies in-memory `idle`, `circle`,
83+
and `square` looks without contacting lyte or output hardware; its cue list lasts
84+
for that process. These names also match lyte's `examples/installation-laser.toml`.
85+
Try Opening/circle, Finale/square, and End/idle, then Go, Go, Back and refresh the
86+
page. This tests cue operation only, not laser output or physical readiness.
87+
5088
### Guided soundcheck
5189

5290
Open **Soundcheck**, select the expected recording inputs, and begin. This clears earlier results without starting output. Confirm the intended disk, then make sound on all selected inputs and check their measured signal/clipping state. Disk confirmation requires fresh recs status, available space without an active disk alert, and a readable Linux mount identity. On systems without that identity, disk verification remains unsuccessful.
@@ -170,4 +208,4 @@ The web UI is for a trusted show network and has no login. Every client that can
170208

171209
The mixer state combines recs-reported audio and MIDI input names with an optional TCP or UDP probe. For X18, the UDP probe sends `/xremote` and waits for a reply. It is a reachability hint only; confirm mixer control with the tablet application or real OSC feedback. X18 `/xremote` subscriptions are renewed for feedback, but successful renewals are not recording events.
172210

173-
The web service limits ordinary requests to eight concurrent connections and waveform event streams to four. Its browser routes are `/` and `/channels`, `/performance`, `/setlist`, `/soundcheck`, `/recovery`, `/health`, `/playback`, `/attributes`, `/actions`, `/errors`, `/status` and `/workflow-status` (JSON), `/diagnostics` (download), and `/waveforms` (server-sent events). Form posts go to `/actions`; requests that accept JSON receive the action result directly.
211+
The web service limits ordinary requests to eight concurrent connections and waveform event streams to four. Its browser routes are `/` and `/channels`, `/performance`, `/setlist`, `/lighting`, `/soundcheck`, `/recovery`, `/health`, `/playback`, `/attributes`, `/actions`, `/errors`, `/status` and `/workflow-status` (JSON), `/diagnostics` (download), and `/waveforms` (server-sent events). Form posts go to `/actions`; requests that accept JSON receive the action result directly.

‎showco/cli.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def run_web_ui(options: WebUiOptions) -> int:
4545
system=rehearsal.RehearsalSystemMonitor(),
4646
mixers=rehearsal.RehearsalMixersMonitor(),
4747
streamo_restart=rehearsal.restart_streamo,
48+
lyte=rehearsal.RehearsalLyteClient(),
4849
streamo_enabled=True,
4950
)
5051
print(f'showco rehearsal listening on http://{options.host}:{options.port}')

‎showco/runtime/lighting.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Manual lighting cues owned by showCo; lyte owns look execution."""
2+
3+
from collections.abc import Callable
4+
from pathlib import Path
5+
6+
from pydantic import BaseModel, Field
7+
8+
from .models import ActionResult
9+
10+
11+
class LightingCue(BaseModel, frozen=True):
12+
name: str = Field(min_length=1, max_length=200)
13+
animation: str = Field(min_length=1, max_length=200)
14+
15+
16+
class LightingState(BaseModel, frozen=True):
17+
revision: int = 0
18+
cues: list[LightingCue] = Field(default_factory=list, max_length=200)
19+
current: int = -1
20+
pending: int | None = None
21+
message: str = ''
22+
23+
24+
class LightingController:
25+
def __init__(self, path: Path | None = None) -> None:
26+
self.path = path
27+
self.state = LightingState()
28+
if path is not None:
29+
try:
30+
self.state = LightingState.model_validate_json(path.read_text())
31+
except FileNotFoundError:
32+
pass
33+
34+
def save(self, state: LightingState) -> None:
35+
if self.path is not None:
36+
self.path.parent.mkdir(parents=True, exist_ok=True)
37+
temporary = self.path.with_suffix('.tmp')
38+
temporary.write_text(state.model_dump_json())
39+
temporary.replace(self.path)
40+
self.state = state
41+
42+
def act(
43+
self,
44+
action: str,
45+
revision: int,
46+
select: Callable[[str], ActionResult],
47+
cues: list[LightingCue] | None = None,
48+
) -> ActionResult:
49+
state = self.state
50+
if revision != state.revision:
51+
raise ValueError('Lighting cues changed; refresh before submitting again')
52+
if state.pending is not None:
53+
if action == 'lighting-accept':
54+
self.save(
55+
state.model_copy(
56+
update={
57+
'revision': revision + 1,
58+
'current': state.pending,
59+
'pending': None,
60+
'message': (
61+
'Cue kept by operator; lighting delivery unverified'
62+
),
63+
}
64+
)
65+
)
66+
return ActionResult(ok=True, message=self.state.message)
67+
if action == 'lighting-cancel':
68+
self.save(
69+
state.model_copy(
70+
update={
71+
'revision': revision + 1,
72+
'pending': None,
73+
'message': 'Cue position kept; no lighting command sent',
74+
}
75+
)
76+
)
77+
return ActionResult(ok=True, message=self.state.message)
78+
if action != 'lighting-retry':
79+
raise ValueError(
80+
'Lighting outcome uncertain; resolve the pending cue first'
81+
)
82+
index = state.pending
83+
elif action == 'lighting-save':
84+
self.save(
85+
LightingState(
86+
revision=revision + 1,
87+
cues=cues or [],
88+
message=(
89+
'Lighting cues saved; position reset without changing lights'
90+
),
91+
)
92+
)
93+
return ActionResult(ok=True, message=self.state.message)
94+
elif action in {'lighting-go', 'lighting-back'}:
95+
index = state.current + (1 if action == 'lighting-go' else -1)
96+
if not 0 <= index < len(state.cues):
97+
raise ValueError(
98+
'No next cue' if action == 'lighting-go' else 'No previous cue'
99+
)
100+
else:
101+
raise ValueError('Unknown lighting cue action')
102+
self.save(
103+
state.model_copy(
104+
update={
105+
'revision': revision + 1,
106+
'pending': index,
107+
'message': (
108+
'Lighting selection pending; '
109+
'inspect live state before resolving'
110+
),
111+
}
112+
)
113+
)
114+
result = select(state.cues[index].animation)
115+
# A failed or lost acknowledgement cannot prove that lyte did not select it.
116+
if not result.ok:
117+
self.save(
118+
self.state.model_copy(
119+
update={'message': f'Lighting unconfirmed: {result.message}'}
120+
)
121+
)
122+
return ActionResult(ok=False, message=self.state.message)
123+
self.save(
124+
self.state.model_copy(
125+
update={
126+
'revision': self.state.revision + 1,
127+
'current': index,
128+
'pending': None,
129+
'message': (
130+
f'Cue queued: {state.cues[index].name}. Check live lyte state.'
131+
),
132+
}
133+
)
134+
)
135+
return ActionResult(ok=True, message=self.state.message)

‎showco/runtime/lyte.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def status(self) -> models.LyteStatus:
3939
last_error='lyte status reply is not an object',
4040
)
4141
)
42+
animations = result.get('animations')
4243
strings = _string_statuses(result.get('strings'))
4344
error = _status_error(result, strings)
4445
return models.LyteStatus(
@@ -48,6 +49,10 @@ def status(self) -> models.LyteStatus:
4849
last_error=error,
4950
),
5051
running=result.get('running') is True,
52+
animations=[a for a in animations if isinstance(a, str)]
53+
if isinstance(animations, list)
54+
else [],
55+
blackout=result.get('blackout') is True,
5156
active_animation=_string(result.get('active_animation')),
5257
queued_animation=_string(result.get('queued_animation')),
5358
strings=strings,
@@ -74,6 +79,20 @@ def test(self) -> models.ActionResult:
7479
return models.ActionResult(ok=True, message='lyte light test queued')
7580
return models.ActionResult(ok=False, message='lyte did not queue light test')
7681

82+
def select_animation(self, name: str) -> models.ActionResult:
83+
if not self.enabled:
84+
return models.ActionResult(ok=False, message='lyte is disabled')
85+
result = self._call('select_animation', name=name)
86+
if (
87+
isinstance(result, dict)
88+
and result.get('state') == 'queued'
89+
and result.get('name') == name
90+
):
91+
return models.ActionResult(ok=True, message=f'lyte queued {name}')
92+
return models.ActionResult(
93+
ok=False, message='lyte selection was not acknowledged'
94+
)
95+
7796
def _call(self, command: str, **params: object) -> str | dict[str, object]:
7897
return rpc.Client(self.control_endpoint, role='showco').call(command, **params)
7998

‎showco/runtime/models.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ class LyteStringStatus(BaseModel, frozen=True):
127127
class LyteStatus(BaseModel, frozen=True):
128128
service: ServiceStatus
129129
running: bool = False
130+
animations: list[str] = Field(default_factory=list)
131+
blackout: bool = False
130132
active_animation: str | None = None
131133
queued_animation: str | None = None
132134
strings: dict[str, LyteStringStatus] = Field(default_factory=dict)

‎showco/runtime/performance.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def set(self, locked: bool) -> None:
3737

3838
PROTECTED_ACTIONS = {
3939
'setlist-save',
40+
'lighting-save',
4041
'soundcheck-begin',
4142
'soundcheck-check',
4243
'soundcheck-start-recording',

‎showco/runtime/rehearsal.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ..streamo.client import StreamoClient
77
from . import models
8+
from .lyte import LyteClient
89
from .mixer import MixersMonitor
910
from .recs import RecsClient, stereo_tracks
1011
from .system import SystemMonitor
@@ -109,6 +110,33 @@ def shutdown(self) -> models.ActionResult:
109110
return models.ActionResult(ok=True, message='rehearsal recs shutdown requested')
110111

111112

113+
class RehearsalLyteClient(LyteClient):
114+
def __init__(self) -> None:
115+
super().__init__(enabled=True)
116+
self.animation = 'idle'
117+
118+
def status(self) -> models.LyteStatus:
119+
return models.LyteStatus(
120+
service=models.ServiceStatus(name='lyte', state='connected'),
121+
running=True,
122+
animations=['idle', 'circle', 'square'],
123+
active_animation=self.animation,
124+
)
125+
126+
def select_animation(self, name: str) -> models.ActionResult:
127+
if name not in self.status().animations:
128+
return models.ActionResult(
129+
ok=False, message=f'Unknown rehearsal look: {name}'
130+
)
131+
self.animation = name
132+
return models.ActionResult(ok=True, message=f'Rehearsal look selected: {name}')
133+
134+
def test(self) -> models.ActionResult:
135+
return models.ActionResult(
136+
ok=True, message='Rehearsal light test; no physical output'
137+
)
138+
139+
112140
class RehearsalStreamoClient(StreamoClient):
113141
def __init__(self) -> None:
114142
self.started_at = time.time()

‎showco/runtime/server.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from . import (
2121
incidents,
2222
input_check,
23+
lighting,
2324
models,
2425
performance,
2526
readiness,
@@ -89,6 +90,9 @@ def __init__(
8990
self.setlist = setlist.SetListController(
9091
state_directory / 'setlist.json' if state_directory else None
9192
)
93+
self.lighting = lighting.LightingController(
94+
state_directory / 'lighting.json' if state_directory else None
95+
)
9296
self.soundcheck = soundcheck.Soundcheck(
9397
state_directory / 'soundcheck.json' if state_directory else None
9498
)
@@ -206,7 +210,7 @@ def _dispatch_action(
206210
return models.ActionResult(
207211
ok=True, message='Fault acknowledged; it remains active until recovery'
208212
)
209-
if action.startswith(('setlist-', 'soundcheck-', 'recovery-')):
213+
if action.startswith(('setlist-', 'soundcheck-', 'recovery-', 'lighting-')):
210214
return workflows.run(self, form)
211215
if action in performance.PROTECTED_ACTIONS:
212216
with self.soundcheck_lock:
@@ -312,7 +316,7 @@ def _do_get(self) -> None:
312316
if self.path == '/performance':
313317
self._html(views.performance_page())
314318
return
315-
if self.path in {'/setlist', '/soundcheck', '/recovery'}:
319+
if self.path in {'/setlist', '/soundcheck', '/recovery', '/lighting'}:
316320
self._html(views.workflow_page(self.path[1:]))
317321
return
318322
if self.path == '/workflow-status':
@@ -577,6 +581,7 @@ def make_server(
577581
streamo_restart: Callable[[], models.ActionResult] | None = None,
578582
streamo_enabled: bool = False,
579583
lyte_enabled: bool = False,
584+
lyte: LyteClient | None = None,
580585
performance_enabled: bool = False,
581586
mixer_specs: list[MixerSpec] | None = None,
582587
) -> ThreadingHTTPServer:
@@ -602,7 +607,7 @@ def make_server(
602607
mixers or MixersMonitor([]),
603608
streamo_restart if streamo_enabled else None,
604609
waveforms,
605-
LyteClient(enabled=lyte_enabled),
610+
lyte if lyte is not None else LyteClient(enabled=lyte_enabled),
606611
(
607612
cable_tester_from_specs(recs_client, mixer_specs)
608613
if mixer_specs and any(m.name == 'X18' for m in mixer_specs)

‎showco/runtime/views.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ def performance_page() -> str:
358358

359359
def workflow_page(name: str) -> str:
360360
return page(
361-
name.title(), site_file(f'{name}.html'), script=site_file('workflow.js')
361+
name.title(),
362+
site_file(f'{name}.html'),
363+
script=site_file('lighting.js' if name == 'lighting' else 'workflow.js'),
362364
)
363365

364366

@@ -383,6 +385,7 @@ def page(title: str, body: str, *, script: str = '') -> str:
383385
<nav>
384386
<a href="/performance">Performance</a>
385387
<a href="/setlist">Set list</a>
388+
<a href="/lighting">Lighting cues</a>
386389
<a href="/soundcheck">Soundcheck</a>
387390
<a href="/recovery">Recovery</a>
388391
<a href="/channels">Channels</a>

0 commit comments

Comments
 (0)