Reference

HTTP API

Everything the web app does goes through this API, so anything else can do it too: scripts, Home Assistant, a Stream Deck, a lighting desk. It is plain HTTP with JSON bodies on the same port as the web app (8080 by default), plus one WebSocket for live data. The routes are registered in packages/server/src/core.js and the modules it plugs in; the route table below is generated from the code (node scripts/gen-api-routes.js), and npm test fails when a route is missing from this page.

TL=http://tempolight.local:8080
curl -s $TL/api/status | jq '{source, bpm, part, effect}'
curl -s -X POST $TL/api/power -H 'content-type: application/json' -d '{"on": true}'
curl -s -X PATCH $TL/api/show -H 'content-type: application/json' -d '{"effect": "laser"}'
curl -s -X POST $TL/api/action -H 'content-type: application/json' -d '{"type": "flash", "color": "#ff0066"}'
curl -s -X POST $TL/api/scenes/party/apply

Conventions

  • Bodies are JSON (content-type: application/json; an empty body is {}), at most 12 MB. Answers are JSON with cache-control: no-store.
  • Ids are short random strings: devices d…, scenes s… (the five built-in scenes are party, chill, movie, focus, relax), schedules t…, songs m…, custom effects u-…, requests r…, fixtures f….
  • Times in songs and shows are seconds on the song's clock; wall-clock times are ISO strings or HH:MM, and sunrise/sunset with an optional offset in minutes (sunset+30, sunrise-15) once a location is set.
  • Colours are #rrggbb. Brightness values are 0 to 1.
  • Numbers out of range are clamped; unknown values (an effect id, a mode) are a 400 error.
  • In the online demo the same calls run inside the browser; routes that need the computer (Hue pairing, now playing, integrations, HomeKit, updates) answer that they need Tempolight running on your network, or don't exist.

Auth

Without a PIN lock, every call works for everyone who can reach the port. With a lock (POST /api/lock):

  • calls in the admin area need an unlock token: POST /api/unlock {pin} gives {token}, valid for 12 hours (kept in memory: a restart logs everyone out). Send it as the header x-tempolight-token: <token> or as ?token=<token>;
  • calls in a guest area (effects, scenes, pads, brightness) work without a token when that area is allowed in Settings (settings.guest, all four by default), else they need one too;
  • open calls always work. A few check something of their own: the wake-up and sleep calls also take the wake-up link key (?key=, header x-tempolight-key or key in the body), the game-state posts need the game's token, and song requests need settings.requests on.
TOKEN=$(curl -s -X POST $TL/api/unlock -H 'content-type: application/json' -d '{"pin": "1234"}' | jq -r .token)
curl -s -X PATCH $TL/api/settings -H "x-tempolight-token: $TOKEN" -H 'content-type: application/json' -d '{"fps": 50}'

Origin rule. Calls that change something (any method but GET) are refused with 403 when a browser sends them from another website: their Origin header's host doesn't match the Host Tempolight was reached at. WebSocket connections from other websites are refused the same way. Scripts, Home Assistant and other apps send no Origin and aren't affected. Behind a reverse proxy that changes the Host header, list the public address in TEMPOLIGHT_ALLOWED_ORIGINS (comma-separated, for example lights.example.com). More in internals/security.md.

Errors

A failed call answers with an HTTP status and {"error": "<a sentence for people>"}:

StatusWhen
400the body is not valid JSON, or a value is wrong (Unknown effect "x", The PIN must be 4 to 8 digits, a device that can't be reached when adding it)
403locked (Settings are locked. Unlock with the PIN in Settings.), wrong PIN, wrong link key or game token, song requests off, or a request from another website
404no such id (No such device), or no such route (No API route GET /api/x)
409POST /api/wake while wake-up is off; POST /api/update when an update can't be applied now
413a body over 12 MB
429too many song requests (30 a minute), game-state posts (40 a second) or wrong game tokens
502POST /api/wifi/setup when the Wi-Fi setup service fails
500anything unexpected (the message says what)

Inside the server these are HttpError(code, message) (core.js); anything else thrown becomes a 500.

All routes

114 routes. Area: open needs nothing, admin needs an unlock token while a PIN lock is set, guest: <area> works without one when that guest area is allowed in Settings (see Auth).

CallAreaDefined in
POST /api/actionguest: padscore.js
GET /api/audio/sourcesopencore.js
GET /api/backupadmincore.js
POST /api/brightnessguest: brightnesscore.js
GET /api/custom-effectsopencore.js
POST /api/custom-effectsguest: effectscore.js
PUT /api/custom-effects/:idguest: effectscore.js
DELETE /api/custom-effects/:idguest: effectscore.js
GET /api/devicesopencore.js
POST /api/devicesadmincore.js
PATCH /api/devices/:idadmincore.js
DELETE /api/devices/:idadmincore.js
POST /api/devices/:id/checkopencore.js
POST /api/devices/:id/fixturesadminpro-api.js
PATCH /api/devices/:id/fixtures/:fidadminpro-api.js
DELETE /api/devices/:id/fixtures/:fidadminpro-api.js
POST /api/devices/:id/identifyopencore.js
POST /api/devices/:id/segmentsadmincore.js
POST /api/discoveradmincore.js
GET /api/dmxopenpro-api.js
GET /api/dmx/profilesopenpro-api.js
GET /api/effectsopencore.js
PATCH /api/effects/:idguest: effectscore.js
POST /api/gsi/:gamegame tokenintegrations/index.js
GET /api/healthopencore.js
GET /api/hearingopenhearing.js
POST /api/hearing/checkopenhearing.js
POST /api/hearing/useadminhearing.js
GET /api/homeopenhome.js
PUT /api/homeadminhome.js
GET /api/homekitadminhomekit/index.js
PATCH /api/homekitadminhomekit/index.js
DELETE /api/homekit/pairings/:idadminhomekit/index.js
POST /api/homekit/resetadminhomekit/index.js
GET /api/integrationsadminintegrations/index.js
PATCH /api/integrations/discordadminintegrations/index.js
POST /api/integrations/discord/testadminintegrations/index.js
PATCH /api/integrations/docsadminintegrations/index.js
PATCH /api/integrations/games/:gameadminintegrations/index.js
GET /api/integrations/games/:game/cfgadminintegrations/index.js
POST /api/integrations/games/:game/testadminintegrations/index.js
POST /api/integrations/games/:game/tokenadminintegrations/index.js
PATCH /api/integrations/obsadminintegrations/index.js
GET /api/integrations/streamdeck/pluginopenintegrations/index.js (plain HTTP)
PATCH /api/integrations/twitchadminintegrations/index.js
POST /api/integrations/twitch/loginadminintegrations/index.js
DELETE /api/integrations/twitch/loginadminintegrations/index.js
POST /api/integrations/twitch/testadminintegrations/index.js
GET /api/layoutopencore.js
POST /api/lockadmincore.js
DELETE /api/lockadmincore.js
GET /api/logopencore.js
POST /api/mapadmincore.js
GET /api/networkopencore.js
GET /api/nowplayingopennowplaying.js
PATCH /api/nowplayingadminnowplaying.js
DELETE /api/nowplaying/spotifyadminnowplaying.js
GET /api/nowplaying/spotify/callbackopennowplaying.js (plain HTTP)
POST /api/nowplaying/spotify/codeadminnowplaying.js
POST /api/nowplaying/spotify/loginadminnowplaying.js
POST /api/powerguest: brightnesscore.js
POST /api/quitadmincore.js
GET /api/requestsopencore.js
POST /api/requestsopencore.js
DELETE /api/requestsadmincore.js
PATCH /api/requests/:idadmincore.js
DELETE /api/requests/:idadmincore.js
POST /api/requests/:id/voteopencore.js
POST /api/resetadmincore.js
POST /api/restoreadmincore.js
GET /api/roomopencore.js
PUT /api/roomadmincore.js
POST /api/room/identifyopencore.js
POST /api/room/presetadmincore.js
GET /api/scenesopencore.js
POST /api/scenesguest: scenescore.js
PUT /api/scenes/:idguest: scenescore.js
DELETE /api/scenes/:idguest: scenescore.js
POST /api/scenes/:id/applyguest: scenescore.js
GET /api/schedulesopencore.js
POST /api/schedulesadmincore.js
PUT /api/schedules/:idadmincore.js
DELETE /api/schedules/:idadmincore.js
POST /api/schedules/:id/runadmincore.js
PATCH /api/settingsadmincore.js
PATCH /api/showguest: effectscore.js
POST /api/sleepbrightness or link keyhome.js
DELETE /api/sleepbrightness or link keyhome.js
POST /api/songopencore.js
DELETE /api/songopencore.js
POST /api/song/clockopencore.js
GET /api/songsopencore.js
DELETE /api/songs/:idadmincore.js
GET /api/songs/:id/showopencore.js
PUT /api/songs/:id/showguest: effectscore.js
DELETE /api/songs/:id/showguest: effectscore.js
PUT /api/songs/:id/show/draftguest: effectscore.js
DELETE /api/songs/:id/show/draftguest: effectscore.js
GET /api/spotifyopenspotify.js
PATCH /api/spotifyadminspotify.js
POST /api/spotify/colorsguest: effectsspotify.js
GET /api/stateopencore.js
GET /api/statusopencore.js
POST /api/unlockopencore.js
GET /api/updateadmincore.js
POST /api/updateadmincore.js
POST /api/wakebrightness or link keyhome.js
DELETE /api/wakebrightness or link keyhome.js
POST /api/wake/keyadminhome.js
POST /api/wake/snoozebrightness or link keyhome.js
POST /api/wake/stopbrightness or link keyhome.js
GET /api/wifiopencore.js
POST /api/wifi/setupadmincore.js
PUT /api/zonesguest: effectscore.js

Besides the API, the server answers GET /ws (the WebSocket), the web app's files, /engine/… (the engine's modules), /packages/…, and the pages /phone (a phone as a light), /request (guest song requests), /obs (the OBS browser source), /playground (the effects playground) and /demo.mp3.

State

CallAreaWhat it does
GET /api/stateopenEverything the web app draws: {version, canQuit, settings, show, devices, room, scenes, schedules (each with next), locked, effects: [{id, name, tag, custom?}], styles: {id: name}, palettes: [ids], presets, captureSources: [{id, label}], firstRun}. Secrets are left out (settings.mqtt.password is blank, hasPassword says one is saved)
GET /api/statusopenWhat is happening now; the same object as the WebSocket's status messages
GET /api/healthopen{ok, version, uptime, fps, leds}, for monitoring
GET /api/effectsopen[{id, name, tag, custom?}]: every effect, built-in and your own
GET /api/layoutopenLED positions for drawing the room: {N, x: [], y: [], shape: [] (shape index per LED), chainStart, ranges: [{dev, start, end}], shapes}
GET /api/logopenThe last 100 activity messages [{t, level: "info"|"warn"|"error", msg}]
GET /api/networkopen{addresses}: this computer's LAN addresses (for the phone QR code)

Lights and show

CallAreaBody
POST /api/powerguest: brightness{on: true} (no body toggles); answers the show
POST /api/brightnessguest: brightness{value: 0.6}; answers {brightness}
PATCH /api/showguest: effectsany of {mode: "auto"|"manual", effect, style: "auto"|<style>, palette, everyBeats: 1-64, speed: 0.1-4, layer: {effect, amount: 0-1.5} | null}. Setting effect alone switches to manual. Answers the show
PATCH /api/effects/:idguest: effects{pref: 1} like, {pref: -1} ban, {pref: 0} neither (automatic mode); {params: {speed: 0.1-4, intensity: 0-3, hue: -180..180, saturation: 0-2, mirror}}, or {params: null} to reset. Answers {id, pref, params}
PUT /api/zonesguest: effects{zones: [{id, name, mode: "follow"|"effect"|"ambient"|"off", effect, brightness}]} (at most 32); shapes join a zone with their zone field
POST /api/actionguest: padsA live pad (below). Answers the pads' state {strobe, blackout, freeze, hold, boost, forced}

Actions for POST /api/action (also over the WebSocket, OSC and MQTT):

typeFieldsWhat
flashcolor?a flash, white or the colour
strobe, blackout, freezeon?on, off, or toggle when left out
holdcolor?hold one colour everywhere; no colour releases it
boostvalue: 0-2brightness gain; no value toggles 1 / 1.5
nextthe next effect from the drop pool
dropa drop now (8 s)
buildseconds? (default 8)a build-up, then a drop
cancelends build/drop and every pad
taptap tempo: 3 taps or more set the BPM (and the live beat phase)
identifydevice or shapelight that device's shapes, or one shape, for 4 s
fogonthe DMX fog pad, while held (at most 10 s; fog must be on in the DMX settings)

Your own effects

CallAreaBody
GET /api/custom-effectsopenyour effects
POST /api/custom-effectsguest: effects{name, pattern: "wave"|"chase"|"pulse"|"sparkle"|"fill"|"breathe"|"gradient", axis: "along"|"x"|"y"|"around"|"centre", drive: "beat"|"kick"|"bass"|"loud"|"time", tag: "calm"|"build"|"drop"|"home", colors: "palette" | ["#ff0066", "#00ccff"], speed: 0.1-8, width: 0.01-1, cycles: 0.25-8, bg: 0-0.5}; answers it with an id (u-…) to use like any effect. At most 100
PUT /api/custom-effects/:idguest: effectsthe same body
DELETE /api/custom-effects/:idguest: effectsremoves it and every reference to it (the shown effect, layer, ambient, zones, scenes, likes, song shows)

Scenes and schedules

CallAreaBody
GET /api/scenesopen[{id, name, show, brightness}]
POST /api/scenesguest: scenes{name} saves the current look; or {name, show, brightness}
PUT /api/scenes/:idguest: scenes{name?, show?, brightness?}
POST /api/scenes/:id/applyguest: scenesapplies it (and turns the lights on); answers the show
DELETE /api/scenes/:idguest: scenes
GET /api/schedulesopeneach with next, the next time it runs (ISO)
POST /api/schedulesadmin{name, time: "19:00"|"sunset+30", days: [1,2,3,4,5] (0 = Sunday), action: {type: "scene", scene: "<id>"} | {type: "on"} | {type: "off"} | {type: "brightness", value}, enabled?}
PUT /api/schedules/:idadminfields to change
DELETE /api/schedules/:idadmin
POST /api/schedules/:id/runadminruns it now

Home and sleep

Sunrise alarm, sleep timer and the ambient plan for the day; see wake-up.md for phone automations. The wake and sleep calls work for guests when the brightness area is allowed, or with the link key (?key=<key>, header x-tempolight-key, or key in the body), which opens these calls only.

CallAreaBody
GET /api/homeopen{wake, sleep, plan, status}; wake.key only when unlocked
PUT /api/homeadminany of {wake: {enabled, source: "times"|"phone", days: ["", "07:00", ...] (7, Sunday first), duration: 10-40, target: "all"|"zone:<id>"|"dev:<id>", endColor, endBrightness, holdMin, snoozeMin}, sleep: {minutes}, plan: {enabled, fade, reset: true, rows: [{name, from, to, look: "white"|"night"|<effect>, palette, kelvin, brightness}]}}
POST /api/wakebrightness or link key{inMinutes?, at?: "07:00"|ISO date|ms, duration?: 1-120}: full light at that time (none: start now). 409 when wake-up is off
POST /api/wake/snoozebrightness or link key{minutes?}
POST /api/wake/stopbrightness or link keystops the running sunrise
DELETE /api/wakebrightness or link keycancels the next sunrise, or skips the next weekly alarm
POST /api/sleepbrightness or link key{minutes?} (1-180): fade out and turn off; cancelled by a brightness or power change
DELETE /api/sleepbrightness or link keycancels the sleep timer
POST /api/wake/keyadminmakes a new link key: {key}

These answer the home status: {wake: {phase: "pending"|"rising"|"snoozed"|"holding"|"ending", start, end, progress, snoozeUntil} | null, nextWake, sleep: {start, end, progress, left} | null, plan: {name, index, night, fading} | null}, which is also status.home. The Home Assistant device also gets the buttons Sleep timer, Snooze wake-up and Stop wake-up (tempolight/action/set with sleep, wake_snooze, wake_stop).

Devices and room

CallAreaBody
GET /api/devicesopenthe devices, each with health (the last check)
POST /api/devicesadminadd one, by kind (below); answers the device
PATCH /api/devices/:idadmin{name?, host?, leds?, ddpPort?, enabled?, maxAmps?: 0-500, mAperLed?: 5-200, protocol?: "ddp"|"e131"|"artnet", universe?, goveeMode?, goveeSegments?, lightLatencyMs?: 0-500|null}; maxAmps caps brightness to the power supply; lightLatencyMs is this light's own delay for light timing, set by hand (null: estimated again). Smart lights: {light: {music: false}} (Yeelight: no music mode), {light: {perZone: true}} (Kasa strip: a colour per zone, beta), {light: {rtPort}} (Twinkly realtime port). Hue: {hueFast: true, hueArea?} turns on fast mode (Entertainment streaming) with that area (the first by default); a bridge paired before needs its link button pressed first, and the call says so; {hueFast: false} back to basic
DELETE /api/devices/:idadminalso removes its shapes
POST /api/devices/:id/checkopenchecks it now; answers its health
POST /api/devices/:id/identifyopenlights that controller's shapes
POST /api/devices/:id/segmentsadminwrites the room's LED ranges into WLED as segments; answers {segments}
POST /api/discoveradmin{scan: true, hosts?: [ip]}: WLED controllers by mDNS and a network scan, Govee lights (kind: "govee"), OpenRGB devices on this computer (kind: "openrgb", index) and LIFX, WiZ, Kasa, Yeelight and Twinkly lights, each with added

POST /api/devices bodies:

KindBody
WLED{host: "192.168.1.42", name?, ddpPort?, leds?, force?}: the LED count is read from WLED; force adds one that doesn't answer
virtual{virtual: true, name?, leds?}: a pretend room that sends nothing
Govee (beta){host, kind: "govee", goveeMode?: "color"|"segments", leds?}
OpenRGB{host: "127.0.0.1", kind: "openrgb", index, ddpPort?: 6742}: one OpenRGB device; the LED count is read from it
Hue{host, kind: "hue", name?}: press the bridge's link button first
Nanoleaf{host, kind: "nanoleaf", name?}: hold the power button for 5-7 s first; the panels are placed as a points shape
Smart lights{host, kind: "lifx"|"wiz"|"kasa"|"yeelight"|"twinkly", name?, force?}: bulbs become one point, strips a line, a mapped Twinkly its layout; answers the device with light: {...} (what was found, for example zones)
DMX{host: "192.168.1.60", kind: "dmx", name?, protocol?: "artnet"|"e131", universe?}: one DMX universe on an Art-Net or sACN node (Art-Net counts from 0, sACN from 1)

Hue devices show hue: {lights, names, fast, area, areas: [{id, name}], fastKey}; Nanoleaf devices nanoleaf: {panels}. The Hue user name and client key and the Nanoleaf token never leave the server.

CallAreaBody
GET /api/roomopen{name, shapes, zones}
PUT /api/roomadmin{name, shapes: [...]} (zones are kept)
POST /api/room/presetadmin{id: "fourwalls"}: a ready-made layout (presets in GET /api/state), built for your controllers
POST /api/room/identifyopen{shape: <index>} lights one shape
POST /api/mapadmincamera mapping: {device, led, level?} lights only that LED (-1: all dark), {end: true} goes back to the show; also over the WebSocket as {type: "map", device, led}. Stops by itself after 5 s without news, or as soon as the WebSocket that started it closes

A shape: {type: "line"|"pole"|"free"|"circle"|"rect"|"hex"|"tri"|"matrix"|"points", name, dev: "<device id>", count (1-8192), from: <first LED or null for "after the previous shape">, rev, rot, start, zone} plus pts: [[x, y], ...] for lines (and for points: one LED exactly at each point, used by camera mapping, Nanoleaf, bulbs and DMX), or cx, cy, size (and w, h for rectangles, cols, rows, w, h for matrices). Positions are on a 16 x 9 wall view, y = 0 at the top. All fields: internals/data-model.md.

DMX

CallAreaBody
POST /api/devices/:id/fixturesadmin{profile, address: 1-512, name?, channels? (custom), pattern?, target?, x?, y?}. Profiles: rgb, rgbw, rgba, dimmer, strobe, moving-head, moving-head-16, moving-head-wheel, moving-head-wheel-16, laser, fog, custom (channels: ["dimmer", "red", "green", "blue", "fixed:0", ...], types from GET /api/dmx/profiles). Moving heads: pattern: "circle"|"figure8"|"sweep"|"point"|"still", target: [x, y] (room position, for point), cycle (beats per loop, 8), size (0-0.5), home: [pan, tilt], panRange/tiltRange (degrees). Also wheel: [{value, color: [r,g,b]}], gobos, patterns, shutter: {open, closed, strobe: [lo, hi]} for fixtures that differ. Each fixture is a point of the device's points shape in the room; x, y place it. Answers {fixture, warnings} (overlapping channels). At most 128 per universe
PATCH /api/devices/:id/fixtures/:fidadminthe same fields
DELETE /api/devices/:id/fixtures/:fidadmin
GET /api/dmxopen{settings, status: {lasers: {on, why}, fog: {on, why, duty, maxDuty, cooldownLeft}}, devices: [{id, name, host, protocol, universe, enabled, values: [512], fixtures: [{..., kind, live: [{n, type, value}]}], warnings}]}: every channel's value now
GET /api/dmx/profilesopen{profiles: [{id, name, kind, channels}], channelTypes, patterns}

DMX settings are in PATCH /api/settings {dmx: {...}} (Settings).

Music

CallAreaBody
POST /api/songopen{name, analysis, clientId?}: a song about to play in a browser; analysis is the result of preAnalyseCore() from /engine/analysis.js (internals/engine-analysis.md). With song memory on, songs over 20 s are remembered
POST /api/song/clockopen{pos: <seconds>, playing: true, rate?: 1}: where the song is now; send it on play, pause and seek, and every second or so (the WebSocket's clock message does the same)
DELETE /api/songopenthe song stopped
GET /api/audio/sourcesopen[{id, label}]: sound inputs the server can capture from
GET /api/songsopensong memory: [{id, name, bpm, dur, style, drops, at, spotifyId?, src?, cover?}] (src: learned, shared or spotify-analysis; cover: percent learned of a partly learned song)
DELETE /api/songs/:idadminforget a song
GET /api/songs/:id/showopenthe song's show: {id, name, dur, edited, show: [{t0, t1, part, effect}], colors, cues, bright} (made automatically the first time), plus what the show editor draws: bpm, P, off, barOff, style, drops, bars, env (loudness, 10 values a second as base64 bytes) and palettes ({id: ["#rrggbb", "#rrggbb"]})
PUT /api/songs/:id/showguest: effects{show: [...], colors?, cues?, bright?}: your own timeline; fields left out are kept. colors: [{t0, t1, palette} | {t0, t1, colors: ["#rrggbb", "#rrggbb"]}] (no overlaps), cues: [{t, type: "flash"|"strobe"|"blackout"|"build"|"drop", color? (flash), beats? (the others: 0.25-128)}], bright: [{t, v: 0..1}] (a straight line between keyframes). Times are seconds on the song's clock, kept to the millisecond; at most 500 of each
DELETE /api/songs/:id/showguest: effectsthrow away edits (colours, cues and brightness too) and make a new show
PUT /api/songs/:id/show/draftguest: effectsplay an unsaved version (same body as PUT …/show) on the lights for two minutes without saving it: the show editor's live preview
DELETE /api/songs/:id/show/draftguest: effectsstop playing the draft

Live audio from a browser goes over the WebSocket; from the server's sound card, turn on PATCH /api/settings {capture: {enabled: true, source: "auto"|<id>, command?}}.

Hearing the music

How Tempolight hears the music (the guided panel on the Music page; guide: hearing-music.md).

CallAreaWhat it does
GET /api/hearingThe panel's view: platform, recommended way, the checklist (computer.steps, each ok/no/maybe/todo), server capture and the sound output
POST /api/hearing/checkThe same, detecting again now
POST /api/hearing/useadmin{choice: "computer"|"mic"|"pi"|""} turns that way on (and server capture on or off)

The status has hearing: {via, sound, level, capture, output, problem, method} and offsetMs (the light timing in use). PATCH /api/settings {syncOffsetMs} sets the timing of the output playing now; add output for a given output, or syncOffsets: {"<key>": null} to forget one.

Now playing

On a PC or Mac (not in the online demo), Tempolight reads which track the music apps on that computer play, with no account: Spotify and Music on macOS (osascript; macOS asks once to allow it under Privacy & Security → Automation), any MPRIS player on Linux (playerctl, or dbus-send), and anything in the Windows media controls (PowerShell, Windows 10 1809+). It is read about once a second. When the track is a remembered song (same title, and artist when the file name has one, and a length within 3 s), the lights follow that song's beat map and show at the app's position (status.source is "nowplaying"); otherwise it is only shown, and live audio still drives the lights. A song played in the web app comes first.

CallAreaBody
GET /api/nowplayingopen{enabled, supported, platform, state: "ok"|"starting"|"unavailable"|"error"|"off", message, method: "osascript"|"playerctl"|"dbus-send"|"powershell", track, spotify: {clientId, connected, error, redirectUri}}
PATCH /api/nowplayingadmin{enabled?: true, spotifyClientId?: "<32 hex digits>"}
POST /api/nowplaying/spotify/loginadminanswers {url, redirectUri}: open url for the Spotify sign-in (PKCE, no client secret), which comes back to redirectUri (http://127.0.0.1:<port>/api/nowplaying/spotify/callback; add it to your Spotify app)
GET /api/nowplaying/spotify/callbackopenwhere Spotify sends the browser after signing in (?code&state); answers a small HTML page, not JSON
POST /api/nowplaying/spotify/codeadmin{url}: the address Spotify sent the browser to, pasted, when signing in from another device (or {code, state})
DELETE /api/nowplaying/spotifyadmindisconnect

With Spotify connected, Spotify playing on any of your devices is used when nothing plays on this computer. The tokens are kept in spotify-token.json next to the settings and never sent to browsers (not in backups either).

GET /api/status has nowPlaying: {source: "macos"|"mpris"|"windows"|"spotify", app, device, title, artist, album, dur, pos, playing, posKnown, remembered: {id, name} | null, following} or null.

Sync with Spotify

The lights follow the Spotify track that plays (here, or anywhere with your account connected) with its beat map, learned by listening the first time, shared by other installs (optional) or from Spotify's own analysis (older Spotify apps). Details, set-up and the shared beat-map service: spotify.md.

CallAreaBody
GET /api/spotifyopen{settings, on, connected, mode, modeName, via, error, track, map, learn, locked, analysis, account, local, poll, cloud, colors}; mode is exact, shared, analysis, learning, live, paused, idle or off
PATCH /api/spotifyadminany of {sync, learn, analysis, coverColours, pauseFade, muteDim, cloud: {url, use, share}}
POST /api/spotify/colorsguest: effects{art, colors: ["#rrggbb", ...]}: the album cover's colours (the browser works them out; 2 to 4)

GET /api/status has spotify: {on, mode, modeName, via, track, map, learn, ...} or null (online demo).

Guest song requests

Guests open /request on their phone (QR code on the Music page). These calls are open to everyone, even when locked.

CallAreaBody
GET /api/requestsopen{enabled, requests: [{id, title, by, votes, at}]}, most votes first
POST /api/requestsopen{title, by?}; the same song again adds a vote instead (merged: true). 403 when requests are off, 429 above 30 a minute
POST /api/requests/:id/voteopen
PATCH /api/requests/:idadmin{done: true} when it has been played
DELETE /api/requests/:idadminremove one
DELETE /api/requestsadminremove all

Turn requests off with PATCH /api/settings {requests: false}.

Settings

PATCH /api/settings (admin) takes any part of settings and answers the settings. Every key, its type and its default: internals/data-model.md.

KeyValue
brightness, fps, syncOffsetMs, idleBrightness, idleAfterSecnumbers (0-1, 10-120, -500..500, 0-1, 1-600)
punch, dynamics0 to 1: the kick pump in drops and how much brightness follows the song part
safe, predict, dropFlash, variations, audioSync, autoDiscover, setupDone, songMemory, moodColours, requests, showsbooleans
performance"auto", "full" or "lite": low-power mode for small computers. Lite caps the frame rate at 30 fps, sends the preview and phones 15 times a second and status twice a second, and the live analyser works out chroma every second frame and the vocal estimate at half rate. Auto picks lite on a Raspberry Pi Zero, 1, 2 or 3, a 32-bit ARM processor, one core or two slow ones, or when the frame loop can't keep up for 10 seconds. status.perf is {mode, setting, why, fps, frameMs, cpu}
ambientan effect id or "off"
capture{enabled, source: "auto"|<id>, command}
metronome{enabled, bpm: 40-220}
osc{enabled, port}: OSC over UDP; addresses /tempolight/effect <id>, /auto, /scene <id or name>, /brightness <0-1 or 0-255>, /power [0|1], /bpm <n>, and any action type (/flash, /strobe 1, …)
guest{effects, scenes, pads, brightness} booleans
location{lat, lon, name?} or null
night{enabled, from, to, maxBrightness, warm} (times like 23:00, sunset or sunrise-30)
text{source: "song"|"bpm"|"clock"|"custom", custom}: the Text Scroller
mqtt{enabled, url, username, password, clearPassword, prefix, base} (Home Assistant)
djlink{enabled, port}: Pioneer Pro DJ Link
link{enabled, port}: Ableton Link, listen-only; status.link is then {peers, bpm, measured, playing, following, beat, error}
dmx{enabled, lasers, fog, fogWhen: "before"|"drop", fogLead, fogBurst, fogCooldown, fogMaxDuty, fogLevel, laserMaxSec}: lasers and fog are off by default; lasers run only during the first laserMaxSec seconds of a drop and never in photosensitive-safe or night mode; fog bursts fogLead s before a drop the song analysis knows, else at the drop, at least fogCooldown s apart and at most fogMaxDuty of any 5 minutes

node scripts/bench.js measures each part's CPU time (internals/testing.md).

Backup, reset, updates, quit

CallAreaBody
GET /api/backupadmin{tempolight: <version>, savedAt, data}: everything except the PIN, as one JSON file (it does include saved passwords and tokens: keep it private)
POST /api/restoreadmina backup file (or its data); the current PIN is kept
POST /api/resetadminback to the defaults: devices, room, scenes, schedules and settings
GET /api/updateadmin{method: "git"|"docker"|"npm"|"binary"|"manual", current?, behind?, ahead?, changes?: [...], dirty?, canUpdate, message} (fetches from GitHub for git installs)
POST /api/updateadminpulls the new version and restarts Tempolight (installs made with git or the installer); 409 when it can't now
POST /api/quitadminstops Tempolight; only the one-file app (canQuit in the state), elsewhere 400

Wi-Fi (Raspberry Pi image)

Only where the Wi-Fi setup service runs (the Raspberry Pi image; Linux, TEMPOLIGHT_WIFI_SETUP set).

CallAreaBody
GET /api/wifiopen{available: false} elsewhere; on the image {available: true, mode: "watch"|"hotspot"|"connecting"|"retry", ssid, hotspot, lastResult}
POST /api/wifi/setupadminleaves the current Wi-Fi and opens the Tempolight-Setup hotspot

Lock

CallAreaBody
POST /api/lockadmin{pin: "1234"} (4-8 digits); answers {locked: true, token} so you stay unlocked. Setting a PIN logs out every other token
POST /api/unlockopen{pin} answers {token}, valid 12 hours; 403 for a wrong PIN (each try takes 300 ms)
DELETE /api/lockadminremoves the PIN

Status

GET /api/status and the WebSocket's status messages (4 a second; 2 in low-power mode):

FieldMeaning
source, sourceLabelsong, nowplaying, browser, capture, djlink, midiclock, link, metronome or idle, and in words
fps, leds, uptimeframes a second now, LEDs in the room, seconds since start
effect, effectName, style, styleNamewhat shows now
bpm, part, beattempo, calm/build/drop/silent/idle, beat in the bar (0-3)
level, bandsloudness 0-1; with live audio 24 spectrum bands (else null)
key, mood, hears, vocal{key, mode, name, confidence}, {valence, energy, name}, "A minor · melancholic", the live vocal estimate 0-1
song{memId, name, playing, pos, dur, bpm, style, drops} while a browser plays a song
recognised{id, name, pos, dur} while song memory follows a song heard live
nowPlaying, spotifysee Now playing and Sync with Spotify
capture{label, running, error} for the server's sound card
djlink, midiClock, link{name, bpm, beatInBar}; {running, stopped, started, barKnown, bpm, beat, input}; {peers, bpm, measured, playing, following, beat, error}; each null when off
power, brightness, night
overlaythe pads: {strobe, blackout, freeze, hold, boost, forced: "build"|"drop"|null}
homethe home status
mqtt{state: "connected"|"connecting"|"reconnecting"|"error"|"off", error} or null
phones, screen, mappingphones connected as lights; the label of a shared screen; {device, led} while camera mapping
perf{mode, setting, why, fps, frameMs, cpu}
timinglight timing: {mode: "ahead"|"reactive", lookaheadMs, audioMs, leadMs, capMs, slowest, frame: {intervalMs, jitterMs, worstMs}}. ahead: frames are drawn lookaheadMs ahead (the slowest light's delay, at most capMs) and faster lights are held back; reactive: listening live without beat prediction, nothing held back
dmx{lasers: {on, why}, fog: {...}} when there are DMX devices, else null
devices[]per device: id, online, version, ledsReported, lastSeen, error, rssi, signal, fps, live, power, maxPower (WLED), rttMs; what we send: sent, used, amps, limited; the latency meter: latencyMs (sound to light, null when off or offline), latency: {audioMs, analysisMs, networkMs, frameMs, offsetMs, lightMs, lightAuto}, light timing: timing: {mode, lightMs, auto, holdMs, shownMs, level} (holdMs: how long its frames are held back; shownMs: the delay left after light timing, what the meter shows), latencyLevel (good under 40 ms, ok under 80, slow, none) and warnings: [{kind: "offline"|"wifi"|"frames"|"notlive"|"power"|"slow", text}]

How these are worked out: internals/runtime-and-outputs.md.

WebSocket

Live data (status, LED previews, audio, low-delay pads) goes over one WebSocket: see WebSocket API.

Home Assistant

The easy way is MQTT discovery. In Settings → Home Assistant, turn it on and enter your broker (for example the Mosquitto add-on: mqtt://homeassistant.local:1883, with a user name and password). Or with the API: PATCH /api/settings {mqtt: {enabled: true, url, username, password, prefix?: "homeassistant", base?: "tempolight"}}. The password is never sent back (hasPassword says one is saved). Or with environment variables, applied at every start and winning over the saved settings (the Home Assistant add-on uses these with the Mosquitto add-on's login): TEMPOLIGHT_MQTT_URL (turns it on), TEMPOLIGHT_MQTT_USER, TEMPOLIGHT_MQTT_PASS, TEMPOLIGHT_MQTT_PREFIX, TEMPOLIGHT_MQTT_BASE, and TEMPOLIGHT_MQTT_ENABLED=0 to keep it off.

Tempolight then appears in Home Assistant as one device with:

EntityTopics (with base tempolight)
Light: on/off, brightness, effect (or "Automatic")tempolight/light/set (JSON schema), state on tempolight/light/state
Select: scenetempolight/scene/set with a scene name
Scenes: one scene entity per Tempolight scene (no device, so it's named just like the scene, for voice assistants), plus "Tempolight off"homeassistant/scene/tempolight/scene_<id>/config; activating sends the scene id to tempolight/scene/set ("Tempolight off": {"state": "OFF"} to tempolight/light/set). Deleted scenes are removed
Sensors: tempo, song part, music sourcetempolight/status (JSON)
Buttons: Flash, Drop now, Next effecttempolight/action/set with flash, drop or next

Voice control through Home Assistant (Google Assistant, Alexa): see voice.md.

tempolight/availability is online or offline (also set by the broker if Tempolight stops suddenly). GET /api/status has mqtt: {state, error}.

Home Assistant without MQTT

rest_command:
  tempolight_scene:
    url: "http://tempolight.local:8080/api/scenes/{{ scene }}/apply"
    method: POST
  tempolight_power:
    url: "http://tempolight.local:8080/api/power"
    method: POST
    payload: '{"on": {{ on }}}'
    content_type: "application/json"

Apple Home (HomeKit)

Tempolight can be a HomeKit bridge (HAP over IP, written into Tempolight: no Homebridge needed). Turn it on in Settings → Apple Home, then in the Home app tap + → Add Accessory and scan the QR code or type the setup code. The Home app warns that Tempolight isn't certified; choose Add Anyway. The iPhone must be on the same network (mDNS, UDP 5353), and the computer's firewall must let it reach the bridge's TCP port (port below).

AccessoryWhat it does
Light "Tempolight"On, brightness; a colour picked in the Home app holds that colour (the hold pad)
Switch "Music reactive"On: automatic mode (and lets go of a held colour); off: keep the current effect
One switch per scene ("Party mode", …)On applies the scene ("Hey Siri, turn on Party mode") and reads on while the show still matches it; off turns the lights off

Changes made anywhere in Tempolight are pushed to the Home app as they happen. New, renamed and deleted scenes show up by themselves (the bridge's configuration number goes up); a scene keeps its accessory, so rooms and automations stay.

CallAreaWhat it does
GET /api/homekitadmin{enabled, state: "off"|"starting"|"ready"|"error", error, mdnsError, port, name, deviceId, paired, setupCode, setupUri ("X-HM://…", for the QR code), pairings: [{id, admin}], accessories}
PATCH /api/homekitadmin{enabled: true}
POST /api/homekit/resetadminForget every paired controller and make a new identity and setup code (then remove the old Tempolight from the Home app)
DELETE /api/homekit/pairings/:idadminForget one controller

The bridge's identity, setup code and pairings are in homekit.json in the data folder, not in tempolight.json, so backups never carry them.

Integrations (OBS, games, Twitch, Discord, Stream Deck)

Node server only (not the online demo). Everything here except the game-state posts and the plugin download is admin-only: with a PIN lock it needs x-tempolight-token. Passwords, the Twitch sign-in and the Discord webhook are kept in the settings file under integrations and never sent back (/api/state doesn't include them). See integrations.md and streamdeck.md.

CallAreaWhat
GET /api/integrationsadminSettings and live status of all of them (obs.status, games.cs2.status, twitch.status, twitch.eventsub, discord.status, streamdeck.available)
PATCH /api/integrations/obsadmin{enabled, url, password, clearPassword, sceneMap: [{obs, scene}], onStreamStart, onStreamStop, onRecordStart, tally: {enabled, when: recording|streaming|either, target, color}}
PATCH /api/integrations/games/:gameadmin:game is cs2 or dota2: {enabled, intensity 0-1, mode: blend|takeover, healthTarget, effectsTarget, manaTarget, ambientTarget, kills, flashbang, bomb, rounds, roshan, daynight}; turning it on makes its token
POST /api/integrations/games/:game/tokenadminA new token (the installed config file stops working)
GET /api/integrations/games/:game/cfgadmin?url=http://<tempolight>:8080: answers {filename, folder, content}, the game's gamestate_integration_tempolight.cfg
POST /api/integrations/games/:game/testadmin{what: flash|bomb|kill|win|lose|roshan|night|day|low}: pretend it happened
POST /api/gsi/:gamegame tokenWhere the games post (Valve GSI JSON with auth.token). 403 without the right token, 429 above 40 a second or after 30 wrong tokens in a minute
PATCH /api/integrations/twitchadmin{chat: {enabled, channel, prefix, who: everyone|subscriber|vip|mod, cooldownSec, userCooldownSec, commands: {scene, effect, flash, color, strobe, drop}, scenes: [ids], effects: [ids]}, clientId, eventsOn, target, events: {follow|subscribe|resub|gift|raid|cheer: {type: none|flash|color|strobe|scene|effect|drop, color, seconds, scene, effect, minBits}}}
POST /api/integrations/twitch/loginadmin{clientId}: starts the device-code sign-in, answers {userCode, uri} to enter at twitch.tv/activate
DELETE /api/integrations/twitch/loginadminSign out
POST /api/integrations/twitch/testadmin{kind: follow|subscribe|resub|gift|raid|cheer}: run that event's reaction
PATCH /api/integrations/discordadmin{webhook, username, nowPlaying, scenes, twitch} (webhook: "" removes it)
POST /api/integrations/discord/testadminPosts a test message
PATCH /api/integrations/docsadmin{tryIt}: let the docs site's API reference (https://tempolight.pages.dev/docs) send requests here (CORS for that address only; off by default)
GET /api/integrations/streamdeck/pluginopenThe .streamDeckPlugin file (or a redirect to the releases page); not JSON

The OBS browser source is the page /obs (?preview=0, ?np=0, ?corner=tl|tr|bl|br, ?scale=1.5, ?bg=1). A target is all, off, zone:<zone id> or dev:<device id>.

Edit on GitHub