OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / lib / rig.js

526 lines · javascript ref:
// 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()
    }
  }
}