A small, single-user Electron desktop app that turns Philips Hue bulbs into a theatrical cue board. This document describes what the code actually does. For the original aspirational design (which is not what got built), see git history.
Standard Electron split:
- Main process (
src/main/) — Node.js. Owns the SQLite database, the Hue bridge client, electron-store persistence, and IPC handlers. No UI. - Renderer process (
src/renderer/) — Chromium. Vanilla HTML/CSS/JS. Talks to main only via the preload bridge. - Preload (
src/shared/preload.js) — runs in an isolated context with access to both Node and the renderer'swindow. UsescontextBridgeto expose a narrowwindow.electronAPIsurface to the renderer. Renderer never gets direct Node access.
┌─────────────────────────────────────┐
│ Renderer (Chromium) │
│ index.html · styles.css · app.js │
│ ↕ window.electronAPI
├─────────────────────────────────────┤ preload.js (contextBridge)
│ ↕ ipcRenderer.invoke
├─────────────────────────────────────┤
│ Main (Node) │
│ index.js │
│ ↳ Database (sqlite3) │
│ ↳ HueService (node-hue-api) │
│ ↳ Session store (electron-store)│
│ ↳ Bridge config (electron-store)│
└─────────────────────────────────────┘
↕ HTTPS / mDNS / N-UPnP
┌─────────────┐
│ Hue Bridge │
└─────────────┘
↕ Zigbee
💡 💡 💡 💡
Three tables, all in %APPDATA%/cyc-app/cyc.db:
shows (id, name, created_date, modified_date)
cues (id, show_id, cue_number REAL, name, fade_time REAL)
cue_lights (id, cue_id, light_id, is_on, brightness, color)cue_numberis aREALso reordering can use midpoint floats (drop between 2 and 3 → 2.5) without renumbering everyone else.cue_lights.is_ondistinguishes "this cue turns light X off" from "this cue ignores light X." Lights NOT incue_lightsfor a given cue are left untouched when the cue fires — the bridge keeps their prior state.- Schema migrations run on every launch via
runMigrations(), idempotent (swallows "duplicate column" errors).
Two electron-store JSON files alongside it:
cyc-hue.json—{ bridgeIp, appKey }from the pairing handshakecyc-session.json—{ showId, cueIndex, savedAt }written after every GO/BACK/Jump for crash recovery
src/main/hue-service.js wraps node-hue-api. Key choices:
- Standard CLIP API, not Entertainment API. The bridge accepts a
transitiontime(in deciseconds) on a singlesetLightStatecall and interpolates the fade locally — no DTLS, no UDP streaming, no Entertainment Area, no per-area light limit. Adequate for theatrical cueing; saves enormous implementation complexity. - Discovery uses both N-UPnP (Philips' cloud service) and mDNS (local), de-duped by IP. Either path alone is sometimes flaky; together they're reliable.
- Pairing uses
createInsecureLocal().connect()thenusers.createUser()— requires the physical link button to have been pressed within ~30s. - Per-cue fire maps
cue_lightsrows to a parallel array ofsetLightStatecalls with the cue's fade time astransitiontime. Failures per-light are caught individually so one unreachable bulb doesn't break the cue. - Heartbeat — renderer pings
getConfiguration()every 10s when healthy, every 5s when disconnected. On failure, the main process clears its client reference and the renderer shows the disconnect banner. - Color: the editor uses standard
<input type="color">(#rrggbb hex). HueService converts hex→Hue's RGB internally (which the library then converts to xy), and Hue's HSB→hex for "Capture from bridge."
A single mutable state object in app.js. No framework, no reactive store. Render functions read from state and rewrite the relevant DOM subtree (renderCueList, renderEditorLights, updateConnectionBadge, etc.).
Cue editor is a working copy: state.editorLights is a Map<lightId, EditorRow> populated when the modal opens. Edits mutate the map directly; Save persists via updateCueLights. Cancel discards by closing the modal without writing.
SortableJS is initialized fresh on each renderCueList call (prior instance destroyed first) since innerHTML = '' blows away its DOM hooks.
All exposed via window.electronAPI in the renderer:
| Namespace | Methods |
|---|---|
| (root) | getShows, createShow, getCues, createCue, updateCue, deleteCue, getCueLights, getCueLightCounts, updateCueLights, exportShow, importShow |
hue |
status, ping, reconnect, discover, pair, unpair, getLights, getGroups, applyCue, blackout |
session |
get, save, clear |
Every handler is ipcMain.handle (promise-based). Errors propagate to the renderer where they're caught and shown via showError().
electron-builder produces an NSIS installer (Cyc Setup x.y.z.exe) under dist/. Per-user install, no admin needed. sqlite3 is rebuilt against Electron's V8 ABI via the postinstall script (electron-builder install-app-deps).
No code signing. No auto-updater (manual reinstall to update). For personal use, fine.
- Not a SaaS. No backend, no accounts, no cloud. Everything is local.
- Not a microservice. One process, one DB file, one bridge connection.
- Not React/Redux/MUI. Vanilla DOM. The cue list and editor are simple enough that a framework would add more code than it removes.
- Not multi-user. One operator, one machine, one show at a time.
- Not protocol-agnostic. Hue-only. Adding DMX or other protocols would mean abstracting
HueServicebehind an interface — a half-day's work, not a redesign.