Skip to content

Put the sensor on standby, wake it on demand, and drive it from a command line client #122

Description

@totally-tim

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.

Auto-standby and wake on demand

const idle = () => attachedMonitors().length === 0
  && keyStream.count === 0 && webcam.count === 0
  && !recorder.armed && !recorder.take;

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  • spawnGrabber has no guard and the retry timers keep no handle: a colour toggle landing during
    a 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 grabberSpawns is counted before spawn().

10. Open questions

  1. 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.
  2. 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.
  3. The size ceiling: the largest export size, or a flat 8192? Recommendation: the export sizes.
  4. 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions