OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / 6d4b761

webui 0.2.0: shared control in the page
On a proto-3 unit a slot is taken on first touch (pointer-down or first change), frames go only while it's held, and it is let go on pointer-up or after 1 s without change (inside the unit's 2 s idle). The release waits for the slot's last value request, so the final colour can't be overtaken. A refused grab snaps back and says who holds it. Someone else's slot is greyed, its controls disabled, and it mirrors the lamp live. "You" field + a per-browser session id; proto-2 units behave as before. Exercised with two real Chromium browsers (separate profiles) over CDP against a fake proto-3 unit: 8/8 -- bob sees "held by alice" and cannot move it while she drags; after she lets go he can; the unit saw exactly grant/release/grant/release with no refused frames. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6d4b761435242debcb76db49bc5e0a9e0f359786
by plankatron <fleeky@gmail.com> · 2026-10-02T15:16:01.000Z

Changed files (7) +299 -19

webui/package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "cthuloid-web",
3 "version": "0.1.0",
3 "version": "0.2.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "cthuloid-web",
9 "version": "0.1.0",
9 "version": "0.2.0",
1010 "dependencies": {
1111 "bare-events": "^2.9.2",
1212 "bare-fs": "^4.8.1",
webui/package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "cthuloid-web",
3 "version": "0.1.1",
3 "version": "0.2.0",
44 "description": "Web control for cthuloid ESP32 light units (esp324xlight) \u2014 discovers units over mDNS and drives them from a browser on the LAN.",
55 "type": "module",
66 "main": "index.js",
webui/static/app.css +7 -0
……@@ -133,3 +133,10 @@ main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; }
133133.looks li span { flex: 1; overflow-wrap: anywhere; }
134134.muted { color: var(--muted); }
135135.small { font-size: .85rem; }
136
137/* Shared control (firmware proto 3) */
138.me { display: flex; align-items: center; gap: .35rem; font-size: .85rem; color: var(--muted); }
139.me input { width: 8.5rem; }
140.slot.held-other .slot-row { opacity: .55; }
141.slot-holder { margin: .15rem 0 0 3.7rem; color: var(--warn); }
142.picker.disabled { opacity: .55; pointer-events: none; }
webui/static/app.js +154 -13
……@@ -5,6 +5,24 @@ import { Picker } from './picker.js'
55
66const $ = (sel, el) => (el || document).querySelector(sel)
77
8// ---- who is at this page ------------------------------------------------------
9// A session id per browser (shared by its tabs: same person) and a display name.
10// Units with shared control (firmware proto 3) show the name to everyone while
11// this browser holds a slot. Storage can be unavailable (private mode): then
12// both just last for this page load.
13
14function stored (key, make) {
15 try {
16 let v = localStorage.getItem(key)
17 if (!v) { v = make(); localStorage.setItem(key, v) }
18 return v
19 } catch {
20 return make()
21 }
22}
23const SESSION = stored('cthuloid.session', () => Math.random().toString(36).slice(2, 10))
24let myName = stored('cthuloid.name', () => 'web-' + SESSION.slice(0, 4))
25
826// ---- talking to the server --------------------------------------------------
927
1028async function post (path, body) {
……@@ -16,7 +34,12 @@ async function post (path, body) {
1634 body: JSON.stringify(body || {})
1735 })
1836 const out = await res.json().catch(() => ({}))
19 if (!res.ok) throw new Error(out.error || res.statusText)
37 if (!res.ok) {
38 const err = new Error(out.error || (out.busy ? 'held by ' + out.busy : res.statusText))
39 err.status = res.status
40 err.body = out
41 throw err
42 }
2043 return out
2144}
2245
……@@ -24,15 +47,27 @@ async function post (path, body) {
2447// newest value is sent next. A drag can't queue a backlog, and a slow reply
2548// can never land an older value after a newer one.
2649const senders = new Map()
27function sendLatest (key, path, body) {
50function sender (key) {
2851 let s = senders.get(key)
2952 if (!s) {
30 s = { busy: false, next: null }
53 s = { busy: false, next: null, after: [], onError: null }
3154 senders.set(key, s)
3255 }
56 return s
57}
58function sendLatest (key, path, body, onError = null) {
59 const s = sender(key)
3360 s.next = { path, body }
61 s.onError = onError
3462 if (!s.busy) pump(s)
3563}
64// Run fn once everything queued on `key` has been sent -- e.g. a release must
65// follow the slot's last value, never overtake it.
66function afterSends (key, fn) {
67 const s = sender(key)
68 if (!s.busy && !s.next) return fn()
69 s.after.push(fn)
70}
3671async function pump (s) {
3772 s.busy = true
3873 while (s.next) {
……@@ -41,10 +76,13 @@ async function pump (s) {
4176 try {
4277 await post(path, body)
4378 } catch (err) {
44 console.warn(path, err.message)
79 if (s.onError) s.onError(err)
80 else console.warn(path, err.message)
4581 }
4682 }
4783 s.busy = false
84 const after = s.after.splice(0)
85 for (const fn of after) fn()
4886}
4987
5088function act (fn) {
……@@ -65,6 +103,17 @@ function actualCss (values, i, redMax) {
65103 return 'rgb(' + [r, g, b].map((v) => Math.round(v * 255)).join(',') + ')'
66104}
67105
106// A slot's colour from the unit's live values (BGR, red capped), for mirroring a
107// slot someone else is moving.
108function slotFromValues (v, i, redMax) {
109 const red = Math.min(redMax, 45000) || 1
110 const b = v[i * 3] / 65535
111 const g = v[i * 3 + 1] / 65535
112 const r = Math.min(1, v[i * 3 + 2] / red)
113 const level = Math.max(r, g, b)
114 return level > 0 ? { r: r / level, g: g / level, b: b / level, level } : { r: 1, g: 1, b: 1, level: 0 }
115}
116
68117// Same words the Blender addon uses, so the two read alike.
69118function stateWords (st, u) {
70119 if (!u.enabled) return ['off', 'off']
……@@ -152,18 +201,76 @@ function makeCard (id) {
152201 $('.slot-n', el2).textContent = 'Slot ' + (i + 1)
153202 // The slot as the user is editing it. V is the brightness: the picker's
154203 // value bar and the row's slider are the same control.
155 const row = { el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null }
204 const row = {
205 el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null,
206 claim: 'idle', // proto 3: idle | asking | held
207 wantSend: false, pointerDown: false, releaseTimer: null
208 }
156209 const level = $('.level', el2)
157210 const panel = $('.slot-picker', el2)
158211 const swatch = $('.swatch', el2)
159 const send = () => {
212 const key = id + ':' + i
213 const shared = () => { const u = unitState(id); return u && u.proto >= 3 }
214
215 const sendValue = () => {
216 row.wantSend = false
217 sendLatest(key, base + '/slot', { index: i, session: SESSION, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) },
218 (err) => {
219 // The unit let go (idle / 30 s max / reconnect): grab again on the next change.
220 if (err.status === 409) row.claim = 'idle'
221 })
222 }
223 const release = () => {
224 clearTimeout(row.releaseTimer)
225 if (row.claim !== 'held') return
226 row.claim = 'idle'
227 afterSends(key, () => post(base + '/release', { index: i, session: SESSION }).catch(() => {}))
228 }
229 // Take the slot (proto 3), then send. First touch asks once; frames that
230 // arrive meanwhile are kept as the latest and sent when it's granted.
231 const claim = () => {
232 if (row.claim !== 'idle') return
233 row.claim = 'asking'
234 post(base + '/grab', { index: i, session: SESSION, name: myName }).then(() => {
235 row.claim = 'held'
236 if (row.wantSend) sendValue()
237 if (!row.pointerDown) armRelease()
238 }, (err) => {
239 row.claim = 'idle'
240 refused(row, err.body && err.body.busy ? err.body.busy : err.message)
241 })
242 }
243 // Let go after a second without changes (keyboard input, or a lifted
244 // finger the browser didn't report) -- well inside the unit's 2 s idle.
245 const armRelease = () => {
246 clearTimeout(row.releaseTimer)
247 row.releaseTimer = setTimeout(release, 1000)
248 }
249 const changed = () => {
160250 paintSlot(row)
161 sendLatest(id + ':' + i, base + '/slot', { index: i, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) })
251 if (!shared()) return sendValue() // proto 2: no grants
252 if (!row.pointerDown) armRelease()
253 if (row.claim === 'held') return sendValue()
254 row.wantSend = true
255 claim()
162256 }
257 const start = () => {
258 row.pointerDown = true
259 clearTimeout(row.releaseTimer)
260 if (shared()) claim()
261 }
262 const end = () => {
263 row.pointerDown = false
264 if (shared()) release()
265 }
266
267 level.addEventListener('pointerdown', start)
268 level.addEventListener('pointerup', end)
269 level.addEventListener('pointercancel', end)
163270 level.addEventListener('input', () => {
164271 row.hsv = { ...row.hsv, v: Number(level.value) / 100 }
165272 if (row.picker) row.picker.set(row.hsv)
166 send()
273 changed()
167274 })
168275 swatch.onclick = () => {
169276 const opening = panel.hidden
……@@ -173,8 +280,10 @@ function makeCard (id) {
173280 row.picker = new Picker(panel, {
174281 onChange: (hsv) => {
175282 row.hsv = hsv
176 send()
177 }
283 changed()
284 },
285 onStart: start,
286 onEnd: end
178287 })
179288 }
180289 row.picker.set(row.hsv)
……@@ -228,11 +337,23 @@ function updateCard (card, u, st) {
228337 const row = card.slots[i]
229338 const s = u.slots ? u.slots[i] : null
230339 row.el.hidden = !u.slots
340 const hold = u.holds ? u.holds[i] : null
341 const other = !!hold && hold.session !== SESSION
342 const mine = !!hold && hold.session === SESSION
343 if (other && row.claim === 'held') row.claim = 'idle' // the unit gave it away
344 // Someone else's slot: no input, and the controls mirror the lamp live.
345 row.el.classList.toggle('held-other', other)
346 $('.level', row.el).disabled = other
347 if (row.picker) row.picker.setEnabled(!other)
348 const tag = $('.slot-holder', row.el)
349 tag.hidden = !hold
350 tag.textContent = other ? 'held by ' + hold.holder : (mine ? 'you have this slot' : '')
231351 // Skip the echo while the user is moving this slot's controls, or an
232352 // older value from the server would yank the handle back mid-drag.
233 const busy = document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging)
234 if (s && !busy) {
235 row.hsv = slotToHsv(s, row.hsv.h)
353 const busy = row.pointerDown || document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging)
354 const shown = other && actual ? slotFromValues(actual, i, u.redMax) : s
355 if (shown && !busy) {
356 row.hsv = slotToHsv(shown, row.hsv.h)
236357 paintSlot(row)
237358 if (row.picker) row.picker.set(row.hsv)
238359 }
……@@ -251,6 +372,19 @@ function updateCard (card, u, st) {
251372 $('.adopt', el).disabled = !u.actual
252373}
253374
375function unitState (id) {
376 return state ? state.units.find((u) => u.id === id) : null
377}
378
379// A grab was refused: put the controls back to what the lamp shows and say why.
380function refused (row, who) {
381 row.wantSend = false
382 const tag = $('.slot-holder', row.el)
383 tag.hidden = false
384 tag.textContent = 'held by ' + who
385 if (state) render(state)
386}
387
254388// One picker open at a time, page-wide.
255389let openRow = null
256390function closePicker () {
……@@ -269,6 +403,13 @@ function paintSlot (row) {
269403
270404// ---- wiring -------------------------------------------------------------------
271405
406$('#my-name').value = myName
407$('#my-name').addEventListener('change', (ev) => {
408 myName = ev.target.value.trim() || myName
409 ev.target.value = myName
410 try { localStorage.setItem('cthuloid.name', myName) } catch {}
411})
412
272413$('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) }))
273414$('#blackout-all').onclick = act(() => post('/api/blackout'))
274415$('#save-look').onsubmit = act(async () => {
webui/static/index.html +2 -0
……@@ -10,6 +10,7 @@
1010<header class="bar">
1111 <h1>Cthuloid Lights</h1>
1212 <div class="bar-actions">
13 <label class="me" title="Shown to others while you hold a slot">You <input id="my-name" type="text" maxlength="32" spellcheck="false"></label>
1314 <button id="connect" class="connect" type="button">CONNECT</button>
1415 <button id="blackout-all" class="danger" type="button" title="Stop fades and drive every unit to zero, connected or not">Blackout all</button>
1516 </div>
……@@ -82,6 +83,7 @@
8283 <span class="pct small"></span>
8384 <span class="actual" title="What the unit is actually showing"></span>
8485 </div>
86 <div class="slot-holder small" hidden></div>
8587 <div class="slot-picker" hidden></div>
8688 </div>
8789</template>
webui/static/picker.js +21 -3
……@@ -2,8 +2,12 @@
22// and RGB / HSV / Hex fields underneath. Pointer events, so mouse and touch
33// both drag. Values are 0..1 throughout, as in Blender.
44//
5// const p = new Picker(el, { onChange: ({ h, s, v }) => ... })
5// const p = new Picker(el, { onChange: ({ h, s, v }) => ..., onStart, onEnd })
66// p.set({ h, s, v }) // from the server; ignored while the user drags
7// p.setEnabled(false) // someone else holds the slot: no input
8//
9// onStart / onEnd bracket a wheel or value-bar drag, so the page can take the
10// slot's grant for exactly as long as the pointer is down.
711
812import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js'
913
……@@ -41,8 +45,11 @@ function wheelBitmap (size) {
4145}
4246
4347export class Picker {
44 constructor (root, { onChange }) {
48 constructor (root, { onChange, onStart = () => {}, onEnd = () => {} }) {
4549 this.onChange = onChange
50 this.onStart = onStart
51 this.onEnd = onEnd
52 this.enabled = true
4653 this.h = 0
4754 this.s = 0
4855 this.v = 1
……@@ -82,6 +89,12 @@ export class Picker {
8289 this._draw()
8390 }
8491
92 setEnabled (on) {
93 this.enabled = on
94 this.root.classList.toggle('disabled', !on)
95 for (const el of this.root.querySelectorAll('input, button')) el.disabled = !on
96 }
97
8598 set ({ h, s, v }) {
8699 if (this.dragging) return
87100 this.h = h
……@@ -95,8 +108,10 @@ export class Picker {
95108 _drag (el, apply) {
96109 el.addEventListener('pointerdown', (e) => {
97110 e.preventDefault()
111 if (!this.enabled) return
98112 el.setPointerCapture(e.pointerId)
99113 this.dragging = true
114 this.onStart()
100115 apply(e)
101116 })
102117 el.addEventListener('pointermove', (e) => {
……@@ -104,7 +119,10 @@ export class Picker {
104119 })
105120 const end = (e) => {
106121 if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
107 this.dragging = false
122 if (this.dragging) {
123 this.dragging = false
124 this.onEnd()
125 }
108126 }
109127 el.addEventListener('pointerup', end)
110128 el.addEventListener('pointercancel', end)
webui/test/browser-shared-control.mjs +112 -0
……@@ -0,0 +1,112 @@
1// Manual browser test (Node + Chromium over CDP), not part of `npm test`:
2// needs a fake proto-3 unit on :1339 and this server on :4052 with it included
3// and connected. Usage: node test/browser-shared-control.mjs <dir-with-fake3-lines.txt>
4// Two real Chromium browsers (separate profiles => separate session ids) drive
5// the page with real mouse events, against a local server + fake proto-3 unit.
6import { spawn } from 'node:child_process'
7import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'
8
9const OUT = process.argv[2]
10const URL = 'http://127.0.0.1:4052/'
11const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
12const results = []
13const check = (name, ok, detail = '') => { results.push(ok); console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok ? '' : ' ' + detail)) }
14
15async function browser (port, label) {
16 const prof = process.env.HOME + '/.cache/cthuloid-shots/prof-' + label + '-' + Date.now()
17 mkdirSync(prof, { recursive: true })
18 const proc = spawn('chromium', ['--headless=new', '--disable-gpu', '--remote-debugging-port=' + port,
19 '--user-data-dir=' + prof, '--window-size=1100,1000', 'about:blank'], { stdio: 'ignore' })
20 let target
21 for (let i = 0; i < 50 && !target; i++) {
22 await sleep(200)
23 try { target = (await (await fetch('http://127.0.0.1:' + port + '/json')).json()).find((t) => t.type === 'page') } catch {}
24 }
25 const ws = new WebSocket(target.webSocketDebuggerUrl)
26 await new Promise((r) => ws.addEventListener('open', r))
27 let id = 0
28 const pending = new Map()
29 ws.addEventListener('message', (ev) => {
30 const m = JSON.parse(ev.data)
31 if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
32 })
33 const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
34 const b = {
35 label,
36 send,
37 eval: async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result.result.value,
38 mouse: (type, x, y) => send('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1, pointerType: 'mouse' }),
39 shot: async (name) => { const r = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(OUT + '/' + name, Buffer.from(r.result.data, 'base64')) },
40 box: async (sel) => b.eval(`(() => { const e = [...document.querySelectorAll(${JSON.stringify(sel)})].find(x => x.offsetParent); if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height } })()`),
41 close: () => { ws.close(); proc.kill() }
42 }
43 await send('Page.enable')
44 await send('Runtime.enable')
45 await send('Page.navigate', { url: URL })
46 await sleep(2000)
47 return b
48}
49
50const card = '.unit:not(.off)'
51const slot1 = `[...document.querySelectorAll('${card} .slot')][0]`
52
53const alice = await browser(9341, 'alice')
54const bob = await browser(9342, 'bob')
55for (const [b, name] of [[alice, 'alice'], [bob, 'bob']]) {
56 await b.eval(`(() => { const i = document.querySelector('#my-name'); i.value = '${name}'; i.dispatchEvent(new Event('change')); return 1 })()`)
57}
58
59// Alice opens slot 1's picker and starts dragging the wheel -- and keeps holding.
60const sw = await alice.box(`${card} .swatch`)
61await alice.mouse('mousePressed', sw.x + sw.w / 2, sw.y + sw.h / 2)
62await alice.mouse('mouseReleased', sw.x + sw.w / 2, sw.y + sw.h / 2)
63await sleep(300)
64const wheel = await alice.box('.pk-wheel')
65const cx = wheel.x + wheel.w / 2
66const cy = wheel.y + wheel.h / 2
67await alice.mouse('mousePressed', cx, cy)
68for (let k = 1; k <= 8; k++) { await alice.mouse('mouseMoved', cx, cy - (wheel.h / 2 - 2) * k / 8); await sleep(40) }
69await sleep(700) // still pressed
70
71const bobView = await bob.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent, disabled: r.querySelector('.level').disabled } })()`)
72check("bob sees slot 1 held by alice while she drags", bobView.held && bobView.tag === 'held by alice' && bobView.disabled, JSON.stringify(bobView))
73const aliceView = await alice.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent } })()`)
74check('alice sees it as hers', !aliceView.held && aliceView.tag === 'you have this slot', JSON.stringify(aliceView))
75await bob.shot('p2b-bob-greyed.png')
76
77// Bob tries to drag the slot's brightness anyway: the control is disabled.
78const lv = await bob.box(`${card} .level`)
79await bob.mouse('mousePressed', lv.x + 5, lv.y + lv.h / 2)
80await bob.mouse('mouseMoved', lv.x + lv.w - 5, lv.y + lv.h / 2)
81await bob.mouse('mouseReleased', lv.x + lv.w - 5, lv.y + lv.h / 2)
82await sleep(400)
83
84// Alice lets go.
85await alice.mouse('mouseReleased', cx, cy - wheel.h / 2 + 2)
86await sleep(1000)
87const bobAfter = await bob.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), disabled: r.querySelector('.level').disabled } })()`)
88check('after alice lets go, bob can use slot 1', !bobAfter.held && !bobAfter.disabled, JSON.stringify(bobAfter))
89
90// Now bob drags slot 1's brightness: alice sees it held by bob.
91await bob.mouse('mousePressed', lv.x + lv.w - 5, lv.y + lv.h / 2)
92for (let k = 1; k <= 6; k++) { await bob.mouse('mouseMoved', lv.x + lv.w - 5 - (lv.w - 10) * k / 12, lv.y + lv.h / 2); await sleep(40) }
93await sleep(600)
94const aliceSees = await alice.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent } })()`)
95check('alice sees slot 1 held by bob while he drags', aliceSees.held && aliceSees.tag === 'held by bob', JSON.stringify(aliceSees))
96await bob.mouse('mouseReleased', lv.x + lv.w / 2, lv.y + lv.h / 2)
97await sleep(1200)
98
99const lines = readFileSync(OUT + '/fake3-lines.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1).join(' '))
100const order = lines.filter((l) => /^(grant|release)/.test(l))
101check('unit saw: alice grant, release, bob grant, release', JSON.stringify(order) === JSON.stringify(['grant 0 alice', 'release 0', 'grant 0 bob', 'release 0']), JSON.stringify(order))
102const errs = lines.filter((l) => l.startsWith('ERR'))
103check('no refused frames', errs.length === 0, JSON.stringify(errs))
104const frames = lines.filter((l) => l.startsWith('slot 0'))
105check('drag frames reached the unit', frames.length >= 2, String(frames.length))
106const ownerAfter = await alice.eval(`(() => { const r = ${slot1}; return r.classList.contains('held-other') || r.querySelector('.slot-holder').hidden === false })()`)
107check('slot 1 free again everywhere', ownerAfter === false)
108
109alice.close()
110bob.close()
111console.log(results.every(Boolean) ? 'ALL PASS' : 'FAILURES')
112process.exit(results.every(Boolean) ? 0 : 1)