// 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'
}