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/applyConventions
- Bodies are JSON (
content-type: application/json; an empty body is{}), at most 12 MB. Answers are JSON withcache-control: no-store. - Ids are short random strings: devices
d…, sceness…(the five built-in scenes areparty,chill,movie,focus,relax), schedulest…, songsm…, custom effectsu-…, requestsr…, fixturesf…. - Times in songs and shows are seconds on the song's clock; wall-clock times are ISO strings or
HH:MM, andsunrise/sunsetwith 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 headerx-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=, headerx-tempolight-keyorkeyin the body), the game-state posts need the game's token, and song requests needsettings.requestson.
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>"}:
| Status | When |
|---|---|
| 400 | the 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) |
| 403 | locked (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 |
| 404 | no such id (No such device), or no such route (No API route GET /api/x) |
| 409 | POST /api/wake while wake-up is off; POST /api/update when an update can't be applied now |
| 413 | a body over 12 MB |
| 429 | too many song requests (30 a minute), game-state posts (40 a second) or wrong game tokens |
| 502 | POST /api/wifi/setup when the Wi-Fi setup service fails |
| 500 | anything 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).
| Call | Area | Defined in |
|---|---|---|
POST /api/action | guest: pads | core.js |
GET /api/audio/sources | open | core.js |
GET /api/backup | admin | core.js |
POST /api/brightness | guest: brightness | core.js |
GET /api/custom-effects | open | core.js |
POST /api/custom-effects | guest: effects | core.js |
PUT /api/custom-effects/:id | guest: effects | core.js |
DELETE /api/custom-effects/:id | guest: effects | core.js |
GET /api/devices | open | core.js |
POST /api/devices | admin | core.js |
PATCH /api/devices/:id | admin | core.js |
DELETE /api/devices/:id | admin | core.js |
POST /api/devices/:id/check | open | core.js |
POST /api/devices/:id/fixtures | admin | pro-api.js |
PATCH /api/devices/:id/fixtures/:fid | admin | pro-api.js |
DELETE /api/devices/:id/fixtures/:fid | admin | pro-api.js |
POST /api/devices/:id/identify | open | core.js |
POST /api/devices/:id/segments | admin | core.js |
POST /api/discover | admin | core.js |
GET /api/dmx | open | pro-api.js |
GET /api/dmx/profiles | open | pro-api.js |
GET /api/effects | open | core.js |
PATCH /api/effects/:id | guest: effects | core.js |
POST /api/gsi/:game | game token | integrations/index.js |
GET /api/health | open | core.js |
GET /api/hearing | open | hearing.js |
POST /api/hearing/check | open | hearing.js |
POST /api/hearing/use | admin | hearing.js |
GET /api/home | open | home.js |
PUT /api/home | admin | home.js |
GET /api/homekit | admin | homekit/index.js |
PATCH /api/homekit | admin | homekit/index.js |
DELETE /api/homekit/pairings/:id | admin | homekit/index.js |
POST /api/homekit/reset | admin | homekit/index.js |
GET /api/integrations | admin | integrations/index.js |
PATCH /api/integrations/discord | admin | integrations/index.js |
POST /api/integrations/discord/test | admin | integrations/index.js |
PATCH /api/integrations/docs | admin | integrations/index.js |
PATCH /api/integrations/games/:game | admin | integrations/index.js |
GET /api/integrations/games/:game/cfg | admin | integrations/index.js |
POST /api/integrations/games/:game/test | admin | integrations/index.js |
POST /api/integrations/games/:game/token | admin | integrations/index.js |
PATCH /api/integrations/obs | admin | integrations/index.js |
GET /api/integrations/streamdeck/plugin | open | integrations/index.js (plain HTTP) |
PATCH /api/integrations/twitch | admin | integrations/index.js |
POST /api/integrations/twitch/login | admin | integrations/index.js |
DELETE /api/integrations/twitch/login | admin | integrations/index.js |
POST /api/integrations/twitch/test | admin | integrations/index.js |
GET /api/layout | open | core.js |
POST /api/lock | admin | core.js |
DELETE /api/lock | admin | core.js |
GET /api/log | open | core.js |
POST /api/map | admin | core.js |
GET /api/network | open | core.js |
GET /api/nowplaying | open | nowplaying.js |
PATCH /api/nowplaying | admin | nowplaying.js |
DELETE /api/nowplaying/spotify | admin | nowplaying.js |
GET /api/nowplaying/spotify/callback | open | nowplaying.js (plain HTTP) |
POST /api/nowplaying/spotify/code | admin | nowplaying.js |
POST /api/nowplaying/spotify/login | admin | nowplaying.js |
POST /api/power | guest: brightness | core.js |
POST /api/quit | admin | core.js |
GET /api/requests | open | core.js |
POST /api/requests | open | core.js |
DELETE /api/requests | admin | core.js |
PATCH /api/requests/:id | admin | core.js |
DELETE /api/requests/:id | admin | core.js |
POST /api/requests/:id/vote | open | core.js |
POST /api/reset | admin | core.js |
POST /api/restore | admin | core.js |
GET /api/room | open | core.js |
PUT /api/room | admin | core.js |
POST /api/room/identify | open | core.js |
POST /api/room/preset | admin | core.js |
GET /api/scenes | open | core.js |
POST /api/scenes | guest: scenes | core.js |
PUT /api/scenes/:id | guest: scenes | core.js |
DELETE /api/scenes/:id | guest: scenes | core.js |
POST /api/scenes/:id/apply | guest: scenes | core.js |
GET /api/schedules | open | core.js |
POST /api/schedules | admin | core.js |
PUT /api/schedules/:id | admin | core.js |
DELETE /api/schedules/:id | admin | core.js |
POST /api/schedules/:id/run | admin | core.js |
PATCH /api/settings | admin | core.js |
PATCH /api/show | guest: effects | core.js |
POST /api/sleep | brightness or link key | home.js |
DELETE /api/sleep | brightness or link key | home.js |
POST /api/song | open | core.js |
DELETE /api/song | open | core.js |
POST /api/song/clock | open | core.js |
GET /api/songs | open | core.js |
DELETE /api/songs/:id | admin | core.js |
GET /api/songs/:id/show | open | core.js |
PUT /api/songs/:id/show | guest: effects | core.js |
DELETE /api/songs/:id/show | guest: effects | core.js |
PUT /api/songs/:id/show/draft | guest: effects | core.js |
DELETE /api/songs/:id/show/draft | guest: effects | core.js |
GET /api/spotify | open | spotify.js |
PATCH /api/spotify | admin | spotify.js |
POST /api/spotify/colors | guest: effects | spotify.js |
GET /api/state | open | core.js |
GET /api/status | open | core.js |
POST /api/unlock | open | core.js |
GET /api/update | admin | core.js |
POST /api/update | admin | core.js |
POST /api/wake | brightness or link key | home.js |
DELETE /api/wake | brightness or link key | home.js |
POST /api/wake/key | admin | home.js |
POST /api/wake/snooze | brightness or link key | home.js |
POST /api/wake/stop | brightness or link key | home.js |
GET /api/wifi | open | core.js |
POST /api/wifi/setup | admin | core.js |
PUT /api/zones | guest: effects | core.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
| Call | Area | What it does |
|---|---|---|
GET /api/state | open | Everything 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/status | open | What is happening now; the same object as the WebSocket's status messages |
GET /api/health | open | {ok, version, uptime, fps, leds}, for monitoring |
GET /api/effects | open | [{id, name, tag, custom?}]: every effect, built-in and your own |
GET /api/layout | open | LED positions for drawing the room: {N, x: [], y: [], shape: [] (shape index per LED), chainStart, ranges: [{dev, start, end}], shapes} |
GET /api/log | open | The last 100 activity messages [{t, level: "info"|"warn"|"error", msg}] |
GET /api/network | open | {addresses}: this computer's LAN addresses (for the phone QR code) |
Lights and show
| Call | Area | Body |
|---|---|---|
POST /api/power | guest: brightness | {on: true} (no body toggles); answers the show |
POST /api/brightness | guest: brightness | {value: 0.6}; answers {brightness} |
PATCH /api/show | guest: effects | any 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/:id | guest: 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/zones | guest: effects | {zones: [{id, name, mode: "follow"|"effect"|"ambient"|"off", effect, brightness}]} (at most 32); shapes join a zone with their zone field |
POST /api/action | guest: pads | A 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):
type | Fields | What |
|---|---|---|
flash | color? | a flash, white or the colour |
strobe, blackout, freeze | on? | on, off, or toggle when left out |
hold | color? | hold one colour everywhere; no colour releases it |
boost | value: 0-2 | brightness gain; no value toggles 1 / 1.5 |
next | the next effect from the drop pool | |
drop | a drop now (8 s) | |
build | seconds? (default 8) | a build-up, then a drop |
cancel | ends build/drop and every pad | |
tap | tap tempo: 3 taps or more set the BPM (and the live beat phase) | |
identify | device or shape | light that device's shapes, or one shape, for 4 s |
fog | on | the DMX fog pad, while held (at most 10 s; fog must be on in the DMX settings) |
Your own effects
| Call | Area | Body |
|---|---|---|
GET /api/custom-effects | open | your effects |
POST /api/custom-effects | guest: 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/:id | guest: effects | the same body |
DELETE /api/custom-effects/:id | guest: effects | removes it and every reference to it (the shown effect, layer, ambient, zones, scenes, likes, song shows) |
Scenes and schedules
| Call | Area | Body |
|---|---|---|
GET /api/scenes | open | [{id, name, show, brightness}] |
POST /api/scenes | guest: scenes | {name} saves the current look; or {name, show, brightness} |
PUT /api/scenes/:id | guest: scenes | {name?, show?, brightness?} |
POST /api/scenes/:id/apply | guest: scenes | applies it (and turns the lights on); answers the show |
DELETE /api/scenes/:id | guest: scenes | |
GET /api/schedules | open | each with next, the next time it runs (ISO) |
POST /api/schedules | admin | {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/:id | admin | fields to change |
DELETE /api/schedules/:id | admin | |
POST /api/schedules/:id/run | admin | runs 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.
| Call | Area | Body |
|---|---|---|
GET /api/home | open | {wake, sleep, plan, status}; wake.key only when unlocked |
PUT /api/home | admin | any 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/wake | brightness 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/snooze | brightness or link key | {minutes?} |
POST /api/wake/stop | brightness or link key | stops the running sunrise |
DELETE /api/wake | brightness or link key | cancels the next sunrise, or skips the next weekly alarm |
POST /api/sleep | brightness or link key | {minutes?} (1-180): fade out and turn off; cancelled by a brightness or power change |
DELETE /api/sleep | brightness or link key | cancels the sleep timer |
POST /api/wake/key | admin | makes 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
| Call | Area | Body |
|---|---|---|
GET /api/devices | open | the devices, each with health (the last check) |
POST /api/devices | admin | add one, by kind (below); answers the device |
PATCH /api/devices/:id | admin | {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/:id | admin | also removes its shapes |
POST /api/devices/:id/check | open | checks it now; answers its health |
POST /api/devices/:id/identify | open | lights that controller's shapes |
POST /api/devices/:id/segments | admin | writes the room's LED ranges into WLED as segments; answers {segments} |
POST /api/discover | admin | {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:
| Kind | Body |
|---|---|
| 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.
| Call | Area | Body |
|---|---|---|
GET /api/room | open | {name, shapes, zones} |
PUT /api/room | admin | {name, shapes: [...]} (zones are kept) |
POST /api/room/preset | admin | {id: "fourwalls"}: a ready-made layout (presets in GET /api/state), built for your controllers |
POST /api/room/identify | open | {shape: <index>} lights one shape |
POST /api/map | admin | camera 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
| Call | Area | Body |
|---|---|---|
POST /api/devices/:id/fixtures | admin | {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/:fid | admin | the same fields |
DELETE /api/devices/:id/fixtures/:fid | admin | |
GET /api/dmx | open | {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/profiles | open | {profiles: [{id, name, kind, channels}], channelTypes, patterns} |
DMX settings are in PATCH /api/settings {dmx: {...}} (Settings).
Music
| Call | Area | Body |
|---|---|---|
POST /api/song | open | {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/clock | open | {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/song | open | the song stopped |
GET /api/audio/sources | open | [{id, label}]: sound inputs the server can capture from |
GET /api/songs | open | song 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/:id | admin | forget a song |
GET /api/songs/:id/show | open | the 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/show | guest: 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/show | guest: effects | throw away edits (colours, cues and brightness too) and make a new show |
PUT /api/songs/:id/show/draft | guest: effects | play 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/draft | guest: effects | stop 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).
| Call | Area | What it does |
|---|---|---|
GET /api/hearing | The panel's view: platform, recommended way, the checklist (computer.steps, each ok/no/maybe/todo), server capture and the sound output | |
POST /api/hearing/check | The same, detecting again now | |
POST /api/hearing/use | admin | {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.
| Call | Area | Body |
|---|---|---|
GET /api/nowplaying | open | {enabled, supported, platform, state: "ok"|"starting"|"unavailable"|"error"|"off", message, method: "osascript"|"playerctl"|"dbus-send"|"powershell", track, spotify: {clientId, connected, error, redirectUri}} |
PATCH /api/nowplaying | admin | {enabled?: true, spotifyClientId?: "<32 hex digits>"} |
POST /api/nowplaying/spotify/login | admin | answers {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/callback | open | where Spotify sends the browser after signing in (?code&state); answers a small HTML page, not JSON |
POST /api/nowplaying/spotify/code | admin | {url}: the address Spotify sent the browser to, pasted, when signing in from another device (or {code, state}) |
DELETE /api/nowplaying/spotify | admin | disconnect |
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.
| Call | Area | Body |
|---|---|---|
GET /api/spotify | open | {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/spotify | admin | any of {sync, learn, analysis, coverColours, pauseFade, muteDim, cloud: {url, use, share}} |
POST /api/spotify/colors | guest: 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.
| Call | Area | Body |
|---|---|---|
GET /api/requests | open | {enabled, requests: [{id, title, by, votes, at}]}, most votes first |
POST /api/requests | open | {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/vote | open | |
PATCH /api/requests/:id | admin | {done: true} when it has been played |
DELETE /api/requests/:id | admin | remove one |
DELETE /api/requests | admin | remove 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.
| Key | Value |
|---|---|
brightness, fps, syncOffsetMs, idleBrightness, idleAfterSec | numbers (0-1, 10-120, -500..500, 0-1, 1-600) |
punch, dynamics | 0 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, shows | booleans |
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} |
ambient | an 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
| Call | Area | Body |
|---|---|---|
GET /api/backup | admin | {tempolight: <version>, savedAt, data}: everything except the PIN, as one JSON file (it does include saved passwords and tokens: keep it private) |
POST /api/restore | admin | a backup file (or its data); the current PIN is kept |
POST /api/reset | admin | back to the defaults: devices, room, scenes, schedules and settings |
GET /api/update | admin | {method: "git"|"docker"|"npm"|"binary"|"manual", current?, behind?, ahead?, changes?: [...], dirty?, canUpdate, message} (fetches from GitHub for git installs) |
POST /api/update | admin | pulls the new version and restarts Tempolight (installs made with git or the installer); 409 when it can't now |
POST /api/quit | admin | stops 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).
| Call | Area | Body |
|---|---|---|
GET /api/wifi | open | {available: false} elsewhere; on the image {available: true, mode: "watch"|"hotspot"|"connecting"|"retry", ssid, hotspot, lastResult} |
POST /api/wifi/setup | admin | leaves the current Wi-Fi and opens the Tempolight-Setup hotspot |
Lock
| Call | Area | Body |
|---|---|---|
POST /api/lock | admin | {pin: "1234"} (4-8 digits); answers {locked: true, token} so you stay unlocked. Setting a PIN logs out every other token |
POST /api/unlock | open | {pin} answers {token}, valid 12 hours; 403 for a wrong PIN (each try takes 300 ms) |
DELETE /api/lock | admin | removes the PIN |
Status
GET /api/status and the WebSocket's status messages (4 a second; 2 in low-power mode):
| Field | Meaning |
|---|---|
source, sourceLabel | song, nowplaying, browser, capture, djlink, midiclock, link, metronome or idle, and in words |
fps, leds, uptime | frames a second now, LEDs in the room, seconds since start |
effect, effectName, style, styleName | what shows now |
bpm, part, beat | tempo, calm/build/drop/silent/idle, beat in the bar (0-3) |
level, bands | loudness 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, spotify | see 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 | |
overlay | the pads: {strobe, blackout, freeze, hold, boost, forced: "build"|"drop"|null} |
home | the home status |
mqtt | {state: "connected"|"connecting"|"reconnecting"|"error"|"off", error} or null |
phones, screen, mapping | phones connected as lights; the label of a shared screen; {device, led} while camera mapping |
perf | {mode, setting, why, fps, frameMs, cpu} |
timing | light 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:
| Entity | Topics (with base tempolight) |
|---|---|
| Light: on/off, brightness, effect (or "Automatic") | tempolight/light/set (JSON schema), state on tempolight/light/state |
| Select: scene | tempolight/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 source | tempolight/status (JSON) |
| Buttons: Flash, Drop now, Next effect | tempolight/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).
| Accessory | What 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.
| Call | Area | What it does |
|---|---|---|
GET /api/homekit | admin | {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/homekit | admin | {enabled: true} |
POST /api/homekit/reset | admin | Forget every paired controller and make a new identity and setup code (then remove the old Tempolight from the Home app) |
DELETE /api/homekit/pairings/:id | admin | Forget 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.
| Call | Area | What |
|---|---|---|
GET /api/integrations | admin | Settings and live status of all of them (obs.status, games.cs2.status, twitch.status, twitch.eventsub, discord.status, streamdeck.available) |
PATCH /api/integrations/obs | admin | {enabled, url, password, clearPassword, sceneMap: [{obs, scene}], onStreamStart, onStreamStop, onRecordStart, tally: {enabled, when: recording|streaming|either, target, color}} |
PATCH /api/integrations/games/:game | admin | :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/token | admin | A new token (the installed config file stops working) |
GET /api/integrations/games/:game/cfg | admin | ?url=http://<tempolight>:8080: answers {filename, folder, content}, the game's gamestate_integration_tempolight.cfg |
POST /api/integrations/games/:game/test | admin | {what: flash|bomb|kill|win|lose|roshan|night|day|low}: pretend it happened |
POST /api/gsi/:game | game token | Where 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/twitch | admin | {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/login | admin | {clientId}: starts the device-code sign-in, answers {userCode, uri} to enter at twitch.tv/activate |
DELETE /api/integrations/twitch/login | admin | Sign out |
POST /api/integrations/twitch/test | admin | {kind: follow|subscribe|resub|gift|raid|cheer}: run that event's reaction |
PATCH /api/integrations/discord | admin | {webhook, username, nowPlaying, scenes, twitch} (webhook: "" removes it) |
POST /api/integrations/discord/test | admin | Posts a test message |
PATCH /api/integrations/docs | admin | {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/plugin | open | The .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>.