Extending Tempolight
Recipes for the four most common changes, with the exact files to touch. Keep the boundaries from
architecture: no I/O in the engine, no node: imports in anything
core.js or runtime.js imports (the online demo's build fails on them), and anything that needs the network or
files comes in through io or env. No new dependencies.
Add a device kind
A smart light that fits the table (most bulbs and strips)
LIFX, WiZ, Kasa, Yeelight and Twinkly share one table, so a new kind of the same sort is mostly one file and one entry.
packages/outputs/src/<kind>.js: the protocol, Node built-ins only. Export a sender class withsend(rgb: Uint8Array)(3 bytes per "LED"),power(on),close()and asentcounter; aninfo(host)that asks the light what it is; adiscover({timeoutMs})that returns[{host, name, leds?}]; and the default port. Keep the rate the light accepts in mind (send only on change, or coalesce).packages/outputs/src/lights.js: add an entry toLIGHT_KINDS:label,port,frameMs,probe(host)→{name, leds, light, layout: "point"|"line"|{points}},sender(dev),check(dev)→{version, leds},discover(o), and optionallyoptions(dev, body)forPATCH /api/devices/:id {light: {...}}.packages/outputs/src/index.js: nothing to do if it is only reached throughLIGHT_KINDS(already exported); export the sender too if tests or other modules need it.packages/server/src/lights.js: add the kind toLIGHT_KIND_NAMES(what the demo lists withoutenv.lights) and a hint toHINTS(what to check in the light's app when it doesn't answer).packages/server/src/latency.js: add its update time toFRAME_MSif it is slower than a frame.apps/web/app.js: add it toSMARTand to a group inKIND_GROUPS(the Add device form), and a description in the device row if it has options;apps/web/wizard.js: add it toSMART(the setup wizard's scan).- Tests: a pretend light in
packages/outputs/test/fake-lights.jsand protocol tests inpackages/outputs/test/smartlights.test.js; adding it through the API inpackages/server/test/lights.test.js. - Docs: api.md (
POST /api/devicesbody), runtime-and-outputs.md, and the user guides' device list (owned by the guides).
core.addDevice() already routes every LIGHT_KINDS kind to lights.add(), the runtime builds the sender from
the table (LK[d.kind].sender(d)), health checks use check(), POST /api/discover uses discover(), and
setPower() calls power().
A device with its own protocol (like Nanoleaf, Hue, OpenRGB)
packages/outputs/src/<kind>.js: the sender and any pairing/info functions; export them fromindex.js.packages/server/src/node-io.js: re-export them (that is whatiois on a PC or Pi).packages/server/src/app.js: pass pairing and info functions intoenv(createCore({env: {...}})), for exampleenv.nanoleafPair,env.nanoleafInfo.packages/server/src/core.jsaddDevice(): a branch forkind === "<kind>": refuse whenenv.<fn>is missing (the demo), check for duplicates, pair or probe, build the device object (id: env.newId("d"),name,host,kind,ddpPort,leds,enabled, kind-specific fields), push it, place a shape (autoPlace(dev)or apointsshape), thenruntime.rebuild(); runtime.checkDevice(dev); runtime.note(...); changed("devices"). Keep tokens out of answers insafeDev().packages/server/src/runtime.js:rebuild(): destructure the sender fromio, add the kind to theprotochoice, every field the sender is built from to the key (so it is rebuilt when they change), and construct it;checkDevice(): a branch that asks the device and callsnoteRtt();setPower(): whether it getssender.power(on);DEFAULT_PORTand core.js'sPATCHdefault port map.
latency.jsFRAME_MS, the web app'sKIND_GROUPS, tests and docs as above.
Add an integration
An integration that talks to something outside (a broker, an app, a web service) belongs in the Node layer, started
by app.js, and plugs into the core without the core knowing it.
packages/server/src/<name>.js(or a folder likeintegrations/):export function start<Name>({core, store, runtime, ...inject})returning{close(), ...}. Take every outside thing (fetch, a socket opener, a clock, URLs) as an option with the real one as the default, so tests can pass pretend ones.- Settings: either under
settings.<name>(add defaults todefaults.js;GET /api/statereturns them, so blank secrets as core.js does formqtt.password) or, when it holds secrets, under a top-level key the module fills in itself (asintegrationsdoes withmerge(DEFAULTS, saved)), or in a file of its own next totempolight.json, written with mode0600(asspotify-token.json,homekit.json). - Routes:
core.route(method, path, "admin", fn); see the next section. ThrowHttpErrorfromcore.jsfor 4xx answers. - What it can do:
core.applyScene(scene),core.applyShow(body),core.applySettings(body),runtime.action({type: ...})(the pads),runtime.setPower(on),runtime.note(msg, level)(the activity log). React to changes withcore.onChange(what => ...)andcore.onScene(scene => ...); readruntime.status(). To draw on top of the show, chain the director'spost(COL, wall)hook (keep the previous one and call it, asintegrations/index.jsdoes, since Home & sleep uses it too). packages/server/src/app.js: start it after the core, passopts.<name>through for tests, and close it inapi.close().- Status: put what the UI should show in a runtime field read by
status()(asrt.mqtt), or in its ownGETroute. - Web app: a panel or a lazy page (
lazyPage(() => import("/<name>.js"), "<name>Page")inapps/web/app.js, and the file insw.js'sLATERlist). Don't load it in demo mode if it needs the server. - Tests:
packages/server/test/<name>.test.jswithcreateApp({dataDir, discover: false, logger: null, <name>: {pretend services}}).
Add an API route
Register it where its feature lives:
- in
packages/server/src/core.jswithroute(method, path, area, fn)(or inpro-api.js/home.js, which get the sameroute); - in a Node-layer module with
core.route(method, path, area, fn).
pathis a string literal with:nameparameters (they arrive asparams.name);areaisnull(open),"admin"(needs the unlock token while a PIN lock is set) or a guest area ("effects","scenes","pads","brightness").fn(ctx, params)getsctx = {headers, query, body}and returns the JSON answer (or a promise). Thrownew HttpError(code, message)for errors; anything else becomes a 500.- in
Validate everything in the body (clamp numbers, check ids exist), save with
changed("<what>")in the core (it saves, tells the browsers and the change listeners), and callruntime.rebuild()/runtime.syncDirector()if the lights depend on it.Routes are tried in the order they were registered and the first match wins; methods must match exactly.
A route that must answer something other than JSON (a redirect page, a file download) is a
handleHttp(req, res, url)in the module, called byapp.jsbefore the API; write it asif (req.method !== "GET" || url.pathname !== "/api/...") return false;so the route reader finds it.Run
node scripts/gen-api-routes.js --write docs/api.mdand describe the route in its section of api.md as`METHOD /api/path`with its body and answer.npm testfails until both are done.Test it through HTTP in
packages/server/test/(and with the lock on, if it has an area).
The online demo gets every route registered in core.js (and what it imports) for free; Node-layer routes don't
exist there and the web app has to cope (api() rejects with 404).
Add a setting
packages/server/src/defaults.js: the default undersettings(orshow). Existing files get it throughmerge()on load.packages/server/src/core.jsapplySettings(b): readb.<key>, validate and clamp it (num(v, lo, hi)), store it. If something must restart when it changes (the loop, capture, OSC, DJ Link), compare it in thebefore/aftersnapshot at the top of the function. Settings for DMX and Link live inpro-api.jsapplySettings, which core calls.- Use it: in the runtime read
data().settings.<key>each frame, or copy it intodirector.settingsinsyncDirector()for the director (and add it to the director'ssettingsdefaults indirector.js). apps/web/app.js: the control on the Settings page (PATCH /api/settings).- Docs: the settings table in data-model.md and the
PATCH /api/settingsrow in api.md. - Test:
packages/server/test/server.test.js(validation and that it takes effect).
If the setting can come from the environment (like the MQTT broker for the Home Assistant add-on), read it in
env-settings.js settingsFromEnv() and apply it in applyEnvSettings().