// Cthuloid Lights page. Server state arrives over /api/events (SSE) and is the
// only source of truth; controls send changes and wait to be told the result.
import { hsvToRgb, hsvToSlot, slotToHsv, toHex } from './color.js'
import { Picker } from './picker.js'
const $ = (sel, el) => (el || document).querySelector(sel)
// ---- who is at this page ------------------------------------------------------
// A session id per browser (shared by its tabs: same person) and a display name.
// Units with shared control (firmware proto 3) show the name to everyone while
// this browser holds a slot. Storage can be unavailable (private mode): then
// both just last for this page load.
function stored (key, make) {
try {
let v = localStorage.getItem(key)
if (!v) { v = make(); localStorage.setItem(key, v) }
return v
} catch {
return make()
}
}
const SESSION = stored('cthuloid.session', () => Math.random().toString(36).slice(2, 10))
let myName = stored('cthuloid.name', () => 'web-' + SESSION.slice(0, 4))
// ---- talking to the server --------------------------------------------------
async function post (path, body) {
const res = await fetch(path, {
method: 'POST',
// The custom header is the cross-site guard: the server refuses POSTs
// without it, and a foreign page cannot add it without a CORS preflight.
headers: { 'content-type': 'application/json', 'x-cthuloid': '1' },
body: JSON.stringify(body || {})
})
const out = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(out.error || (out.busy ? 'held by ' + out.busy : res.statusText))
err.status = res.status
err.body = out
throw err
}
return out
}
// Latest-wins sender per control: at most one request in flight, and only the
// newest value is sent next. A drag can't queue a backlog, and a slow reply
// can never land an older value after a newer one.
const senders = new Map()
function sender (key) {
let s = senders.get(key)
if (!s) {
s = { busy: false, next: null, after: [], onError: null }
senders.set(key, s)
}
return s
}
function sendLatest (key, path, body, onError = null) {
const s = sender(key)
s.next = { path, body }
s.onError = onError
if (!s.busy) pump(s)
}
// Run fn once everything queued on `key` has been sent -- e.g. a release must
// follow the slot's last value, never overtake it.
function afterSends (key, fn) {
const s = sender(key)
if (!s.busy && !s.next) return fn()
s.after.push(fn)
}
async function pump (s) {
s.busy = true
while (s.next) {
const { path, body } = s.next
s.next = null
try {
await post(path, body)
} catch (err) {
if (s.onError) s.onError(err)
else console.warn(path, err.message)
}
}
s.busy = false
const after = s.after.splice(0)
for (const fn of after) fn()
}
function act (fn) {
return (ev) => {
if (ev) ev.preventDefault()
fn().catch((err) => alert(err.message))
}
}
// ---- colour helpers ---------------------------------------------------------
// A unit's readback (BGR per slot, red capped) as a CSS colour.
function actualCss (values, i, redMax) {
const red = Math.min(redMax, 45000) || 1
const b = values[i * 3] / 65535
const g = values[i * 3 + 1] / 65535
const r = Math.min(1, values[i * 3 + 2] / red)
return 'rgb(' + [r, g, b].map((v) => Math.round(v * 255)).join(',') + ')'
}
// A slot's colour from the unit's live values (BGR, red capped), for mirroring a
// slot someone else is moving.
function slotFromValues (v, i, redMax) {
const red = Math.min(redMax, 45000) || 1
const b = v[i * 3] / 65535
const g = v[i * 3 + 1] / 65535
const r = Math.min(1, v[i * 3 + 2] / red)
const level = Math.max(r, g, b)
return level > 0 ? { r: r / level, g: g / level, b: b / level, level } : { r: 1, g: 1, b: 1, level: 0 }
}
// Same words the Blender addon uses, so the two read alike.
function stateWords (st, u) {
if (!u.enabled) return ['off', 'off']
if (!st.connected) return ['not connected', 'off']
if (u.status === 'connected') return ['connected', 'connected']
if (u.status === 'unreachable') return ['unreachable', 'unreachable']
return ['connecting…', 'connecting']
}
// Don't overwrite a control the user is touching with an echo of an older value.
function setValue (el, v) {
if (document.activeElement === el) return
if (String(el.value) !== String(v)) el.value = v
}
// ---- rendering ----------------------------------------------------------------
const cards = new Map()
let state = null
function render (st) {
state = st
const connect = $('#connect')
connect.textContent = st.connected ? '● CONNECTED — live (click to disconnect)' : 'CONNECT'
connect.classList.toggle('on', st.connected)
const banner = $('#banner')
banner.textContent = st.connected
? 'Changes go straight to the included lights.'
: 'Not connected: nothing is sent to the lights.'
banner.classList.toggle('on', st.connected)
const disc = $('#discovery')
disc.hidden = st.discovery.available
disc.textContent = 'Automatic discovery is not working on the server: ' + st.discovery.reason
const seen = new Set()
for (const u of st.units) {
seen.add(u.id)
let card = cards.get(u.id)
if (!card) {
card = makeCard(u.id)
cards.set(u.id, card)
$('#units').appendChild(card.el)
}
updateCard(card, u, st)
}
for (const [id, card] of cards) {
if (!seen.has(id)) {
card.el.remove()
cards.delete(id)
}
}
$('#no-units').hidden = st.units.length > 0
const list = $('#looks')
list.replaceChildren(...st.looks.map((name) => {
const li = document.createElement('li')
const label = document.createElement('span')
label.textContent = name
const recall = document.createElement('button')
recall.type = 'button'
recall.textContent = 'Recall'
recall.disabled = !st.connected
recall.title = st.connected ? 'Fade the included units to this look' : 'Connect first'
recall.onclick = act(() => post('/api/looks/' + encodeURIComponent(name) + '/recall',
{ fadeMs: Math.round(Number($('#recall-fade').value) * 1000) }))
const del = document.createElement('button')
del.type = 'button'
del.className = 'danger'
del.textContent = 'Delete'
del.onclick = act(async () => {
if (confirm('Delete the look "' + name + '"?')) await post('/api/looks/' + encodeURIComponent(name) + '/delete')
})
li.append(label, recall, del)
return li
}))
}
function makeCard (id) {
const el = $('#unit-tpl').content.firstElementChild.cloneNode(true)
const base = '/api/units/' + encodeURIComponent(id)
const slots = []
for (let i = 0; i < 4; i++) {
const el2 = $('#slot-tpl').content.firstElementChild.cloneNode(true)
$('.slot-n', el2).textContent = 'Slot ' + (i + 1)
// The slot as the user is editing it. V is the brightness: the picker's
// value bar and the row's slider are the same control.
const row = {
el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null,
claim: 'idle', // proto 3: idle | asking | held
wantSend: false, pointerDown: false, releaseTimer: null
}
const level = $('.level', el2)
const panel = $('.slot-picker', el2)
const swatch = $('.swatch', el2)
const key = id + ':' + i
const shared = () => { const u = unitState(id); return u && u.proto >= 3 }
const sendValue = () => {
row.wantSend = false
sendLatest(key, base + '/slot', { index: i, session: SESSION, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) },
(err) => {
// The unit let go (idle / 30 s max / reconnect): grab again on the next change.
if (err.status === 409) row.claim = 'idle'
})
}
const release = () => {
clearTimeout(row.releaseTimer)
if (row.claim !== 'held') return
row.claim = 'idle'
afterSends(key, () => post(base + '/release', { index: i, session: SESSION }).catch(() => {}))
}
// Take the slot (proto 3), then send. First touch asks once; frames that
// arrive meanwhile are kept as the latest and sent when it's granted.
const claim = () => {
if (row.claim !== 'idle') return
row.claim = 'asking'
post(base + '/grab', { index: i, session: SESSION, name: myName }).then(() => {
row.claim = 'held'
if (row.wantSend) sendValue()
if (!row.pointerDown) armRelease()
}, (err) => {
row.claim = 'idle'
refused(row, err.body && err.body.busy ? err.body.busy : err.message)
})
}
// Let go after a second without changes (keyboard input, or a lifted
// finger the browser didn't report) -- well inside the unit's 2 s idle.
const armRelease = () => {
clearTimeout(row.releaseTimer)
row.releaseTimer = setTimeout(release, 1000)
}
const changed = () => {
paintSlot(row)
if (!shared()) return sendValue() // proto 2: no grants
if (!row.pointerDown) armRelease()
if (row.claim === 'held') return sendValue()
row.wantSend = true
claim()
}
const start = () => {
row.pointerDown = true
clearTimeout(row.releaseTimer)
if (shared()) claim()
}
const end = () => {
row.pointerDown = false
if (shared()) release()
}
level.addEventListener('pointerdown', start)
level.addEventListener('pointerup', end)
level.addEventListener('pointercancel', end)
level.addEventListener('input', () => {
row.hsv = { ...row.hsv, v: Number(level.value) / 100 }
if (row.picker) row.picker.set(row.hsv)
changed()
})
swatch.onclick = () => {
const opening = panel.hidden
closePicker()
if (!opening) return
if (!row.picker) {
row.picker = new Picker(panel, {
onChange: (hsv) => {
row.hsv = hsv
changed()
},
onStart: start,
onEnd: end
})
}
row.picker.set(row.hsv)
panel.hidden = false
swatch.classList.add('open')
openRow = row
}
$('.slots', el).appendChild(el2)
slots.push(row)
}
$('.enabled', el).addEventListener('change', (ev) => sendLatest(id + ':enabled', base, { enabled: ev.target.checked }))
$('.fade', el).addEventListener('change', (ev) => sendLatest(id + ':fade', base, { fadeMs: Number(ev.target.value) }))
$('.label-in', el).addEventListener('change', (ev) => sendLatest(id + ':label', base, { label: ev.target.value }))
$('.redmax', el).addEventListener('change', (ev) => sendLatest(id + ':redmax', base, { redMax: Number(ev.target.value) }))
for (const k of ['gb', 'gg', 'gr']) {
$('.' + k, el).addEventListener('change', () => sendLatest(id + ':gamma', base, {
gamma: { b: Number($('.gb', el).value), g: Number($('.gg', el).value), r: Number($('.gr', el).value) }
}))
}
$('.freq', el).addEventListener('change', (ev) => sendLatest(id + ':freq', base, { freq: Number(ev.target.value) }))
$('.push-settings', el).onclick = act(() => post(base + '/settings'))
$('.adopt', el).onclick = act(() => post(base + '/adopt'))
$('.blackout', el).onclick = act(() => post(base + '/blackout'))
return { el, slots }
}
function updateCard (card, u, st) {
const el = card.el
el.classList.toggle('off', !u.enabled)
$('.label', el).textContent = u.label
$('.enabled', el).checked = u.enabled
const [words, cls] = stateWords(st, u)
const status = $('.status', el)
status.textContent = words
status.className = 'status ' + cls
status.title = u.error || ''
const where = u.online ? 'on the network at ' + u.addr : (u.manual ? 'added by hand: ' + u.host : 'not seen on the network')
$('.where', el).textContent = u.id + ' — ' + where
const note = $('.note', el)
const oldFw = u.online && u.proto < 2
note.hidden = !oldFw && u.slots !== null
note.textContent = oldFw
? 'Older firmware: no readback, so the dots show nothing.'
: 'No look yet: include this unit and connect, and it will pick up what the light is showing now.'
const actual = u.actual ? u.actual.values : null
for (let i = 0; i < 4; i++) {
const row = card.slots[i]
const s = u.slots ? u.slots[i] : null
row.el.hidden = !u.slots
const hold = u.holds ? u.holds[i] : null
const other = !!hold && hold.session !== SESSION
const mine = !!hold && hold.session === SESSION
if (other && row.claim === 'held') row.claim = 'idle' // the unit gave it away
// Someone else's slot: no input, and the controls mirror the lamp live.
row.el.classList.toggle('held-other', other)
$('.level', row.el).disabled = other
if (row.picker) row.picker.setEnabled(!other)
const tag = $('.slot-holder', row.el)
tag.hidden = !hold
tag.textContent = other ? 'held by ' + hold.holder : (mine ? 'you have this slot' : '')
// Skip the echo while the user is moving this slot's controls, or an
// older value from the server would yank the handle back mid-drag.
const busy = row.pointerDown || document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging)
const shown = other && actual ? slotFromValues(actual, i, u.redMax) : s
if (shown && !busy) {
row.hsv = slotToHsv(shown, row.hsv.h)
paintSlot(row)
if (row.picker) row.picker.set(row.hsv)
}
$('.actual', row.el).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent'
}
setValue($('.fade', el), u.fadeMs)
setValue($('.label-in', el), u.label)
setValue($('.redmax', el), u.redMax)
setValue($('.gb', el), u.gamma.b)
setValue($('.gg', el), u.gamma.g)
setValue($('.gr', el), u.gamma.r)
setValue($('.freq', el), u.freq)
const live = st.connected && u.status === 'connected'
$('.push-settings', el).disabled = !live
$('.adopt', el).disabled = !u.actual
}
function unitState (id) {
return state ? state.units.find((u) => u.id === id) : null
}
// A grab was refused: put the controls back to what the lamp shows and say why.
function refused (row, who) {
row.wantSend = false
const tag = $('.slot-holder', row.el)
tag.hidden = false
tag.textContent = 'held by ' + who
if (state) render(state)
}
// One picker open at a time, page-wide.
let openRow = null
function closePicker () {
if (!openRow) return
$('.slot-picker', openRow.el).hidden = true
$('.swatch', openRow.el).classList.remove('open')
openRow = null
}
function paintSlot (row) {
const { h, s, v } = row.hsv
$('.swatch', row.el).style.background = toHex(hsvToRgb(h, s, v))
setValue($('.level', row.el), Math.round(v * 100))
$('.pct', row.el).textContent = Math.round(v * 100) + '%'
}
// ---- wiring -------------------------------------------------------------------
$('#my-name').value = myName
$('#my-name').addEventListener('change', (ev) => {
myName = ev.target.value.trim() || myName
ev.target.value = myName
try { localStorage.setItem('cthuloid.name', myName) } catch {}
})
$('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) }))
$('#blackout-all').onclick = act(() => post('/api/blackout'))
$('#save-look').onsubmit = act(async () => {
await post('/api/looks', { name: $('#look-name').value })
$('#look-name').value = ''
})
$('#add-unit').onsubmit = act(async () => {
const out = $('#add-result')
out.textContent = 'Asking the unit its name…'
try {
const r = await post('/api/units', { host: $('#add-host').value })
out.textContent = 'Added ' + r.id + '.'
$('#add-host').value = ''
} catch (err) {
out.textContent = err.message
}
})
function listen () {
const es = new EventSource('/api/events')
es.addEventListener('state', (ev) => {
$('#offline').hidden = true
render(JSON.parse(ev.data))
})
// EventSource reconnects by itself; just say so meanwhile.
es.onerror = () => { $('#offline').hidden = false }
}
listen()