You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Put the sensor on standby, wake it on demand, and drive it from a command line client #122
Most people run the Kinect on the laptop they also work on, with OBS in front of it, and one of them
asked in Discord for a way to drive it from cron: they automate braindance and OBS around MS Teams
calls, their previous Kinect died after running unattended for hours, and today the only control
they have is starting and killing the whole npm start process. They asked for a way to start and
stop the camera itself, to change the OBS output, and to change the look, from a command line.
Another user asked for a standby control on the record surface. This issue maps what the server
has, designs the feature, and lists what changes, file by file, so it can be built in two pull
requests. Every claim below was read against the code at 627fb3a and checked by a second pass;
the corrections from that pass are folded in.
1. What exists today
The route table is already a contract a script can use.ROUTES in server/index.js is the
dispatcher and GET /library/routes serves it. A caller sending no Origin skips the origin check,
so curl, Node's fetch and any cron job may hit POST /record/start, /record/stop, /record/mark, read /sensor/health, /record/state, /presets, /library/takes and /jobs
with nothing added. The one rule they must obey: a mutating route needs Content-Type: application/json even with an empty body, or requireMutation answers 415 (readBody turns an
empty body into {}). library-check walks the table, so a new row gets its guard coverage by
existing; only its behaviour needs new rows.
The sensor has four states and no standby.sensorState takes starting, live, lost and absent, all through setSensorState, which broadcasts {status} and marks the webcam and the
key stream unavailable for anything but live. The grabber's stdin knows low-light, hd-color
and key; colour on or off is a full grabber restart. There is no way to make the Kinect go dark
while the server stays up.
Camera control is WebSocket-only.{camera: {color, lowLight}} on a monitor socket reaches applyCamera, which merges the booleans, broadcasts {camera}, restarts the grabber on a colour
change (or lets buildArgs carry the setting to the next spawn when no child exists) and writes low-light on|off to the child's stdin. No HTTP route reaches it, and in --replay the message is
dropped silently because applyCamera is null.
The OBS output's state lives in the operator's browser tab. The /program page (OBS's browser
source) sends {programOut: {hello: true}} when its socket opens; the server relays every programOut object to every other socket and remembers nothing; the /record page answers with sendProgramOutState(), which is {mode, size, params: params.values(), view}, and relays every
registry write through paramWritten and every free-camera move through streamMirrorPose. Only
the /record page writes to that wire: /edit never calls connect(). So a headless OBS with no /record tab draws defaults, and anything a script sent would be overwritten the next time a
source reconnected. applyProgramOut on the source applies params atomically and returns early
if one key is refused, dropping the same patch's mode and size; vcam-check's patch-params-applied-one-at-a-time mutation holds that contract.
Consumers are already counted.attachedMonitors() returns the open sockets still in the monitors map, webcam.count and keyStream.count are getters that reap dead peers, and recorder.armed and recorder.take are fields. OnDemand in server/on-demand.js already turns
the grabber's colour and key encoders on for the first subscriber and off six seconds after the
last one leaves, and re-asserts the request when a new grabber says hello. Both the /record
page and the /program page open a frame socket on loopback and are granted full rate, so each
counts as a monitor.
/camera.mjpg refuses rather than waits.Webcam.attach answers 503 {error} at once while unavailable is set and the request never enters subscribers; an OBS source retrying against a
starting sensor produces no demand signal. When the camera is available but no frame has arrived
yet, the same function writes the 200 multipart headers and waits for the next offer. The hold
exists; it is not reached from the unavailable side.
2. The feature
A fifth sensor state, standby: the grabber process is not running, the server is up, the
Kinect is dark. It is entered by a button on the record surface, by POST /sensor/standby, or by
itself after an idle window with no consumer. It is left by POST /sensor/wake or by the first
consumer to arrive: a monitor socket, a /camera.mjpg request, a /key client, or POST /record/start. So for the Discord use case, OBS opening the scene wakes the Kinect and
OBS closing it lets the Kinect sleep, with no Teams detection anywhere.
A command line client, bin/braindance.mjs, that is a thin HTTP client of the route table:
braindance [--url URL] [--json] <verb>
status sensor state, fps, take, output, consumers
sensor status | standby | wake [--wait]
record start | stop | mark
camera color on|off | low-light on|off
output the OBS output state
output mode camera|mirror | size WxH | preset NAME | set key=value ...
presets | takes | jobs
--url defaults to http://127.0.0.1:8080 and BRAINDANCE_URL overrides it. Output is one line
per fact, --json prints the response body. Exit 0 on success, 1 when the server refused and its
sentence was printed, 2 when no server answered or the arguments were wrong. Every write prints
the state read back after it. npm link puts braindance on PATH; node bin/braindance.mjs
works without it.
Four new routes, in the table like every other: POST /sensor/standby, POST /sensor/wake, GET/POST /sensor/camera, GET/POST /output. One new flag, --standby-after S, in seconds,
default 600, 0 never.
3. How it works
Standby
standby is a boolean inside startLive, beside restarting and shuttingDown, because only that
closure reaches child. POST /sensor/standby refuses with 409 while recorder.armed or recorder.take is set (a take that is armed or running is a consumer, and a standby under an armed
recorder would make the wake hello open a take nobody asked for), and with 409 in --replay
carrying the replay sentence. Otherwise it sets the flag, cancels any pending spawn timer, and if a
child exists calls stopGrabber with a longer grace than today's STOP_GRACE_MS. The exit handler
gets a third arm, after shuttingDown and before restarting: reset killedHard and attempt, setSensorState('standby'), return. setSensorState already broadcasts the status and writes the
unavailable reasons for the webcam and the key stream, and both pages render an unknown status word
as-is, so standby reaches the UI with no client change beyond the button. With no child (the
sensor was lost), the state is set at once. The route answers when the state has settled, so the
CLI's read-back says standby.
spawnGrabber refuses at its top while standby is set, before grabberSpawns++. This is the
load-bearing guard: today spawnGrabber has no "already running or scheduled" check, and neither scheduleRetry nor the restart arm keeps its timer handle, so without the gate a retry scheduled
before the standby would spawn after it, and a wake during a pending timer would double-spawn, the
loser flipping a live sensor to lost and scheduling a third. Both timers store their handles and
standby clears them.
POST /sensor/wake clears the flag, zeroes attempt, counts grabberWakes++ and calls spawnGrabber. It answers at once with starting; braindance sensor wake --wait polls /sensor/health until live or lost, bounded. A wake when not on standby answers 200 with the
current state. /sensor/health gains wakes and reports respawns = grabberSpawns - 1 - grabberRestarts - grabberWakes, so an asked-for wake is not sensor flapping, and its fps and bytesPerSec are zeroed on standby (today they hold the last live value through an empty window). cannotRecord keeps its two conditions, so /record/start on standby arms, wakes, and opens the
take at the hello, exactly as it does today during starting.
The grace matters. stopGrabber SIGTERMs and after 2 s SIGKILLs; the grabber's teardown is dev->stop(); dev->close() and the comment in pollCommands says closing the device on macOS
sleeps about 4 s inside libfreenect2. A SIGKILLed grabber never tells the Kinect to stop, so
standby uses STANDBY_GRACE_MS, set from the measurement in section 8, and the clean exit path is
what the cli-check row asserts.
Every term exists. A 5 s tick evaluates it; the first idle tick records idleSince; when the
state is live or lost and now - idleSince >= STANDBY_AFTER_MS, the standby path runs. Any
non-idle tick clears idleSince, and so does a wake, so the window counts from the last consumer
leaving and never from a spawn. absent is excluded: there is no sensor to protect and an
editing station should keep reading "no sensor on this machine". Replay is excluded.
Wake triggers hook where the consumer arrives: the top of wss.on('connection'), the /camera.mjpg route before webcam.attach, and serveRecordStart before recorder.start. The camera message does not wake: with no child the setting lands through buildArgs at the next
spawn, which is what it does today.
/camera.mjpg learns to wait. Webcam.unavailable becomes a reason plus whether it is transient.
Transient reasons (starting, lost, standby, the grabber is restarting) take the hold path
that already exists for "available, no frame yet": write the 200 multipart headers, add the
subscriber, settle(), and let the first offer push. A held request that sees no frame within HOLD_MS is ended with the reason. Permanent reasons (absent, colour off, replay) keep the 503.
This is also what makes an OBS retry storm a demand signal instead of noise, and it fits #68's
recommendation to attach the colour camera as a browser source, since Chromium's <img> waits
on an open connection and shows a broken image on a 503 until reload.
The camera route
GET /sensor/camera answers {camera: {color, lowLight}, available, unavailable}. POST takes {color?, lowLight?}, refuses a non-boolean with 400 and replay with 409, calls the same applyCamera the socket branch calls, and answers the merged state plus restarting: true when a
colour change had a child to restart. One function, two callers.
The output state moves to the server
The server holds output = {mode: 'camera', size: {w: 1920, h: 1080}, preset: null, params: {}}.
The output is a preset plus edits on top: POST /output {preset: NAME} reads the preset store,
refuses 404 for a missing name, 409 for body.version !== PROJECT_VERSION naming both versions,
and 409 for a requires[].id that EFFECTS.list() does not hold, naming the id; on success it
stores the name and clears the look keys in params, and only those. params also carries the
composition: the program camera, transform, and crop with its six faces that readFaces in web/key.js needs, none of which a preset names (presetValueNames() excludes framing). A clear
of the whole object would reset the program camera and wipe the keyed webcam's crop box on every
preset change, and a fresh source would get the preset and nothing else. The server has no
registry to tell a look key from a composition key, so PR 2 picks one of two ways to get the tag
server-side: the /record page relays each write with its registry tag, or the base parameter
table moves into a module under web/ that server/ imports (allowed; server/library.js
already imports from web/). mode must be camera or mirror, size two positive integers
no larger than the largest size web/export-sizes.js offers. params values are stored merged
and never validated.
On every connection, after sendMonitor(ws), and on every change, the server sends three separate
messages: {programOut: {mode, size}}, then {programOut: {preset: body}} when a preset is set,
then {programOut: {params}} when non-empty. Separate, because a params key the source cannot
name (an effect removed since) must not take mode and size down with it; applyProgramOut
keeps applying params atomically and vcam-check's mutation stays. applyProgramOut gains preset, which runs the refusePresetBody and applyStoredPreset path on the source's single
clip, so a whole-look preset resets the effects it does not name exactly as it does on the
operator. The server does not forward body.values as params: for a whole look that would leave
the previous look's effects on.
The /record page becomes a writer, not the memory. On boot it fetches GET /output, paints #progMode and #progSize, and adopts preset and params into its registry so the page shows
the output's look. Its #progMode and #progSize handlers POST /output. paramWritten keeps
relaying {programOut: {params: {name: value}}} over the socket, and the server merges that into output.params before relaying it on, so the store is the single writer's memory. view is
relayed and never stored: it moves at up to 30 Hz under the hand and means nothing to a fresh
source. sendProgramOutState, the operator's hello reply, the source's hello and web/key.js's
hello are deleted; readFaces in key.js reads the crop faces from the connect-time params
message unchanged.
Replay
startReplay never assigns applyCamera, requestHdColor or requestKey, and its tick loop
sets live and lost itself. /sensor/standby, /sensor/wake and POST /sensor/camera answer
409 with the replay sentence the webcam already carries; auto-standby does not run; /output
works, since the source draws a replayed cloud like a live one.
4. What changes, by file
server/index.js: --standby-after beside --record; standby, grabberWakes, idleSince
and the stored timer handles inside startLive; the gate at the top of spawnGrabber; the
third arm in child.on('exit'); stopGrabber takes a grace; the idle tick; wake calls in wss.on('connection'), the /camera.mjpg route and serveRecordStart; serveSensorHealth
gains wakes and zeroes the observed rates on standby; output state, serveOutput and serveOutputWrite, the three connect-time messages, and the merge in the programOut relay
branch; four ROUTES rows; a SIGTERM handler doing what SIGINT does, so a launchd or systemd stop
closes an open take's sidecar (the grabber itself already notices the closed pipe and tears down). server/webcam.js: transient versus permanent unavailable, the hold with HOLD_MS. web/main.js: the standby button in #sensorGroup painted from {status} the way showCamera
paints the checkboxes; /record boot fetches and adopts /output; #progMode and #progSize
post; applyProgramOut takes preset; the hello and sendProgramOutState go. web/key.js: the
hello goes. web/index.html: the button. bin/braindance.mjs: the entry, shebang, mode 100755. bin/verbs.js: the verb table (verb, route, method, body, formatter) as data, imported by the
entry and by test/cli-verbs.test.mjs, so it can be walked. package.json: bin. No new
dependency: global fetch is on the 18.15 floor the server already uses.
5. Proof
tools/cli-check.mjs, port 8401 (free; the tool probes it first and exits 2), a staged copy under .cli-check/ (added to .gitignore), the fake grabber with --hd for the MJPEG rows. Sections:
The verb table and /library/routes, both ways: every verb's route is a row with that method;
every mutates row has a verb or sits in a declared exemption list (the worker's /jobs/*
lease routes, the effect store's PUT/DELETE, the library's destructive routes). A route
added later without either goes red.
Standby: the child PID via pgrep -P before; POST /sensor/standby answers standby; the
PID is gone; still gone 2.5 s later (a standby that is a bare kill respawns at 1 s); the grabber
exited on SIGTERM, not SIGKILL; respawns unchanged; fps 0. Wake: starting then live; a
new PID; wakes 1; the spawn identity library-check holds, extended to respawns + restarts + wakes + 1.
Refusals: standby while armed is 409; standby, wake and camera on a --replay server are 409
with the replay sentence; wake when live is 200 and changes nothing.
Auto-standby and wake on demand, on a server started with --standby-after 2: no consumer, so standby within the window plus one tick; GET /camera.mjpg is held, the 200 headers arrive,
the first JPEG part arrives, the state is live; the subscriber leaves and the sensor is back
on standby after the window; a monitor socket wakes it; POST /record/start wakes it, arms,
and the take opens at the hello. An armed recorder with no monitor never goes to standby.
The camera route: POST {lowLight: false} answers the merged state and a socket receives {camera}; POST {color: false} answers restarting: true, a new PID, restarts 1; {color: "off"} is 400.
The output: POST mode and size, GET reflects them; a new socket receives the three messages
in order on connect; a preset naming an effect the store lacks is 409 naming it; a preset at
another version is 409 naming both; an unknown name is 404; a relayed operator params
message shows up in GET /output.
Mutations, each a literal-anchor entry in MUTATIONS as syntax-check requires: standby-is-a-bare-kill, standby-leaves-the-retry-timer, wake-reads-as-a-respawn, idle-ignores-the-recorder, standby-from-absent, mjpeg-refuses-while-waking, camera-route-bypasses-applyCamera, output-forgets-on-connect, preset-skips-requires, verb-without-a-route.
Other tools: library-check's spawn identity row takes wakes; every tool that spawns a server
passes --standby-after 0 in its one start() helper (14 tools; one line each) so a long
sensorless or consumer-free run does not change state under its rows, and cli-check is the one
tool that runs with it on. vcam-check gains rows for the source applying a preset message and
for the /record page adopting /output on boot, and keeps patch-params-applied-one-at-a-time. syntax-check's FLOORS gains bin. module-check covers bin/ by existing. test/cli-verbs.test.mjs covers the verb table's parsing and formatting without a server.
6. Documentation
README.md: a "Command line client" section after "Streaming to OBS": the install line, the
verbs, one launchd and one systemd --user example for keeping the server up from login, and in
the OBS section one line to turn on "Shutdown source when not visible", because OBS holds the
browser source's socket for an inactive scene otherwise and the sensor never idles. docs/reference.md: the --standby-after row; a "Command line client" verb table between
"Reaching it from another machine" and the viewer controls; standby in the record surface
table; wakes and standby in the readings; four rows in "HTTP routes". SECURITY.md: rows for
the four routes in the exposure table (standby and a colour toggle drop live OBS sources). docs/architecture.md: the five sensor states and where the output state lives. docs/proof-tools.md: the cli-check section and its mutations. CLAUDE.md: the table row and
port 8401.
7. Decisions taken
Standby is the process gone, not dev->stop() on a kept-open device. The first is the only
one where the emitter and the USB link are known to be quiet; the second is a later measurement.
The word is standby.parked already means two things here: an effect key carried and
never evaluated, and the playhead.
Auto-standby is on by default, 600 s. The failure being fixed is a sensor nobody was watching
for hours, and a default only readers of the docs get protects nobody. The cost is one line in
each tool's spawn helper.
The CLI is a client, HTTP only, one verb per route. No serve, stop or daemon verbs: npm start stays the one way to start the server, and with standby there is no reason to kill
it. Keeping it up from login is the operating system's job and gets two example lines.
The server is the memory for the output, the /record page is a writer, view is relayed
only.
Preset application stays on the source. The server checks version and requires, the
source runs the same functions the operator runs.
8. Measurements owed before the constants are set
Two numbers are claims until taken. Method for each, on a real sensor on an M-series Mac with the gl pipeline, ten samples, warm USB, page cache irrelevant:
Standby to dark.POST /sensor/standby to the grabber's exit, reading the exit code and
signal from the server log, and the emitter observed off by eye after the clean exit. Sets STANDBY_GRACE_MS. If the clean exit takes longer than the measurement suggests, the grabber
gets a quit stdin command that breaks the loop without waiting on waitForNewFrame.
Wake to first frame.POST /sensor/wake to the first type 2 frame on a loopback socket.
Sets HOLD_MS (about three times the median) and the README's sentence on what to expect.
The same two on the Pi, since the systemd example is for it.
9. Found while mapping
STOP_GRACE_MS (2000) is shorter than the grabber's clean teardown (about 4 s in close() on
macOS), so today's colour toggle and every asked restart end in SIGKILL and the USB reclaim
race the 1500 ms respawn delay exists for. Standby needs the longer grace; the colour toggle
probably wants it too.
The server handles SIGINT only. A SIGTERM from launchd or systemd skips recorder.close, so an
open take is left without its sidecar.
The holdProcessOpen comment in stopGrabber describes a wait that does not happen when no
take is open: recorder.close(...).finally(() => process.exit(0)) exits first.
A /record tab left open is a full-rate loopback monitor and keeps the sensor awake. Ship as
is and let the button and the CLI cover it, or have a hidden tab release its socket after the
idle window? Recommendation: ship as is, follow up.
output mode mirror from the CLI with no operator draws the last relayed view or the default
pose. Accept and document, or 409 while no /record socket is open? Recommendation: accept.
The size ceiling: the largest export size, or a flat 8192? Recommendation: the export sizes.
600 s as the default window.
11. Out of scope
Teams or call detection of any kind; serve, stop or daemon verbs; authentication; a standby
that keeps the device open; OBS's own websocket API.
12. Phasing
PR 1: standby, auto-standby, wake on demand, the MJPEG hold, the camera route, the SIGTERM
handler, the CLI with status, sensor, record, camera and the lists, cli-check sections
1 to 5, the docs for all of it. This is the whole Discord automation. PR 2: the output state on the server, the look-only clear on a preset and the tag mechanism
behind it, the CLI's output verbs, cli-check section 6, the vcam-check rows. It changes a
protocol in web/main.js and needs a GPU browser to prove.
Related: #67 (the respawn arithmetic this extends), #68 (the browser source over /camera.mjpg
that the hold serves), #107 (the OBS URLs braindance output could also print).
Most people run the Kinect on the laptop they also work on, with OBS in front of it, and one of them
asked in Discord for a way to drive it from cron: they automate braindance and OBS around MS Teams
calls, their previous Kinect died after running unattended for hours, and today the only control
they have is starting and killing the whole
npm startprocess. They asked for a way to start andstop the camera itself, to change the OBS output, and to change the look, from a command line.
Another user asked for a standby control on the record surface. This issue maps what the server
has, designs the feature, and lists what changes, file by file, so it can be built in two pull
requests. Every claim below was read against the code at
627fb3aand checked by a second pass;the corrections from that pass are folded in.
1. What exists today
The route table is already a contract a script can use.
ROUTESinserver/index.jsis thedispatcher and
GET /library/routesserves it. A caller sending noOriginskips the origin check,so
curl, Node'sfetchand any cron job may hitPOST /record/start,/record/stop,/record/mark, read/sensor/health,/record/state,/presets,/library/takesand/jobswith nothing added. The one rule they must obey: a mutating route needs
Content-Type: application/jsoneven with an empty body, orrequireMutationanswers 415 (readBodyturns anempty body into
{}).library-checkwalks the table, so a new row gets its guard coverage byexisting; only its behaviour needs new rows.
The sensor has four states and no standby.
sensorStatetakesstarting,live,lostandabsent, all throughsetSensorState, which broadcasts{status}and marks the webcam and thekey stream unavailable for anything but
live. The grabber's stdin knowslow-light,hd-colorand
key; colour on or off is a full grabber restart. There is no way to make the Kinect go darkwhile the server stays up.
Camera control is WebSocket-only.
{camera: {color, lowLight}}on a monitor socket reachesapplyCamera, which merges the booleans, broadcasts{camera}, restarts the grabber on a colourchange (or lets
buildArgscarry the setting to the next spawn when no child exists) and writeslow-light on|offto the child's stdin. No HTTP route reaches it, and in--replaythe message isdropped silently because
applyCamerais null.The OBS output's state lives in the operator's browser tab. The
/programpage (OBS's browsersource) sends
{programOut: {hello: true}}when its socket opens; the server relays everyprogramOutobject to every other socket and remembers nothing; the/recordpage answers withsendProgramOutState(), which is{mode, size, params: params.values(), view}, and relays everyregistry write through
paramWrittenand every free-camera move throughstreamMirrorPose. Onlythe
/recordpage writes to that wire:/editnever callsconnect(). So a headless OBS with no/recordtab draws defaults, and anything a script sent would be overwritten the next time asource reconnected.
applyProgramOuton the source appliesparamsatomically and returns earlyif one key is refused, dropping the same patch's
modeandsize;vcam-check'spatch-params-applied-one-at-a-timemutation holds that contract.Consumers are already counted.
attachedMonitors()returns the open sockets still in themonitorsmap,webcam.countandkeyStream.countare getters that reap dead peers, andrecorder.armedandrecorder.takeare fields.OnDemandinserver/on-demand.jsalready turnsthe grabber's colour and key encoders on for the first subscriber and off six seconds after the
last one leaves, and re-asserts the request when a new grabber says hello. Both the
/recordpage and the
/programpage open a frame socket on loopback and are granted full rate, so eachcounts as a monitor.
/camera.mjpgrefuses rather than waits.Webcam.attachanswers 503{error}at once whileunavailableis set and the request never enterssubscribers; an OBS source retrying against astarting sensor produces no demand signal. When the camera is available but no frame has arrived
yet, the same function writes the 200 multipart headers and waits for the next
offer. The holdexists; it is not reached from the unavailable side.
2. The feature
A fifth sensor state, standby: the grabber process is not running, the server is up, the
Kinect is dark. It is entered by a button on the record surface, by
POST /sensor/standby, or byitself after an idle window with no consumer. It is left by
POST /sensor/wakeor by the firstconsumer to arrive: a monitor socket, a
/camera.mjpgrequest, a/keyclient, orPOST /record/start. So for the Discord use case, OBS opening the scene wakes the Kinect andOBS closing it lets the Kinect sleep, with no Teams detection anywhere.
A command line client,
bin/braindance.mjs, that is a thin HTTP client of the route table:--urldefaults tohttp://127.0.0.1:8080andBRAINDANCE_URLoverrides it. Output is one lineper fact,
--jsonprints the response body. Exit 0 on success, 1 when the server refused and itssentence was printed, 2 when no server answered or the arguments were wrong. Every write prints
the state read back after it.
npm linkputsbraindanceonPATH;node bin/braindance.mjsworks without it.
Four new routes, in the table like every other:
POST /sensor/standby,POST /sensor/wake,GET/POST /sensor/camera,GET/POST /output. One new flag,--standby-after S, in seconds,default 600,
0never.3. How it works
Standby
standbyis a boolean insidestartLive, besiderestartingandshuttingDown, because only thatclosure reaches
child.POST /sensor/standbyrefuses with 409 whilerecorder.armedorrecorder.takeis set (a take that is armed or running is a consumer, and a standby under an armedrecorder would make the wake hello open a take nobody asked for), and with 409 in
--replaycarrying the replay sentence. Otherwise it sets the flag, cancels any pending spawn timer, and if a
child exists calls
stopGrabberwith a longer grace than today'sSTOP_GRACE_MS. The exit handlergets a third arm, after
shuttingDownand beforerestarting: resetkilledHardandattempt,setSensorState('standby'), return.setSensorStatealready broadcasts the status and writes theunavailable reasons for the webcam and the key stream, and both pages render an unknown status word
as-is, so
standbyreaches the UI with no client change beyond the button. With no child (thesensor was
lost), the state is set at once. The route answers when the state has settled, so theCLI's read-back says
standby.spawnGrabberrefuses at its top whilestandbyis set, beforegrabberSpawns++. This is theload-bearing guard: today
spawnGrabberhas no "already running or scheduled" check, and neitherscheduleRetrynor the restart arm keeps its timer handle, so without the gate a retry scheduledbefore the standby would spawn after it, and a wake during a pending timer would double-spawn, the
loser flipping a live sensor to
lostand scheduling a third. Both timers store their handles andstandby clears them.
POST /sensor/wakeclears the flag, zeroesattempt, countsgrabberWakes++and callsspawnGrabber. It answers at once withstarting;braindance sensor wake --waitpolls/sensor/healthuntilliveorlost, bounded. A wake when not on standby answers 200 with thecurrent state.
/sensor/healthgainswakesand reportsrespawns = grabberSpawns - 1 - grabberRestarts - grabberWakes, so an asked-for wake is not sensor flapping, and itsfpsandbytesPerSecare zeroed on standby (today they hold the last live value through an empty window).cannotRecordkeeps its two conditions, so/record/starton standby arms, wakes, and opens thetake at the hello, exactly as it does today during
starting.The grace matters.
stopGrabberSIGTERMs and after 2 s SIGKILLs; the grabber's teardown isdev->stop(); dev->close()and the comment inpollCommandssays closing the device on macOSsleeps about 4 s inside libfreenect2. A SIGKILLed grabber never tells the Kinect to stop, so
standby uses
STANDBY_GRACE_MS, set from the measurement in section 8, and the clean exit path iswhat the
cli-checkrow asserts.Auto-standby and wake on demand
Every term exists. A 5 s tick evaluates it; the first idle tick records
idleSince; when thestate is
liveorlostandnow - idleSince >= STANDBY_AFTER_MS, the standby path runs. Anynon-idle tick clears
idleSince, and so does a wake, so the window counts from the last consumerleaving and never from a spawn.
absentis excluded: there is no sensor to protect and anediting station should keep reading "no sensor on this machine". Replay is excluded.
Wake triggers hook where the consumer arrives: the top of
wss.on('connection'), the/camera.mjpgroute beforewebcam.attach, andserveRecordStartbeforerecorder.start. Thecameramessage does not wake: with no child the setting lands throughbuildArgsat the nextspawn, which is what it does today.
/camera.mjpglearns to wait.Webcam.unavailablebecomes a reason plus whether it is transient.Transient reasons (
starting,lost,standby,the grabber is restarting) take the hold paththat already exists for "available, no frame yet": write the 200 multipart headers, add the
subscriber,
settle(), and let the firstofferpush. A held request that sees no frame withinHOLD_MSis ended with the reason. Permanent reasons (absent, colour off, replay) keep the 503.This is also what makes an OBS retry storm a demand signal instead of noise, and it fits #68's
recommendation to attach the colour camera as a browser source, since Chromium's
<img>waitson an open connection and shows a broken image on a 503 until reload.
The camera route
GET /sensor/cameraanswers{camera: {color, lowLight}, available, unavailable}.POSTtakes{color?, lowLight?}, refuses a non-boolean with 400 and replay with 409, calls the sameapplyCamerathe socket branch calls, and answers the merged state plusrestarting: truewhen acolour change had a child to restart. One function, two callers.
The output state moves to the server
The server holds
output = {mode: 'camera', size: {w: 1920, h: 1080}, preset: null, params: {}}.The output is a preset plus edits on top:
POST /output {preset: NAME}reads the preset store,refuses 404 for a missing name, 409 for
body.version !== PROJECT_VERSIONnaming both versions,and 409 for a
requires[].idthatEFFECTS.list()does not hold, naming the id; on success itstores the name and clears the look keys in
params, and only those.paramsalso carries thecomposition: the program camera,
transform, andcropwith its six faces thatreadFacesinweb/key.jsneeds, none of which a preset names (presetValueNames()excludes framing). A clearof the whole object would reset the program camera and wipe the keyed webcam's crop box on every
preset change, and a fresh source would get the preset and nothing else. The server has no
registry to tell a look key from a composition key, so PR 2 picks one of two ways to get the tag
server-side: the
/recordpage relays each write with its registry tag, or the base parametertable moves into a module under
web/thatserver/imports (allowed;server/library.jsalready imports from
web/).modemust becameraormirror,sizetwo positive integersno larger than the largest size
web/export-sizes.jsoffers.paramsvalues are stored mergedand never validated.
On every connection, after
sendMonitor(ws), and on every change, the server sends three separatemessages:
{programOut: {mode, size}}, then{programOut: {preset: body}}when a preset is set,then
{programOut: {params}}when non-empty. Separate, because aparamskey the source cannotname (an effect removed since) must not take
modeandsizedown with it;applyProgramOutkeeps applying
paramsatomically andvcam-check's mutation stays.applyProgramOutgainspreset, which runs therefusePresetBodyandapplyStoredPresetpath on the source's singleclip, so a whole-look preset resets the effects it does not name exactly as it does on the
operator. The server does not forward
body.valuesasparams: for a whole look that would leavethe previous look's effects on.
The
/recordpage becomes a writer, not the memory. On boot it fetchesGET /output, paints#progModeand#progSize, and adoptspresetandparamsinto its registry so the page showsthe output's look. Its
#progModeand#progSizehandlersPOST /output.paramWrittenkeepsrelaying
{programOut: {params: {name: value}}}over the socket, and the server merges that intooutput.paramsbefore relaying it on, so the store is the single writer's memory.viewisrelayed and never stored: it moves at up to 30 Hz under the hand and means nothing to a fresh
source.
sendProgramOutState, the operator's hello reply, the source's hello andweb/key.js'shello are deleted;
readFacesinkey.jsreads the crop faces from the connect-timeparamsmessage unchanged.
Replay
startReplaynever assignsapplyCamera,requestHdColororrequestKey, and its tick loopsets
liveandlostitself./sensor/standby,/sensor/wakeandPOST /sensor/cameraanswer409 with the replay sentence the webcam already carries; auto-standby does not run;
/outputworks, since the source draws a replayed cloud like a live one.
4. What changes, by file
server/index.js:--standby-afterbeside--record;standby,grabberWakes,idleSinceand the stored timer handles inside
startLive; the gate at the top ofspawnGrabber; thethird arm in
child.on('exit');stopGrabbertakes a grace; the idle tick; wake calls inwss.on('connection'), the/camera.mjpgroute andserveRecordStart;serveSensorHealthgains
wakesand zeroes the observed rates on standby;outputstate,serveOutputandserveOutputWrite, the three connect-time messages, and the merge in theprogramOutrelaybranch; four
ROUTESrows; a SIGTERM handler doing what SIGINT does, so a launchd or systemd stopcloses an open take's sidecar (the grabber itself already notices the closed pipe and tears down).
server/webcam.js: transient versus permanentunavailable, the hold withHOLD_MS.web/main.js: the standby button in#sensorGrouppainted from{status}the wayshowCamerapaints the checkboxes;
/recordboot fetches and adopts/output;#progModeand#progSizepost;
applyProgramOuttakespreset; the hello andsendProgramOutStatego.web/key.js: thehello goes.
web/index.html: the button.bin/braindance.mjs: the entry, shebang, mode 100755.bin/verbs.js: the verb table (verb, route, method, body, formatter) as data, imported by theentry and by
test/cli-verbs.test.mjs, so it can be walked.package.json:bin. No newdependency: global
fetchis on the 18.15 floor the server already uses.5. Proof
tools/cli-check.mjs, port 8401 (free; the tool probes it first and exits 2), a staged copy under.cli-check/(added to.gitignore), the fake grabber with--hdfor the MJPEG rows. Sections:/library/routes, both ways: every verb's route is a row with that method;every
mutatesrow has a verb or sits in a declared exemption list (the worker's/jobs/*lease routes, the effect store's
PUT/DELETE, the library's destructive routes). A routeadded later without either goes red.
pgrep -Pbefore;POST /sensor/standbyanswersstandby; thePID is gone; still gone 2.5 s later (a standby that is a bare kill respawns at 1 s); the grabber
exited on SIGTERM, not SIGKILL;
respawnsunchanged;fps0. Wake:startingthenlive; anew PID;
wakes1; the spawn identitylibrary-checkholds, extended torespawns + restarts + wakes + 1.--replayserver are 409with the replay sentence; wake when live is 200 and changes nothing.
--standby-after 2: no consumer, sostandbywithin the window plus one tick;GET /camera.mjpgis held, the 200 headers arrive,the first JPEG part arrives, the state is
live; the subscriber leaves and the sensor is backon standby after the window; a monitor socket wakes it;
POST /record/startwakes it, arms,and the take opens at the hello. An armed recorder with no monitor never goes to standby.
POST {lowLight: false}answers the merged state and a socket receives{camera};POST {color: false}answersrestarting: true, a new PID,restarts1;{color: "off"}is 400.POSTmode and size,GETreflects them; a new socket receives the three messagesin order on connect; a preset naming an effect the store lacks is 409 naming it; a preset at
another
versionis 409 naming both; an unknown name is 404; a relayed operatorparamsmessage shows up in
GET /output.Mutations, each a literal-anchor entry in
MUTATIONSassyntax-checkrequires:standby-is-a-bare-kill,standby-leaves-the-retry-timer,wake-reads-as-a-respawn,idle-ignores-the-recorder,standby-from-absent,mjpeg-refuses-while-waking,camera-route-bypasses-applyCamera,output-forgets-on-connect,preset-skips-requires,verb-without-a-route.Other tools:
library-check's spawn identity row takeswakes; every tool that spawns a serverpasses
--standby-after 0in its onestart()helper (14 tools; one line each) so a longsensorless or consumer-free run does not change state under its rows, and
cli-checkis the onetool that runs with it on.
vcam-checkgains rows for the source applying apresetmessage andfor the
/recordpage adopting/outputon boot, and keepspatch-params-applied-one-at-a-time.syntax-check'sFLOORSgainsbin.module-checkcoversbin/by existing.test/cli-verbs.test.mjscovers the verb table's parsing and formatting without a server.6. Documentation
README.md: a "Command line client" section after "Streaming to OBS": the install line, theverbs, one launchd and one
systemd --userexample for keeping the server up from login, and inthe OBS section one line to turn on "Shutdown source when not visible", because OBS holds the
browser source's socket for an inactive scene otherwise and the sensor never idles.
docs/reference.md: the--standby-afterrow; a "Command line client" verb table between"Reaching it from another machine" and the viewer controls;
standbyin the record surfacetable;
wakesandstandbyin the readings; four rows in "HTTP routes".SECURITY.md: rows forthe four routes in the exposure table (standby and a colour toggle drop live OBS sources).
docs/architecture.md: the five sensor states and where the output state lives.docs/proof-tools.md: thecli-checksection and its mutations.CLAUDE.md: the table row andport 8401.
7. Decisions taken
dev->stop()on a kept-open device. The first is the onlyone where the emitter and the USB link are known to be quiet; the second is a later measurement.
standby.parkedalready means two things here: an effect key carried andnever evaluated, and the playhead.
for hours, and a default only readers of the docs get protects nobody. The cost is one line in
each tool's spawn helper.
serve,stopor daemon verbs:npm startstays the one way to start the server, and with standby there is no reason to killit. Keeping it up from login is the operating system's job and gets two example lines.
/recordpage is a writer,viewis relayedonly.
requires, thesource runs the same functions the operator runs.
8. Measurements owed before the constants are set
Two numbers are claims until taken. Method for each, on a real sensor on an M-series Mac with the
glpipeline, ten samples, warm USB, page cache irrelevant:POST /sensor/standbyto the grabber's exit, reading the exit code andsignal from the server log, and the emitter observed off by eye after the clean exit. Sets
STANDBY_GRACE_MS. If the clean exit takes longer than the measurement suggests, the grabbergets a
quitstdin command that breaks the loop without waiting onwaitForNewFrame.POST /sensor/waketo the first type 2 frame on a loopback socket.Sets
HOLD_MS(about three times the median) and the README's sentence on what to expect.The same two on the Pi, since the systemd example is for it.
9. Found while mapping
STOP_GRACE_MS(2000) is shorter than the grabber's clean teardown (about 4 s inclose()onmacOS), so today's colour toggle and every asked restart end in SIGKILL and the USB reclaim
race the 1500 ms respawn delay exists for. Standby needs the longer grace; the colour toggle
probably wants it too.
recorder.close, so anopen take is left without its sidecar.
holdProcessOpencomment instopGrabberdescribes a wait that does not happen when notake is open:
recorder.close(...).finally(() => process.exit(0))exits first.spawnGrabberhas no guard and the retry timers keep no handle: a colour toggle landing duringa backoff can double-spawn today. Four findings from the Codex review of #59: a respawn miscount, a refresh race, and two instruments asserting more than they test #67 already notes
grabberSpawnsis counted beforespawn().10. Open questions
/recordtab left open is a full-rate loopback monitor and keeps the sensor awake. Ship asis and let the button and the CLI cover it, or have a hidden tab release its socket after the
idle window? Recommendation: ship as is, follow up.
output mode mirrorfrom the CLI with no operator draws the last relayed view or the defaultpose. Accept and document, or 409 while no
/recordsocket is open? Recommendation: accept.11. Out of scope
Teams or call detection of any kind;
serve,stopor daemon verbs; authentication; a standbythat keeps the device open; OBS's own websocket API.
12. Phasing
PR 1: standby, auto-standby, wake on demand, the MJPEG hold, the camera route, the SIGTERM
handler, the CLI with
status,sensor,record,cameraand the lists,cli-checksections1 to 5, the docs for all of it. This is the whole Discord automation.
PR 2: the output state on the server, the look-only clear on a preset and the tag mechanism
behind it, the CLI's
outputverbs,cli-checksection 6, thevcam-checkrows. It changes aprotocol in
web/main.jsand needs a GPU browser to prove.Related: #67 (the respawn arithmetic this extends), #68 (the browser source over
/camera.mjpgthat the hold serves), #107 (the OBS URLs
braindance outputcould also print).