// The rig: every known unit, the Connect switch, and saved looks.
//
// Same model as the Blender addon, so the two read alike:
// * One switch. The server starts disconnected (connection is never saved):
// no sockets, nothing sent. Connect opens the ticked units; Disconnect
// closes every socket and stops every retry.
// * Unticking a unit drops its connection; ticking it while connected brings
// it up at once.
// * Blackout works connected or not; disconnected it is one-shot.
//
// Two rules of its own, because a web UI joins a rig other things are driving:
// * A newly discovered unit starts UNTICKED. Nothing changes its look until
// someone includes it.
// * A unit with no look of ours yet (first connect) ADOPTS what the unit is
// actually showing, read back with `state`, instead of pushing black over
// whatever Blender or a person left on it.
//
// Proto-3 units (shared control, docs/DESIGN-collab.md) change the model:
// * The LAMP is the truth. Connecting adopts what it shows and pushes
// nothing; `values` / `free` broadcasts keep us current, so no polling.
// * Slots are changed only under a grant: grab -> frames -> release. Every
// browser session of this server shares ONE socket, and the unit sees one
// socket as one holder, so the server referees its own sessions first and
// only asks the unit for a slot none of them holds.
// * A `free` (anyone's release, timeout or disconnect) carries the value the
// slot ended on; that becomes the slot's stored look.
import EventEmitter from 'bare-events'
import { Unit } from './unit.js'
import * as P from './protocol.js'
const DEFAULT_RED_MAX = 30000 // the original rig's ceiling; the addon's default
const DEFAULT_FADE_MS = 60 // a short pwm2 smooths slider steps
const SAVE_DELAY_MS = 300
const CHANGE_DELAY_MS = 50
function clampSlot (s) {
if (!s) return null
const c = (v) => P.clamp(Number(v) || 0, 0, 1)
return { r: c(s.r), g: c(s.g), b: c(s.b), level: c(s.level) }
}
// Read 12 PWM values back into four { r, g, b, level } slots: level is the
// brightest channel, the colour is normalised against it.
export function slotsFromValues (values, redMax) {
const red = Math.min(redMax, P.FIRMWARE_RED_MAX_PWM)
const slots = []
for (let i = 0; i < P.SLOTS_PER_UNIT; i++) {
const b = values[i * 3] / P.FIRMWARE_MAX_PWM
const g = values[i * 3 + 1] / P.FIRMWARE_MAX_PWM
const r = Math.min(1, values[i * 3 + 2] / red)
const level = Math.max(r, g, b)
slots.push(level > 0
? { r: r / level, g: g / level, b: b / level, level }
: { r: 1, g: 1, b: 1, level: 0 })
}
return slots
}
// One slot from the unit's B, G, R values (as in a `free` broadcast).
export function slotFromBGR (b, g, r, redMax) {
const vals = [b, g, r, 0, 0, 0, 0, 0, 0, 0, 0, 0]
return slotsFromValues(vals, redMax)[0]
}
export class Rig extends EventEmitter {
constructor ({ store, discovery, readbackMs = 1000, unitOpts = {} }) {
super()
this.store = store
this.discovery = discovery
this.readbackMs = readbackMs
this.unitOpts = unitOpts
this.connected = false
this.units = new Map()
this.looks = {}
this.viewers = 0
this._saveTimer = null
this._changeTimer = null
this._readbackTimer = null
}
// ---- lifecycle -----------------------------------------------------------
start () {
const data = this.store.load() || {}
for (const [id, cfg] of Object.entries(data.units || {})) this._addUnit(id, cfg)
this.looks = data.looks || {}
this.discovery.on('up', (ev) => this._onDiscovered(ev))
this.discovery.on('down', (ev) => this._onLost(ev))
this.discovery.on('unavailable', () => this._changed())
this.discovery.on('available', () => this._changed())
this.discovery.start()
this._readbackTimer = setInterval(() => this._readback(), this.readbackMs)
}
stop () {
clearInterval(this._readbackTimer)
this.discovery.stop()
for (const e of this.units.values()) e.unit.close()
this.flushSave()
clearTimeout(this._changeTimer)
}
// ---- units ---------------------------------------------------------------
_addUnit (id, cfg = {}) {
const entry = {
id,
label: cfg.label || id,
host: cfg.host || id + '.local', // name to dial when mDNS has no address
addr: null, // address from mDNS, preferred: no lookup to wait on
// Last address this unit was reached at (mDNS or added by IP). Dialled until
// discovery reports it again: on a phone `<name>.local` cannot be resolved at
// all (Android gives apps no DNS config), so without it a known lamp is unreachable.
lastAddr: cfg.lastAddr || null,
port: cfg.port || 1337,
manual: !!cfg.manual,
online: false,
proto: 0,
enabled: !!cfg.enabled,
redMax: cfg.redMax || DEFAULT_RED_MAX,
fadeMs: cfg.fadeMs === undefined ? DEFAULT_FADE_MS : cfg.fadeMs,
slots: Array.isArray(cfg.slots) ? cfg.slots.map(clampSlot) : null,
gamma: cfg.gamma || { b: 1, g: 1, r: 1 },
freq: cfg.freq || 1000,
grants: [null, null, null, null], // ours: { session, name, epoch, n, pending }
holders: [null, null, null, null], // anyone's, from broadcasts: { holder, epoch, n }
unit: null
}
// Slots saved as all-null are "no look yet", same as never set.
if (entry.slots && entry.slots.every((s) => s === null)) entry.slots = null
entry.unit = new Unit({ id, host: entry.lastAddr || entry.host, port: entry.port, ...this.unitOpts })
entry.unit.on('status', () => this._changed())
entry.unit.on('state', (st) => this._onState(entry, st))
entry.unit.on('hello', () => this._changed())
entry.unit.on('values', () => this._changed())
entry.unit.on('held', (h) => {
entry.holders[h.slot] = { holder: h.holder, epoch: h.epoch, n: h.n }
this._changed()
})
entry.unit.on('free', (f) => this._onFree(entry, f))
entry.unit.on('reset', () => {
// The unit freed every grant of the old socket.
entry.grants = [null, null, null, null]
entry.holders = [null, null, null, null]
this._changed()
})
this.units.set(id, entry)
return entry
}
_onDiscovered (ev) {
let e = this.units.get(ev.name)
if (!e) {
e = this._addUnit(ev.name, { host: ev.host, port: ev.port })
this._save()
}
e.online = true
e.proto = ev.proto
e.addr = ev.addr
if (e.lastAddr !== ev.addr) {
e.lastAddr = ev.addr
this._save()
}
e.port = ev.port
e.unit.host = ev.addr // dial the address: never a .local lookup in the path
e.unit.port = ev.port
this._changed()
}
_onLost (ev) {
const e = this.units.get(ev.name)
if (!e) return
e.online = false
this._changed()
}
_isV3 (e) {
return e.proto >= 3 || e.unit.proto >= 3
}
_onFree (e, f) {
const h = e.holders[f.slot]
if (h && h.n === f.n && h.epoch === f.epoch) e.holders[f.slot] = null
const g = e.grants[f.slot]
if (g && !g.pending && g.n === f.n && g.epoch === f.epoch) e.grants[f.slot] = null
if (!e.slots) e.slots = [null, null, null, null]
e.slots[f.slot] = slotFromBGR(f.b, f.g, f.r, e.redMax)
this._save()
this._changed()
}
_onState (e, st) {
if (this._isV3(e)) {
// The lamp is the truth: always adopt what it shows.
e.slots = slotsFromValues(st.values, e.redMax)
this._save()
this._changed()
return
}
if (e.slots === null) {
// First sight of this unit's look: adopt it rather than overwrite it.
e.slots = slotsFromValues(st.values, e.redMax)
this._save()
}
this._changed()
}
async addManual (host, port = 1337) {
// Ask the unit its name, so a unit added by IP and later seen on mDNS is
// one entry, not two. This one connection would evict a controller on old
// single-client firmware -- acceptable for an explicit add.
const probe = new Unit({ id: host, host, port, ...this.unitOpts })
const name = await new Promise((resolve) => {
const done = (n) => { clearTimeout(timer); resolve(n) }
const timer = setTimeout(() => done(null), 4000)
probe.once('greeting', done)
probe.sendOnce('hostname\n')
})
probe.close()
if (!name) throw new Error('no light unit answered at ' + host + ':' + port)
let e = this.units.get(name)
if (!e) e = this._addUnit(name, { host, port, manual: true })
e.host = host
e.manual = true
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) e.lastAddr = host
if (!e.online) {
e.unit.host = host
e.unit.port = port
}
this._save()
this._changed()
return name
}
updateUnit (id, patch) {
const e = this.units.get(id)
if (!e) throw new Error('unknown unit ' + id)
if (typeof patch.label === 'string') e.label = patch.label.slice(0, 64) || id
if (patch.redMax !== undefined) e.redMax = P.clamp(Math.trunc(Number(patch.redMax) || 0), 0, P.FIRMWARE_RED_MAX_PWM)
if (patch.fadeMs !== undefined) e.fadeMs = P.clamp(Math.trunc(Number(patch.fadeMs) || 0), 0, 600000)
if (patch.gamma) {
const g = (v, d) => P.clamp(Number(v) || d, 0.5, 5)
e.gamma = { b: g(patch.gamma.b, e.gamma.b), g: g(patch.gamma.g, e.gamma.g), r: g(patch.gamma.r, e.gamma.r) }
}
if (patch.freq !== undefined) e.freq = P.clamp(Math.trunc(Number(patch.freq) || 1000), 1, 40000)
// Proto 3: a slots patch updates the model only -- driving the lamp needs a
// grant (grab/setSlot/release, or recallLook).
let push = patch.redMax !== undefined && !this._isV3(e)
if (Array.isArray(patch.slots)) {
e.slots = patch.slots.slice(0, P.SLOTS_PER_UNIT).map(clampSlot)
while (e.slots.length < P.SLOTS_PER_UNIT) e.slots.push(null)
push = !this._isV3(e)
}
if (patch.enabled !== undefined && !!patch.enabled !== e.enabled) {
e.enabled = !!patch.enabled
if (!e.enabled) e.unit.close()
else if (this.connected) this._open(e)
} else if (push) {
this._push(e)
}
this._save()
this._changed()
}
setSlot (id, index, slot, session = null) {
const e = this.units.get(id)
if (!e) throw new Error('unknown unit ' + id)
if (!(index >= 0 && index < P.SLOTS_PER_UNIT)) throw new Error('slot out of range')
if (this._isV3(e)) {
const g = e.grants[index]
if (!g || g.pending || g.session !== session) throw new Error('not holding slot ' + (index + 1))
if (!e.slots) e.slots = [null, null, null, null]
e.slots[index] = clampSlot(slot)
const s = e.slots[index]
e.unit.sendSlot(index, g, P.channelsFromRGB(s.r, s.g, s.b, s.level, e.redMax), e.fadeMs)
this._changed()
return
}
if (!e.slots) e.slots = [null, null, null, null]
e.slots[index] = clampSlot(slot)
this._push(e)
this._save()
this._changed()
}
// ---- proto 3 grants ---------------------------------------------------------
// A browser session asks for a slot. Resolves { granted: true } or
// { busy: holderName }.
async grab (id, index, session, name) {
const e = this.units.get(id)
if (!e) throw new Error('unknown unit ' + id)
if (!(index >= 0 && index < P.SLOTS_PER_UNIT)) throw new Error('slot out of range')
if (!this._isV3(e)) throw new Error('unit has no shared control (firmware proto < 3)')
if (!this.connected || !e.enabled || e.unit.status !== 'connected') throw new Error('unit is not connected')
const mine = e.grants[index]
if (mine) return mine.session === session ? { granted: true } : { busy: mine.name }
// Mark it ours BEFORE asking, so a second session grabbing the same slot
// in the meantime is refused here rather than racing on the shared socket.
const g = { session, name: P.holderId(name), epoch: 0, n: 0, pending: true }
e.grants[index] = g
const r = await e.unit.grant(index, g.name)
if (e.grants[index] !== g) return { busy: 'reset' } // a reconnect wiped it
if (r.granted) {
g.epoch = r.granted[0].epoch
g.n = r.granted[0].n
g.pending = false
this._changed()
return { granted: true }
}
e.grants[index] = null
if (r.busy) return { busy: r.busy.holder }
throw new Error(r.error || 'grant failed')
}
release (id, index, session) {
const e = this.units.get(id)
if (!e) throw new Error('unknown unit ' + id)
const g = e.grants[index]
if (!g || g.pending || g.session !== session) return false
e.unit.release(index) // flushes the slot's pending frame first
return true
}
adoptActual (id) {
const e = this.units.get(id)
if (!e || !e.unit.actual) return false
e.slots = slotsFromValues(e.unit.actual.values, e.redMax)
this._save()
this._changed()
return true
}
pushSettings (id) {
const e = this.units.get(id)
if (!e) throw new Error('unknown unit ' + id)
return e.unit.command(P.gammaCommand(e.gamma.b, e.gamma.g, e.gamma.r) + P.freqCommand(e.freq))
}
// ---- the Connect switch ---------------------------------------------------
setConnected (on) {
on = !!on
if (on === this.connected) return
this.connected = on
for (const e of this.units.values()) {
if (on && e.enabled) this._open(e)
else e.unit.close()
}
this._changed()
}
_open (e) {
e.unit.open()
// Proto is known from mDNS (TXT proto=) or, for units added by hand, from
// the `hello` line that follows the greeting.
e.unit.once('status', () => this._afterHello(e, () => {
if (this._isV3(e) || e.slots === null) e.unit.requestState()
else this._push(e, { force: true })
}))
}
_afterHello (e, fn) {
if (this._isV3(e) || e.unit.proto > 0) return fn()
let done = false
const go = () => { if (!done) { done = true; clearTimeout(timer); fn() } }
const timer = setTimeout(go, 400) // proto <= 2 sends no hello
e.unit.once('hello', go)
}
_push (e, { force = false, fadeMs = e.fadeMs } = {}) {
if (!this.connected || !e.enabled || e.slots === null || this._isV3(e)) return
const values = P.unitValues(e.slots, e.redMax)
const payload = fadeMs > 0 ? P.pwm2Command(values, fadeMs) : P.pwmCommand(values)
e.unit.set(payload, { force })
}
// ---- blackout and looks ---------------------------------------------------
blackout (id = null) {
const targets = id ? [this.units.get(id)].filter(Boolean) : [...this.units.values()]
const cmd = P.blackoutCommand()
for (const e of targets) {
if (e.slots) e.slots = e.slots.map((s) => (s ? { ...s, level: 0 } : s))
if (this.connected && e.enabled) e.unit.set(cmd, { force: true })
else if (e.online || e.manual) e.unit.sendOnce(cmd)
}
this._save()
this._changed()
return targets.length
}
saveLook (name) {
name = String(name || '').trim().slice(0, 64)
if (!name) throw new Error('a look needs a name')
const units = {}
for (const e of this.units.values()) if (e.slots) units[e.id] = e.slots
this.looks[name] = { savedAt: new Date().toISOString(), units: JSON.parse(JSON.stringify(units)) }
this._save()
this._changed()
}
// Proto 3: take all four slots (or skip the unit if anyone holds one), fade
// to the look, let go. The frees then record what the lamp ended on.
async recallLook (name, fadeMs = 1000) {
const look = this.looks[name]
if (!look) throw new Error('unknown look ' + name)
const skipped = []
for (const [id, slots] of Object.entries(look.units)) {
const e = this.units.get(id)
if (!e) continue
if (!this._isV3(e)) {
e.slots = slots.map(clampSlot)
this._push(e, { force: true, fadeMs })
continue
}
if (!this.connected || !e.enabled || e.unit.status !== 'connected') continue
const local = e.grants.find(Boolean)
if (local) { skipped.push({ unit: id, holder: local.name }); continue }
const r = await e.unit.grant('all', 'look')
if (!r.granted) { skipped.push({ unit: id, holder: r.busy ? r.busy.holder : (r.error || '?') }); continue }
const want = slots.map(clampSlot)
for (const g of r.granted) {
const s = want[g.slot]
if (!s) continue
e.unit.sendSlot(g.slot, g, P.channelsFromRGB(s.r, s.g, s.b, s.level, e.redMax), fadeMs)
}
e.unit.release('all')
}
this._save()
this._changed()
return { skipped }
}
deleteLook (name) {
// Removes a saved look from the user's own list -- an explicit user action.
delete this.looks[name]
this._save()
this._changed()
}
// ---- readback -------------------------------------------------------------
setViewers (n) {
this.viewers = n
}
_readback () {
// `state` is cheap but not free on a single-threaded unit: only ask while
// someone is looking, or while a unit's look still has to be adopted.
for (const e of this.units.values()) {
if (e.unit.status !== 'connected' || this._isV3(e)) continue // v3 broadcasts
if (this.viewers > 0 || e.slots === null) e.unit.requestState()
}
}
// ---- persistence and change notification -----------------------------------
_serial () {
const units = {}
for (const e of this.units.values()) {
units[e.id] = {
label: e.label, host: e.host, lastAddr: e.lastAddr, port: e.port, manual: e.manual, enabled: e.enabled,
redMax: e.redMax, fadeMs: e.fadeMs, slots: e.slots, gamma: e.gamma, freq: e.freq
}
}
return { units, looks: this.looks }
}
_save () {
clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => this.flushSave(), SAVE_DELAY_MS)
}
flushSave () {
if (this._saveTimer === null) return
clearTimeout(this._saveTimer)
this._saveTimer = null
this.store.save(this._serial())
}
_changed () {
if (this._changeTimer) return
this._changeTimer = setTimeout(() => {
this._changeTimer = null
this.emit('change')
}, CHANGE_DELAY_MS)
}
snapshot () {
return {
connected: this.connected,
discovery: { available: this.discovery.available, reason: this.discovery.reason },
units: [...this.units.values()].map((e) => ({
id: e.id,
label: e.label,
host: e.host,
addr: e.addr,
port: e.port,
online: e.online,
manual: e.manual,
proto: Math.max(e.proto, e.unit.proto),
// Who holds each slot. `session` is set only for this server's own
// grants, so a page can tell "mine" from "someone else's".
holds: e.holders.map((h, s) => {
if (!h) return null
const g = e.grants[s]
const ours = g && !g.pending && g.n === h.n && g.epoch === h.epoch
return { holder: h.holder, session: ours ? g.session : null }
}),
enabled: e.enabled,
status: e.unit.status,
error: e.unit.error,
redMax: e.redMax,
fadeMs: e.fadeMs,
slots: e.slots,
gamma: e.gamma,
freq: e.freq,
actual: e.unit.actual
})),
looks: Object.keys(this.looks).sort()
}
}
}