OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / lib / protocol.js

213 lines · javascript ref:
// Wire protocol for the esp324xlight firmware — a port of
// blender-addon/cthuloid_lights/protocol.py, kept byte-identical so the addon
// and the web UI send the same command for the same look. test/protocol.test.js
// pins it to vectors generated from the Python.
//
// Channel order is B, G, R per slot: the firmware caps every value at index
// count % 3 == 2 to RED_MAX_PWM, so the third of each triple IS red. A unit is
// 12 channels = 4 slots x 3. With INVERT_HIGH_AND_LOW in the firmware, input 0
// is LED-off, so padding with zeros is dark.

export const CHANNELS_PER_SLOT = 3
export const SLOTS_PER_UNIT = 4
export const NUM_CHANNELS = CHANNELS_PER_SLOT * SLOTS_PER_UNIT

// parsePWM() rejects the WHOLE command if any one value exceeds these, so every
// value is clamped before it is sent — one bad value would drop all 12.
export const FIRMWARE_MAX_PWM = 65535
export const FIRMWARE_RED_MAX_PWM = 45000

export function clamp (v, lo, hi) {
  return v < lo ? lo : (v > hi ? hi : v)
}

// Python's round() rounds halves to even; Math.round rounds them up. Matching
// Python keeps the two controllers from disagreeing by one step on a .5.
export function roundHalfEven (x) {
  const f = Math.floor(x)
  const d = x - f
  if (d > 0.5) return f + 1
  if (d < 0.5) return f
  return f % 2 === 0 ? f : f + 1
}

export function pwmFromUnit (value, pwmMax) {
  return clamp(roundHalfEven(Math.abs(value) * pwmMax), 0, pwmMax)
}

// [B, G, R] PWM ints from 0..1 colour channels. `level` scales all three (the
// web UI's brightness slider); redMax is the per-unit red ceiling.
export function channelsFromRGB (r, g, b, level, redMax) {
  const s = clamp(level, 0, 1)
  return [
    pwmFromUnit(b * s, FIRMWARE_MAX_PWM),
    pwmFromUnit(g * s, FIRMWARE_MAX_PWM),
    pwmFromUnit(r * s, Math.min(redMax, FIRMWARE_RED_MAX_PWM))
  ]
}

// Same conversion as protocol.py channels_from_rgbe, energy scaling included,
// so the shared vectors can exercise it directly.
export function channelsFromRGBE (r, g, b, energy, redMax, useEnergy, energyRef) {
  let scale = 1
  if (useEnergy) {
    const ref = energyRef > 0 ? energyRef : 1
    scale = clamp(energy / ref, 0, 1)
  }
  return [
    pwmFromUnit(b * scale, FIRMWARE_MAX_PWM),
    pwmFromUnit(g * scale, FIRMWARE_MAX_PWM),
    pwmFromUnit(r * scale, Math.min(redMax, FIRMWARE_RED_MAX_PWM))
  ]
}

// The 12 values for a unit from its four slots ({ r, g, b, level }).
export function unitValues (slots, redMax) {
  const values = []
  for (let i = 0; i < SLOTS_PER_UNIT; i++) {
    const s = slots[i]
    if (!s) values.push(0, 0, 0)
    else values.push(...channelsFromRGB(s.r, s.g, s.b, s.level, redMax))
  }
  return values
}

export function pwmCommand (values) {
  return 'pwm ' + values.join(' ') + '\n'
}

// Split a fade into (numPeriods, periodMs), both within the 1..1000 parsePWM
// accepts. The firmware interpolates on wall-clock time, so the pair only has
// to multiply out to the intended duration.
export function pickPwm2Args (totalMs) {
  totalMs = Math.trunc(clamp(totalMs, 1, 1000 * 1000))
  if (totalMs <= 1000) return [1, totalMs]
  const periodMs = Math.floor((totalMs + 999) / 1000)
  const numPeriods = Math.max(1, Math.floor(totalMs / periodMs))
  return [numPeriods, periodMs]
}

export function pwm2Command (values, fadeMs) {
  const [periods, periodMs] = pickPwm2Args(fadeMs)
  return 'pwm2 ' + periods + ' ' + periodMs + ' ' + values.join(' ') + '\n'
}

// Firmware silently ignores any gamma outside 0.5..5.0.
export function gammaCommand (b, g, r) {
  const f = (v) => clamp(v, 0.5, 5.0).toFixed(2)
  return 'gamma ' + f(b) + ' ' + f(g) + ' ' + f(r) + '\n'
}

export function freqCommand (hz) {
  return 'freq ' + Math.trunc(clamp(hz, 1, 40000)) + '\n'
}

export function blackoutCommand () {
  return 'stopfade\n' + pwmCommand(new Array(NUM_CHANNELS).fill(0))
}

// `state <fade> <freq> <gB> <gG> <gR> <v1..v12>` (firmware proto 2). Returns
// null for anything else, so callers can feed it every line the unit sends.
export function parseState (line) {
  const parts = line.trim().split(/\s+/)
  if (parts[0] !== 'state' || parts.length !== 6 + NUM_CHANNELS) return null
  const nums = parts.slice(1).map(Number)
  if (nums.some((n) => !Number.isFinite(n))) return null
  return {
    fading: nums[0] === 1,
    freq: nums[1],
    gamma: { b: nums[2], g: nums[3], r: nums[4] },
    values: nums.slice(5)
  }
}

// "<hostname> ready" — the greeting every unit sends on connect.
export function parseGreeting (line) {
  const m = /^(\S+) ready$/.exec(line.trim())
  return m ? m[1] : null
}

// ---- proto 3: shared control (docs/DESIGN-collab.md §5) ---------------------
//
// The unit referees per-slot grants. Lines it sends:
//   hello proto=3 unit=<name> epoch=<e> pk=<hex>      (line 2 of the greeting)
//   granted <s> <epoch> <n>  |  busy <s> <holder>     (replies to `grant`)
//   held <s> <holder> <epoch> <n>                     (broadcast)
//   free <s> <epoch> <n> <b> <g> <r> <why> <holder> <sig>   (broadcast, signed)
//   values <v1> ... <v12>                             (broadcast, <= 4/s)

const UINT = /^\d+$/

function uints (parts) {
  return parts.every((x) => UINT.test(x)) ? parts.map(Number) : null
}

export function parseHello (line) {
  const parts = line.trim().split(/\s+/)
  if (parts[0] !== 'hello') return null
  const kv = {}
  for (const p of parts.slice(1)) {
    const i = p.indexOf('=')
    if (i > 0) kv[p.slice(0, i)] = p.slice(i + 1)
  }
  if (!UINT.test(kv.proto || '') || !UINT.test(kv.epoch || '')) return null
  return { proto: Number(kv.proto), unit: kv.unit || '', epoch: Number(kv.epoch), pk: kv.pk || '' }
}

export function parseGranted (line) {
  const p = line.trim().split(/\s+/)
  if (p[0] !== 'granted' || p.length !== 4) return null
  const n = uints(p.slice(1))
  return n ? { slot: n[0], epoch: n[1], n: n[2] } : null
}

export function parseBusy (line) {
  const p = line.trim().split(/\s+/)
  if (p[0] !== 'busy' || p.length !== 3 || !UINT.test(p[1])) return null
  return { slot: Number(p[1]), holder: p[2] }
}

export function parseHeld (line) {
  const p = line.trim().split(/\s+/)
  if (p[0] !== 'held' || p.length !== 5) return null
  const n = uints([p[1], p[3], p[4]])
  return n ? { slot: n[0], holder: p[2], epoch: n[1], n: n[2] } : null
}

export function parseFree (line) {
  const p = line.trim().split(/\s+/)
  if (p[0] !== 'free' || p.length !== 10) return null
  const n = uints(p.slice(1, 7))
  if (!n) return null
  return { slot: n[0], epoch: n[1], n: n[2], b: n[3], g: n[4], r: n[5], why: p[7], holder: p[8], sig: p[9] }
}

export function parseValues (line) {
  const p = line.trim().split(/\s+/)
  if (p[0] !== 'values' || p.length !== 1 + NUM_CHANNELS) return null
  return uints(p.slice(1))
}

export function grantCommand (slot, holder) {
  return 'grant ' + slot + ' ' + holder + '\n'
}

export function slotCommand (slot, epoch, n, bgr, fadeMs) {
  return 'slot ' + slot + ' ' + epoch + ' ' + n + ' ' + bgr.join(' ') + ' ' + Math.trunc(fadeMs) + '\n'
}

export function releaseCommand (slot) {
  return 'release ' + slot + '\n'
}

// The exact bytes a unit signs for a `free` (grants.h freeMessage).
export function freeMessage (unit, f) {
  return 'cthuloid/free/1 ' + unit + ' ' + f.slot + ' ' + f.epoch + ' ' + f.n + ' ' + f.b + ' ' + f.g + ' ' + f.r
}

// Holder ids go to the firmware as one token (<= 32 chars, no spaces).
export function holderId (name) {
  const s = String(name || '').trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32)
  return s || 'web'
}