OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / 8dfb4c1

webui: cthuloid-web, a Bare/pgh web UI to drive the light units
Runs on stortron at :4050 (LAN, no login). Finds units over mDNS (_cthuloid._tcp via avahi-browse), one Connect switch like the addon, per-unit independent connections (latest-state, rate-limited, keepalive, backoff), and adopts a unit's actual look via `state` on first connect instead of overwriting it. Slots, fade, blackout, gamma/freq/red cap, saved looks; live updates over SSE. Protocol is a byte-identical port of the addon's protocol.py (vectors generated from it). Deps pinned for pgh's Bare 1.28.7. 32 brittle-bare tests; verified against the real studiolight alongside Blender. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8dfb4c149255d924f659242cdc4856146f97840a
by plankatron <fleeky@gmail.com> · 2026-10-02T14:05:49.000Z

Changed files (20) +3264 -0

webui/.gitignore +1 -0
……@@ -0,0 +1 @@
1node_modules/
webui/README.md +58 -0
……@@ -0,0 +1,58 @@
1# cthuloid-web
2
3Web control for cthuloid light units (`esp324xlight` firmware). A Bare app,
4deployed with pgh, meant to run on stortron and be used from any browser on the
5LAN at **http://stortron.local:4050**.
6
7## Behaviour
8
9Same model as the Blender addon (`../blender-addon`), so the two read alike:
10
11- **One Connect switch.** The server starts disconnected on every launch — no
12 sockets, nothing sent. Connect opens the ticked units; Disconnect closes every
13 socket and stops every retry. The button is red while connected.
14- **Units find themselves.** Firmware with command-surface proto ≥ 2 advertises
15 `_cthuloid._tcp` on port 1337; the server follows it through the host's avahi
16 (`avahi-browse`, event-driven). Older units are added by hostname or IP.
17- **New units start unticked**, and the first time one connects the page
18 **adopts what the lamp is actually showing** (firmware `state` readback)
19 instead of pushing over it — so joining a rig Blender is driving changes
20 nothing until you move a control.
21- Each unit has its own connection: an unplugged unit never delays another.
22 Latest state only, ≤ 25 sends/s per unit, identical frames skipped,
23 keepalive, reconnect with backoff (1 → 15 s).
24- Per unit: 4 slot colours + brightness, fade time, Blackout, "Match lights",
25 gamma/frequency/red cap. Looks: save all units' colours, recall with a fade.
26- Blackout works connected or not (disconnected: one-shot, then lets go).
27
28LAN-only with no login, by decision. POSTs must carry `x-cthuloid: 1` with a
29JSON body, which a cross-site page cannot send without a CORS preflight that this
30server never answers.
31
32Settings and looks live in `~/.config/cthuloid-web/config.json`.
33
34## Run / test
35
36```bash
37npm install
38npm test # brittle-bare, 32 tests
39bare index.js # 127.0.0.1:4050 by default
40CTHULOID_WEB_HOST=0.0.0.0 bare index.js
41```
42
43Env: `CTHULOID_WEB_PORT` (4050), `CTHULOID_WEB_HOST` (127.0.0.1; the pgh
44defaults set 0.0.0.0), `CTHULOID_WEB_CONFIG` (config dir).
45
46Dependencies are pinned for the deployed runtime, pgh's Bare **1.28.7**: newer
47`bare-type` / `bare-structured-clone` / `bare-assert` declare `bare >= 1.32` and
48refuse to load (see `overrides` in package.json).
49
50## Deploy (pgh)
51
52```bash
53pgh publish . # from this directory
54# on stortron:
55pgh app install pgh://<registry-key>/cthuloid-web --name cthuloid-web
56pgh app start cthuloid-web
57pgh app autostart cthuloid-web on
58```
webui/index.js +45 -0
……@@ -0,0 +1,45 @@
1// cthuloid-web — `bare index.js`. Web control for cthuloid light units.
2//
3// Finds units over mDNS (_cthuloid._tcp, via the host's avahi), serves one page
4// on the LAN, and drives the units the user includes, the same way the Blender
5// addon does. Starts disconnected on every launch: nothing is sent to a light
6// until someone presses Connect.
7//
8// ⛔ LAN-only, no login, by decision (2026-10-02). The pgh defaults bind
9// 0.0.0.0:4050; the code default is 127.0.0.1 so a bare `bare index.js` on a
10// dev machine never exposes the lights by accident.
11
12import os from 'bare-os'
13import path from 'bare-path'
14import process from 'bare-process'
15import { Store } from './lib/store.js'
16import { Discovery } from './lib/discovery.js'
17import { Rig } from './lib/rig.js'
18import { createServer } from './lib/server.js'
19
20const port = Number(process.env.CTHULOID_WEB_PORT || 4050)
21const host = process.env.CTHULOID_WEB_HOST || '127.0.0.1'
22const configDir = process.env.CTHULOID_WEB_CONFIG || path.join(os.homedir(), '.config', 'cthuloid-web')
23
24const store = new Store(configDir)
25const discovery = new Discovery()
26const rig = new Rig({ store, discovery })
27
28discovery.on('unavailable', (reason) => console.error(JSON.stringify({ msg: 'discovery unavailable', reason })))
29discovery.on('up', (u) => console.log(JSON.stringify({ msg: 'unit up', name: u.name, addr: u.addr, proto: u.proto })))
30discovery.on('down', (u) => console.log(JSON.stringify({ msg: 'unit down', name: u.name })))
31
32rig.start()
33const server = createServer(rig)
34server.listen(port, host, () => {
35 console.log(JSON.stringify({ msg: 'cthuloid-web up', url: 'http://' + host + ':' + port, config: configDir }))
36})
37
38// Settings are written on a short debounce; flush them on the way out.
39const shutdown = () => {
40 rig.stop()
41 server.close()
42 process.exit(0)
43}
44process.on('SIGTERM', shutdown)
45process.on('SIGINT', shutdown)
webui/lib/discovery.js +125 -0
……@@ -0,0 +1,125 @@
1// Unit discovery over mDNS, via the host's avahi.
2//
3// Units (firmware proto >= 2) advertise _cthuloid._tcp on their command port.
4// That name is specific to these lights, so no probe connection is needed to
5// tell a unit from any other device -- which matters, because a probe to a
6// unit on old single-client firmware would evict whoever is driving it.
7// Units on older firmware advertise only telnet/23 and are added by hand.
8//
9// `avahi-browse` without -t keeps running and prints every change, so this is
10// event-driven: no polling. bare-mdns-discovery exists but labels itself an
11// experimental generated prototype; the system avahi is already resolving
12// these units on stortron.
13
14import EventEmitter from 'bare-events'
15import { spawn } from 'bare-subprocess'
16
17export const DEFAULT_COMMAND = ['avahi-browse', '--resolve', '--parsable', '--no-db-lookup', '_cthuloid._tcp']
18
19// avahi's parsable output escapes bytes as \DDD (decimal) and specials as \c.
20function unescape (s) {
21 return s.replace(/\\(\d{3}|.)/g, (_, e) => (e.length === 3 ? String.fromCharCode(Number(e)) : e))
22}
23
24// '=' resolved, '-' removed; '+' (seen, not yet resolved) is ignored because
25// it carries no address. IPv4 only: the units speak IPv4.
26export function parseAvahiLine (line) {
27 const f = line.split(';')
28 if (f[0] === '-' && f.length >= 6) {
29 if (f[2] !== 'IPv4') return null
30 return { type: 'down', name: unescape(f[3]) }
31 }
32 if (f[0] !== '=' || f.length < 10 || f[2] !== 'IPv4') return null
33 const port = Number(f[8])
34 if (!Number.isInteger(port)) return null
35 const txt = f.slice(9).join(';')
36 const m = /"proto=(\d+)"/.exec(txt)
37 return {
38 type: 'up',
39 name: unescape(f[3]),
40 host: unescape(f[6]),
41 addr: f[7],
42 port,
43 proto: m ? Number(m[1]) : 0
44 }
45}
46
47export class Discovery extends EventEmitter {
48 constructor ({ command = DEFAULT_COMMAND, respawnMs = 5000 } = {}) {
49 super()
50 this.command = command
51 this.respawnMs = respawnMs
52 this.available = true
53 this.reason = ''
54 this._proc = null
55 this._running = false
56 this._timer = null
57 this._buf = ''
58 }
59
60 start () {
61 if (this._running) return
62 this._running = true
63 this._spawn()
64 }
65
66 stop () {
67 this._running = false
68 clearTimeout(this._timer)
69 this._timer = null
70 const p = this._proc
71 this._proc = null
72 if (p) {
73 try { p.kill() } catch {}
74 }
75 }
76
77 _spawn () {
78 this._buf = ''
79 // Launched via `sh -c 'exec "$0" "$@"'` rather than directly. In
80 // bare-subprocess 6.1.0 a spawn of a missing binary throws ENOENT AFTER
81 // creating its stdio pipes and never closes them, so the process can then
82 // never exit. sh always exists; exec replaces it with the real command (no
83 // extra process); a missing command becomes exit 127, reported below.
84 let proc
85 try {
86 proc = spawn('/bin/sh', ['-c', 'exec "$0" "$@"', ...this.command], { stdio: ['ignore', 'pipe', 'pipe'] })
87 } catch (err) {
88 return this._unavailable(err.message || String(err))
89 }
90 this._proc = proc
91 let stderr = ''
92 proc.stdout.on('data', (d) => this._onData(d))
93 proc.stderr.on('data', (d) => { stderr += d })
94 proc.on('error', (err) => this._unavailable(err.message || String(err)))
95 proc.on('exit', (code) => {
96 if (this._proc !== proc) return
97 this._proc = null
98 // avahi restarted or the daemon is down: try again, and say why meanwhile.
99 if (code !== 0) this._unavailable((stderr.trim() || 'exited with ' + code))
100 if (this._running) this._timer = setTimeout(() => this._running && this._spawn(), this.respawnMs)
101 })
102 }
103
104 _unavailable (reason) {
105 this.available = false
106 this.reason = reason
107 this.emit('unavailable', reason)
108 }
109
110 _onData (d) {
111 if (!this.available) {
112 this.available = true
113 this.reason = ''
114 this.emit('available')
115 }
116 this._buf += d.toString()
117 let i
118 while ((i = this._buf.indexOf('\n')) !== -1) {
119 const line = this._buf.slice(0, i)
120 this._buf = this._buf.slice(i + 1)
121 const ev = parseAvahiLine(line)
122 if (ev) this.emit(ev.type, ev)
123 }
124 }
125}
webui/lib/protocol.js +128 -0
……@@ -0,0 +1,128 @@
1// Wire protocol for the esp324xlight firmware — a port of
2// blender-addon/cthuloid_lights/protocol.py, kept byte-identical so the addon
3// and the web UI send the same command for the same look. test/protocol.test.js
4// pins it to vectors generated from the Python.
5//
6// Channel order is B, G, R per slot: the firmware caps every value at index
7// count % 3 == 2 to RED_MAX_PWM, so the third of each triple IS red. A unit is
8// 12 channels = 4 slots x 3. With INVERT_HIGH_AND_LOW in the firmware, input 0
9// is LED-off, so padding with zeros is dark.
10
11export const CHANNELS_PER_SLOT = 3
12export const SLOTS_PER_UNIT = 4
13export const NUM_CHANNELS = CHANNELS_PER_SLOT * SLOTS_PER_UNIT
14
15// parsePWM() rejects the WHOLE command if any one value exceeds these, so every
16// value is clamped before it is sent — one bad value would drop all 12.
17export const FIRMWARE_MAX_PWM = 65535
18export const FIRMWARE_RED_MAX_PWM = 45000
19
20export function clamp (v, lo, hi) {
21 return v < lo ? lo : (v > hi ? hi : v)
22}
23
24// Python's round() rounds halves to even; Math.round rounds them up. Matching
25// Python keeps the two controllers from disagreeing by one step on a .5.
26export function roundHalfEven (x) {
27 const f = Math.floor(x)
28 const d = x - f
29 if (d > 0.5) return f + 1
30 if (d < 0.5) return f
31 return f % 2 === 0 ? f : f + 1
32}
33
34export function pwmFromUnit (value, pwmMax) {
35 return clamp(roundHalfEven(Math.abs(value) * pwmMax), 0, pwmMax)
36}
37
38// [B, G, R] PWM ints from 0..1 colour channels. `level` scales all three (the
39// web UI's brightness slider); redMax is the per-unit red ceiling.
40export function channelsFromRGB (r, g, b, level, redMax) {
41 const s = clamp(level, 0, 1)
42 return [
43 pwmFromUnit(b * s, FIRMWARE_MAX_PWM),
44 pwmFromUnit(g * s, FIRMWARE_MAX_PWM),
45 pwmFromUnit(r * s, Math.min(redMax, FIRMWARE_RED_MAX_PWM))
46 ]
47}
48
49// Same conversion as protocol.py channels_from_rgbe, energy scaling included,
50// so the shared vectors can exercise it directly.
51export function channelsFromRGBE (r, g, b, energy, redMax, useEnergy, energyRef) {
52 let scale = 1
53 if (useEnergy) {
54 const ref = energyRef > 0 ? energyRef : 1
55 scale = clamp(energy / ref, 0, 1)
56 }
57 return [
58 pwmFromUnit(b * scale, FIRMWARE_MAX_PWM),
59 pwmFromUnit(g * scale, FIRMWARE_MAX_PWM),
60 pwmFromUnit(r * scale, Math.min(redMax, FIRMWARE_RED_MAX_PWM))
61 ]
62}
63
64// The 12 values for a unit from its four slots ({ r, g, b, level }).
65export function unitValues (slots, redMax) {
66 const values = []
67 for (let i = 0; i < SLOTS_PER_UNIT; i++) {
68 const s = slots[i]
69 if (!s) values.push(0, 0, 0)
70 else values.push(...channelsFromRGB(s.r, s.g, s.b, s.level, redMax))
71 }
72 return values
73}
74
75export function pwmCommand (values) {
76 return 'pwm ' + values.join(' ') + '\n'
77}
78
79// Split a fade into (numPeriods, periodMs), both within the 1..1000 parsePWM
80// accepts. The firmware interpolates on wall-clock time, so the pair only has
81// to multiply out to the intended duration.
82export function pickPwm2Args (totalMs) {
83 totalMs = Math.trunc(clamp(totalMs, 1, 1000 * 1000))
84 if (totalMs <= 1000) return [1, totalMs]
85 const periodMs = Math.floor((totalMs + 999) / 1000)
86 const numPeriods = Math.max(1, Math.floor(totalMs / periodMs))
87 return [numPeriods, periodMs]
88}
89
90export function pwm2Command (values, fadeMs) {
91 const [periods, periodMs] = pickPwm2Args(fadeMs)
92 return 'pwm2 ' + periods + ' ' + periodMs + ' ' + values.join(' ') + '\n'
93}
94
95// Firmware silently ignores any gamma outside 0.5..5.0.
96export function gammaCommand (b, g, r) {
97 const f = (v) => clamp(v, 0.5, 5.0).toFixed(2)
98 return 'gamma ' + f(b) + ' ' + f(g) + ' ' + f(r) + '\n'
99}
100
101export function freqCommand (hz) {
102 return 'freq ' + Math.trunc(clamp(hz, 1, 40000)) + '\n'
103}
104
105export function blackoutCommand () {
106 return 'stopfade\n' + pwmCommand(new Array(NUM_CHANNELS).fill(0))
107}
108
109// `state <fade> <freq> <gB> <gG> <gR> <v1..v12>` (firmware proto 2). Returns
110// null for anything else, so callers can feed it every line the unit sends.
111export function parseState (line) {
112 const parts = line.trim().split(/\s+/)
113 if (parts[0] !== 'state' || parts.length !== 6 + NUM_CHANNELS) return null
114 const nums = parts.slice(1).map(Number)
115 if (nums.some((n) => !Number.isFinite(n))) return null
116 return {
117 fading: nums[0] === 1,
118 freq: nums[1],
119 gamma: { b: nums[2], g: nums[3], r: nums[4] },
120 values: nums.slice(5)
121 }
122}
123
124// "<hostname> ready" — the greeting every unit sends on connect.
125export function parseGreeting (line) {
126 const m = /^(\S+) ready$/.exec(line.trim())
127 return m ? m[1] : null
128}
webui/lib/rig.js +373 -0
……@@ -0,0 +1,373 @@
1// The rig: every known unit, the Connect switch, and saved looks.
2//
3// Same model as the Blender addon, so the two read alike:
4// * One switch. The server starts disconnected (connection is never saved):
5// no sockets, nothing sent. Connect opens the ticked units; Disconnect
6// closes every socket and stops every retry.
7// * Unticking a unit drops its connection; ticking it while connected brings
8// it up at once.
9// * Blackout works connected or not; disconnected it is one-shot.
10//
11// Two rules of its own, because a web UI joins a rig other things are driving:
12// * A newly discovered unit starts UNTICKED. Nothing changes its look until
13// someone includes it.
14// * A unit with no look of ours yet (first connect) ADOPTS what the unit is
15// actually showing, read back with `state`, instead of pushing black over
16// whatever Blender or a person left on it.
17
18import EventEmitter from 'bare-events'
19import { Unit } from './unit.js'
20import * as P from './protocol.js'
21
22const DEFAULT_RED_MAX = 30000 // the original rig's ceiling; the addon's default
23const DEFAULT_FADE_MS = 60 // a short pwm2 smooths slider steps
24const SAVE_DELAY_MS = 300
25const CHANGE_DELAY_MS = 50
26
27function clampSlot (s) {
28 if (!s) return null
29 const c = (v) => P.clamp(Number(v) || 0, 0, 1)
30 return { r: c(s.r), g: c(s.g), b: c(s.b), level: c(s.level) }
31}
32
33// Read 12 PWM values back into four { r, g, b, level } slots: level is the
34// brightest channel, the colour is normalised against it.
35export function slotsFromValues (values, redMax) {
36 const red = Math.min(redMax, P.FIRMWARE_RED_MAX_PWM)
37 const slots = []
38 for (let i = 0; i < P.SLOTS_PER_UNIT; i++) {
39 const b = values[i * 3] / P.FIRMWARE_MAX_PWM
40 const g = values[i * 3 + 1] / P.FIRMWARE_MAX_PWM
41 const r = Math.min(1, values[i * 3 + 2] / red)
42 const level = Math.max(r, g, b)
43 slots.push(level > 0
44 ? { r: r / level, g: g / level, b: b / level, level }
45 : { r: 1, g: 1, b: 1, level: 0 })
46 }
47 return slots
48}
49
50export class Rig extends EventEmitter {
51 constructor ({ store, discovery, readbackMs = 1000, unitOpts = {} }) {
52 super()
53 this.store = store
54 this.discovery = discovery
55 this.readbackMs = readbackMs
56 this.unitOpts = unitOpts
57 this.connected = false
58 this.units = new Map()
59 this.looks = {}
60 this.viewers = 0
61 this._saveTimer = null
62 this._changeTimer = null
63 this._readbackTimer = null
64 }
65
66 // ---- lifecycle -----------------------------------------------------------
67
68 start () {
69 const data = this.store.load() || {}
70 for (const [id, cfg] of Object.entries(data.units || {})) this._addUnit(id, cfg)
71 this.looks = data.looks || {}
72 this.discovery.on('up', (ev) => this._onDiscovered(ev))
73 this.discovery.on('down', (ev) => this._onLost(ev))
74 this.discovery.on('unavailable', () => this._changed())
75 this.discovery.on('available', () => this._changed())
76 this.discovery.start()
77 this._readbackTimer = setInterval(() => this._readback(), this.readbackMs)
78 }
79
80 stop () {
81 clearInterval(this._readbackTimer)
82 this.discovery.stop()
83 for (const e of this.units.values()) e.unit.close()
84 this.flushSave()
85 clearTimeout(this._changeTimer)
86 }
87
88 // ---- units ---------------------------------------------------------------
89
90 _addUnit (id, cfg = {}) {
91 const entry = {
92 id,
93 label: cfg.label || id,
94 host: cfg.host || id + '.local', // name to dial when mDNS has no address
95 addr: null, // address from mDNS, preferred: no lookup to wait on
96 port: cfg.port || 1337,
97 manual: !!cfg.manual,
98 online: false,
99 proto: 0,
100 enabled: !!cfg.enabled,
101 redMax: cfg.redMax || DEFAULT_RED_MAX,
102 fadeMs: cfg.fadeMs === undefined ? DEFAULT_FADE_MS : cfg.fadeMs,
103 slots: Array.isArray(cfg.slots) ? cfg.slots.map(clampSlot) : null,
104 gamma: cfg.gamma || { b: 1, g: 1, r: 1 },
105 freq: cfg.freq || 1000,
106 unit: null
107 }
108 // Slots saved as all-null are "no look yet", same as never set.
109 if (entry.slots && entry.slots.every((s) => s === null)) entry.slots = null
110 entry.unit = new Unit({ id, host: entry.host, port: entry.port, ...this.unitOpts })
111 entry.unit.on('status', () => this._changed())
112 entry.unit.on('state', (st) => this._onState(entry, st))
113 this.units.set(id, entry)
114 return entry
115 }
116
117 _onDiscovered (ev) {
118 let e = this.units.get(ev.name)
119 if (!e) {
120 e = this._addUnit(ev.name, { host: ev.host, port: ev.port })
121 this._save()
122 }
123 e.online = true
124 e.proto = ev.proto
125 e.addr = ev.addr
126 e.port = ev.port
127 e.unit.host = ev.addr // dial the address: never a .local lookup in the path
128 e.unit.port = ev.port
129 this._changed()
130 }
131
132 _onLost (ev) {
133 const e = this.units.get(ev.name)
134 if (!e) return
135 e.online = false
136 this._changed()
137 }
138
139 _onState (e, st) {
140 if (e.slots === null) {
141 // First sight of this unit's look: adopt it rather than overwrite it.
142 e.slots = slotsFromValues(st.values, e.redMax)
143 this._save()
144 }
145 this._changed()
146 }
147
148 async addManual (host, port = 1337) {
149 // Ask the unit its name, so a unit added by IP and later seen on mDNS is
150 // one entry, not two. This one connection would evict a controller on old
151 // single-client firmware -- acceptable for an explicit add.
152 const probe = new Unit({ id: host, host, port, ...this.unitOpts })
153 const name = await new Promise((resolve) => {
154 const done = (n) => { clearTimeout(timer); resolve(n) }
155 const timer = setTimeout(() => done(null), 4000)
156 probe.once('greeting', done)
157 probe.sendOnce('hostname\n')
158 })
159 probe.close()
160 if (!name) throw new Error('no light unit answered at ' + host + ':' + port)
161 let e = this.units.get(name)
162 if (!e) e = this._addUnit(name, { host, port, manual: true })
163 e.host = host
164 e.manual = true
165 if (!e.online) {
166 e.unit.host = host
167 e.unit.port = port
168 }
169 this._save()
170 this._changed()
171 return name
172 }
173
174 updateUnit (id, patch) {
175 const e = this.units.get(id)
176 if (!e) throw new Error('unknown unit ' + id)
177 if (typeof patch.label === 'string') e.label = patch.label.slice(0, 64) || id
178 if (patch.redMax !== undefined) e.redMax = P.clamp(Math.trunc(Number(patch.redMax) || 0), 0, P.FIRMWARE_RED_MAX_PWM)
179 if (patch.fadeMs !== undefined) e.fadeMs = P.clamp(Math.trunc(Number(patch.fadeMs) || 0), 0, 600000)
180 if (patch.gamma) {
181 const g = (v, d) => P.clamp(Number(v) || d, 0.5, 5)
182 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) }
183 }
184 if (patch.freq !== undefined) e.freq = P.clamp(Math.trunc(Number(patch.freq) || 1000), 1, 40000)
185 let push = patch.redMax !== undefined
186 if (Array.isArray(patch.slots)) {
187 e.slots = patch.slots.slice(0, P.SLOTS_PER_UNIT).map(clampSlot)
188 while (e.slots.length < P.SLOTS_PER_UNIT) e.slots.push(null)
189 push = true
190 }
191 if (patch.enabled !== undefined && !!patch.enabled !== e.enabled) {
192 e.enabled = !!patch.enabled
193 if (!e.enabled) e.unit.close()
194 else if (this.connected) this._open(e)
195 } else if (push) {
196 this._push(e)
197 }
198 this._save()
199 this._changed()
200 }
201
202 setSlot (id, index, slot) {
203 const e = this.units.get(id)
204 if (!e) throw new Error('unknown unit ' + id)
205 if (index < 0 || index >= P.SLOTS_PER_UNIT) throw new Error('slot out of range')
206 if (!e.slots) e.slots = [null, null, null, null]
207 e.slots[index] = clampSlot(slot)
208 this._push(e)
209 this._save()
210 this._changed()
211 }
212
213 adoptActual (id) {
214 const e = this.units.get(id)
215 if (!e || !e.unit.actual) return false
216 e.slots = slotsFromValues(e.unit.actual.values, e.redMax)
217 this._save()
218 this._changed()
219 return true
220 }
221
222 pushSettings (id) {
223 const e = this.units.get(id)
224 if (!e) throw new Error('unknown unit ' + id)
225 return e.unit.command(P.gammaCommand(e.gamma.b, e.gamma.g, e.gamma.r) + P.freqCommand(e.freq))
226 }
227
228 // ---- the Connect switch ---------------------------------------------------
229
230 setConnected (on) {
231 on = !!on
232 if (on === this.connected) return
233 this.connected = on
234 for (const e of this.units.values()) {
235 if (on && e.enabled) this._open(e)
236 else e.unit.close()
237 }
238 this._changed()
239 }
240
241 _open (e) {
242 e.unit.open()
243 if (e.slots === null) e.unit.once('status', () => e.unit.requestState())
244 else this._push(e, { force: true })
245 }
246
247 _push (e, { force = false, fadeMs = e.fadeMs } = {}) {
248 if (!this.connected || !e.enabled || e.slots === null) return
249 const values = P.unitValues(e.slots, e.redMax)
250 const payload = fadeMs > 0 ? P.pwm2Command(values, fadeMs) : P.pwmCommand(values)
251 e.unit.set(payload, { force })
252 }
253
254 // ---- blackout and looks ---------------------------------------------------
255
256 blackout (id = null) {
257 const targets = id ? [this.units.get(id)].filter(Boolean) : [...this.units.values()]
258 const cmd = P.blackoutCommand()
259 for (const e of targets) {
260 if (e.slots) e.slots = e.slots.map((s) => (s ? { ...s, level: 0 } : s))
261 if (this.connected && e.enabled) e.unit.set(cmd, { force: true })
262 else if (e.online || e.manual) e.unit.sendOnce(cmd)
263 }
264 this._save()
265 this._changed()
266 return targets.length
267 }
268
269 saveLook (name) {
270 name = String(name || '').trim().slice(0, 64)
271 if (!name) throw new Error('a look needs a name')
272 const units = {}
273 for (const e of this.units.values()) if (e.slots) units[e.id] = e.slots
274 this.looks[name] = { savedAt: new Date().toISOString(), units: JSON.parse(JSON.stringify(units)) }
275 this._save()
276 this._changed()
277 }
278
279 recallLook (name, fadeMs = 1000) {
280 const look = this.looks[name]
281 if (!look) throw new Error('unknown look ' + name)
282 for (const [id, slots] of Object.entries(look.units)) {
283 const e = this.units.get(id)
284 if (!e) continue
285 e.slots = slots.map(clampSlot)
286 this._push(e, { force: true, fadeMs })
287 }
288 this._save()
289 this._changed()
290 }
291
292 deleteLook (name) {
293 // Removes a saved look from the user's own list -- an explicit user action.
294 delete this.looks[name]
295 this._save()
296 this._changed()
297 }
298
299 // ---- readback -------------------------------------------------------------
300
301 setViewers (n) {
302 this.viewers = n
303 }
304
305 _readback () {
306 // `state` is cheap but not free on a single-threaded unit: only ask while
307 // someone is looking, or while a unit's look still has to be adopted.
308 for (const e of this.units.values()) {
309 if (e.unit.status !== 'connected') continue
310 if (this.viewers > 0 || e.slots === null) e.unit.requestState()
311 }
312 }
313
314 // ---- persistence and change notification -----------------------------------
315
316 _serial () {
317 const units = {}
318 for (const e of this.units.values()) {
319 units[e.id] = {
320 label: e.label, host: e.host, port: e.port, manual: e.manual, enabled: e.enabled,
321 redMax: e.redMax, fadeMs: e.fadeMs, slots: e.slots, gamma: e.gamma, freq: e.freq
322 }
323 }
324 return { units, looks: this.looks }
325 }
326
327 _save () {
328 clearTimeout(this._saveTimer)
329 this._saveTimer = setTimeout(() => this.flushSave(), SAVE_DELAY_MS)
330 }
331
332 flushSave () {
333 if (this._saveTimer === null) return
334 clearTimeout(this._saveTimer)
335 this._saveTimer = null
336 this.store.save(this._serial())
337 }
338
339 _changed () {
340 if (this._changeTimer) return
341 this._changeTimer = setTimeout(() => {
342 this._changeTimer = null
343 this.emit('change')
344 }, CHANGE_DELAY_MS)
345 }
346
347 snapshot () {
348 return {
349 connected: this.connected,
350 discovery: { available: this.discovery.available, reason: this.discovery.reason },
351 units: [...this.units.values()].map((e) => ({
352 id: e.id,
353 label: e.label,
354 host: e.host,
355 addr: e.addr,
356 port: e.port,
357 online: e.online,
358 manual: e.manual,
359 proto: e.proto,
360 enabled: e.enabled,
361 status: e.unit.status,
362 error: e.unit.error,
363 redMax: e.redMax,
364 fadeMs: e.fadeMs,
365 slots: e.slots,
366 gamma: e.gamma,
367 freq: e.freq,
368 actual: e.unit.actual
369 })),
370 looks: Object.keys(this.looks).sort()
371 }
372 }
373}
webui/lib/server.js +157 -0
……@@ -0,0 +1,157 @@
1// HTTP surface: the page, a JSON API, and a Server-Sent Events stream that
2// pushes the rig's state to every open page whenever it changes.
3//
4// ⛔ No login: LAN-only by decision (2026-10-02). What stops a random web page
5// in a LAN browser from driving the lights is that every POST must carry
6// `x-cthuloid: 1` with a JSON body -- a cross-site request cannot add a custom
7// header without a CORS preflight, and this server never answers one.
8
9import http from 'bare-http1'
10import fs from 'bare-fs'
11import path from 'bare-path'
12
13const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static')
14const ASSETS = {
15 '/': ['index.html', 'text/html; charset=utf-8'],
16 '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
17 '/app.css': ['app.css', 'text/css; charset=utf-8']
18}
19const MAX_BODY = 64 * 1024
20const PING_MS = 15000
21
22class HttpError extends Error {
23 constructor (status, message) {
24 super(message)
25 this.status = status
26 }
27}
28
29function sendJSON (res, status, obj) {
30 const body = JSON.stringify(obj)
31 res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'cache-control': 'no-store' })
32 res.end(body)
33}
34
35function readJSON (req) {
36 return new Promise((resolve, reject) => {
37 let size = 0
38 const chunks = []
39 req.on('data', (d) => {
40 size += d.length
41 if (size > MAX_BODY) return reject(new HttpError(413, 'body too large'))
42 chunks.push(d)
43 })
44 req.on('end', () => {
45 const text = Buffer.concat(chunks).toString()
46 if (!text) return resolve({})
47 try {
48 resolve(JSON.parse(text))
49 } catch {
50 reject(new HttpError(400, 'body is not JSON'))
51 }
52 })
53 req.on('error', reject)
54 })
55}
56
57export function createServer (rig) {
58 const streams = new Set()
59
60 // One snapshot per change, to every open page.
61 rig.on('change', () => {
62 const frame = 'event: state\ndata: ' + JSON.stringify(rig.snapshot()) + '\n\n'
63 for (const res of streams) res.write(frame)
64 })
65 const ping = setInterval(() => {
66 for (const res of streams) res.write(': ping\n\n')
67 }, PING_MS)
68
69 function events (req, res) {
70 res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' })
71 res.write('event: state\ndata: ' + JSON.stringify(rig.snapshot()) + '\n\n')
72 streams.add(res)
73 rig.setViewers(streams.size)
74 const gone = () => {
75 if (!streams.delete(res)) return
76 rig.setViewers(streams.size)
77 }
78 // Only the RESPONSE closing means the page went away. In bare-http1 the
79 // request emits 'close' as soon as its (empty) body is read -- measured at
80 // ~9 ms -- and listening for that dropped every stream after one frame.
81 res.on('close', gone)
82 res.on('error', gone)
83 }
84
85 async function api (req, res, url) {
86 if (req.method === 'GET' && url === '/api/state') return sendJSON(res, 200, rig.snapshot())
87 if (req.method === 'GET' && url === '/api/events') return events(req, res)
88 if (req.method !== 'POST') throw new HttpError(405, 'method not allowed')
89 if (req.headers['x-cthuloid'] !== '1') throw new HttpError(403, 'missing x-cthuloid header')
90
91 const body = await readJSON(req)
92 const parts = url.split('/').slice(2).map(decodeURIComponent) // ['units', id, action]
93 const [kind, id, action] = parts
94
95 if (kind === 'connect' && parts.length === 1) {
96 rig.setConnected(!!body.on)
97 } else if (kind === 'blackout' && parts.length === 1) {
98 rig.blackout()
99 } else if (kind === 'units' && parts.length === 1) {
100 const name = await rig.addManual(String(body.host || '').trim(), Number(body.port) || 1337)
101 return sendJSON(res, 200, { ok: true, id: name })
102 } else if (kind === 'units' && parts.length === 2) {
103 rig.updateUnit(id, body)
104 } else if (kind === 'units' && action === 'slot') {
105 rig.setSlot(id, Number(body.index), body)
106 } else if (kind === 'units' && action === 'blackout') {
107 rig.blackout(id)
108 } else if (kind === 'units' && action === 'settings') {
109 if (!rig.pushSettings(id)) throw new HttpError(409, 'unit is not connected')
110 } else if (kind === 'units' && action === 'adopt') {
111 if (!rig.adoptActual(id)) throw new HttpError(409, 'no readback from this unit yet')
112 } else if (kind === 'looks' && parts.length === 1) {
113 rig.saveLook(body.name)
114 } else if (kind === 'looks' && action === 'recall') {
115 rig.recallLook(id, Number(body.fadeMs) || 0)
116 } else if (kind === 'looks' && action === 'delete') {
117 rig.deleteLook(id)
118 } else {
119 throw new HttpError(404, 'not found')
120 }
121 sendJSON(res, 200, { ok: true })
122 }
123
124 const server = http.createServer((req, res) => {
125 const url = req.url.split('?')[0]
126 if (url.startsWith('/api/')) {
127 api(req, res, url).catch((err) => {
128 // A thrown model error (unknown unit, bad slot) is the caller's fault.
129 const status = err.status || 400
130 if (status >= 500) console.error('api failed: ' + (err.stack || err))
131 sendJSON(res, status, { error: err.message })
132 })
133 return
134 }
135 const asset = ASSETS[url]
136 if (!asset || req.method !== 'GET') {
137 res.writeHead(404, { 'content-type': 'text/plain' })
138 return res.end('not found')
139 }
140 let body
141 try {
142 body = fs.readFileSync(path.join(STATIC_DIR, asset[0]))
143 } catch (err) {
144 res.writeHead(500, { 'content-type': 'text/plain' })
145 return res.end('asset missing: ' + asset[0])
146 }
147 res.writeHead(200, { 'content-type': asset[1], 'content-length': body.length, 'cache-control': 'no-cache' })
148 res.end(body)
149 })
150
151 server.on('close', () => {
152 clearInterval(ping)
153 for (const res of streams) res.end()
154 streams.clear()
155 })
156 return server
157}
webui/lib/store.js +40 -0
……@@ -0,0 +1,40 @@
1// Settings persistence: one JSON file, written atomically (temp + rename) so a
2// crash mid-write can never leave a truncated config.
3//
4// A config that fails to parse is moved aside, never overwritten: otherwise the
5// first change after a bad read would replace the user's units and looks with
6// defaults.
7
8import fs from 'bare-fs'
9import path from 'bare-path'
10
11export class Store {
12 constructor (dir) {
13 this.dir = dir
14 this.file = path.join(dir, 'config.json')
15 }
16
17 load () {
18 let text
19 try {
20 text = fs.readFileSync(this.file, 'utf8')
21 } catch {
22 return {}
23 }
24 try {
25 return JSON.parse(text)
26 } catch (err) {
27 const aside = this.file + '.bad-' + Date.now()
28 fs.renameSync(this.file, aside)
29 console.error(JSON.stringify({ msg: 'config unreadable, moved aside', aside, error: err.message }))
30 return {}
31 }
32 }
33
34 save (data) {
35 fs.mkdirSync(this.dir, { recursive: true })
36 const tmp = this.file + '.tmp'
37 fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n')
38 fs.renameSync(tmp, this.file)
39 }
40}
webui/lib/unit.js +255 -0
……@@ -0,0 +1,255 @@
1// One light unit's connection. A port of the lessons in
2// blender-addon/cthuloid_lights/net.py, where each was paid for:
3//
4// * Each unit owns its own socket and timers, so an unreachable unit only
5// ever delays itself. (The addon once served every fixture from one thread,
6// and three unplugged units' ~5 s .local lookups starved the live one.)
7// * Latest state only. `set()` replaces the pending look; if the browser
8// outruns the wire, intermediate frames are dropped, never queued, so a
9// slider drag can't build a backlog that plays back late.
10// * Rate limited (the firmware is single-threaded) and deduplicated.
11// * Reconnect with backoff; on reconnect the desired look is re-asserted,
12// because the unit may have rebooted and cannot be assumed to hold it.
13// * Keepalive with `hostname`, the one command that touches no pin state, so
14// the firmware's 5-minute idle drop never fires on a held look.
15//
16// Nothing here connects until open() (the Connect switch) or sendOnce()
17// (Blackout while disconnected).
18
19import EventEmitter from 'bare-events'
20import net from 'bare-net'
21import { parseGreeting, parseState } from './protocol.js'
22
23const KEEPALIVE_PAYLOAD = 'hostname\n'
24
25export class Unit extends EventEmitter {
26 constructor ({
27 id,
28 host,
29 port = 1337,
30 minIntervalMs = 40,
31 keepaliveMs = 60000,
32 connectTimeoutMs = 3000,
33 backoffStartMs = 1000,
34 backoffMaxMs = 15000
35 }) {
36 super()
37 this.id = id
38 this.host = host
39 this.port = port
40 this.minIntervalMs = minIntervalMs
41 this.keepaliveMs = keepaliveMs
42 this.connectTimeoutMs = connectTimeoutMs
43 this.backoffStartMs = backoffStartMs
44 this.backoffMaxMs = backoffMaxMs
45
46 this.status = 'off' // off | connecting | connected | unreachable
47 this.error = ''
48 this.greetingName = null
49 this.actual = null // last `state` readback
50
51 this._wanted = false // open() called and not close()d
52 this._once = false // sendOnce in progress: deliver, then let go
53 this._sock = null
54 this._connected = false
55 this._buf = ''
56 this._desired = null
57 this._dirty = false
58 this._force = false
59 this._lastSent = null
60 this._lastTx = 0
61 this._backoff = backoffStartMs
62 this._retryTimer = null
63 this._flushTimer = null
64 this._connectTimer = null
65 this._keepaliveTimer = null
66 }
67
68 // ---- control -----------------------------------------------------------
69
70 open () {
71 this._once = false
72 if (this._wanted) return
73 this._wanted = true
74 this._backoff = this.backoffStartMs
75 if (!this._sock) this._connect()
76 }
77
78 close () {
79 this._wanted = false
80 this._once = false
81 this._teardown()
82 clearTimeout(this._retryTimer)
83 this._retryTimer = null
84 this._lastSent = null
85 this._setStatus('off', '')
86 }
87
88 // Latest look for this unit. Never blocks; delivered when connected.
89 set (payload, { force = false } = {}) {
90 if (!payload) return
91 this._desired = payload
92 if (force) this._force = true
93 if (force || payload !== this._lastSent) {
94 this._dirty = true
95 this._flush()
96 }
97 }
98
99 // One-shot delivery while not open (Blackout when disconnected): one
100 // connection attempt, deliver, close. No retry loop is left behind.
101 sendOnce (payload) {
102 if (this._wanted) return this.set(payload, { force: true })
103 this._once = true
104 this._desired = payload
105 this._dirty = true
106 this._force = true
107 if (!this._sock) this._connect()
108 else this._flush()
109 }
110
111 // A one-off command beside the look (gamma, freq, state). Unlike set() it
112 // does not replace the pending look, and it is dropped if not connected:
113 // these are re-pushed on demand, not state that must survive a reconnect.
114 command (payload) {
115 if (!this._connected) return false
116 this._sock.write(payload)
117 this._lastTx = Date.now()
118 return true
119 }
120
121 requestState () {
122 return this.command('state\n')
123 }
124
125 // ---- connection --------------------------------------------------------
126
127 _connect () {
128 this._setStatus('connecting', '')
129 this._connected = false
130 this._buf = ''
131 const sock = net.createConnection({ port: this.port, host: this.host, noDelay: true })
132 this._sock = sock
133
134 this._connectTimer = setTimeout(() => {
135 if (this._sock === sock && !this._connected) sock.destroy(new Error('connect timeout'))
136 }, this.connectTimeoutMs)
137
138 sock.on('connect', () => {
139 if (this._sock !== sock) return
140 clearTimeout(this._connectTimer)
141 this._connected = true
142 this._backoff = this.backoffStartMs
143 this._lastTx = Date.now()
144 this._setStatus('connected', '')
145 // The unit may have rebooted or been dropped mid-look; re-assert.
146 this._lastSent = null
147 if (this._desired !== null) this._dirty = true
148 this._flush()
149 this._armKeepalive()
150 })
151
152 sock.on('data', (d) => this._onData(d))
153 sock.on('error', (err) => {
154 if (this._sock === sock) this.error = err.message || String(err)
155 })
156 sock.on('close', () => {
157 if (this._sock !== sock) return
158 const wasConnected = this._connected
159 this._teardown()
160 if (this._once) {
161 // One attempt only. A once-send that failed to connect is dropped.
162 this._once = false
163 this._setStatus('off', wasConnected ? '' : this.error)
164 return
165 }
166 if (!this._wanted) return
167 this._setStatus('unreachable', this.error || (wasConnected ? 'connection closed' : 'connect failed'))
168 if (this._desired !== null) this._dirty = true
169 this._lastSent = null
170 this._retryTimer = setTimeout(() => {
171 this._retryTimer = null
172 if (this._wanted && !this._sock) this._connect()
173 }, this._backoff)
174 this._backoff = Math.min(this._backoff * 2, this.backoffMaxMs)
175 })
176 }
177
178 _teardown () {
179 clearTimeout(this._connectTimer)
180 clearTimeout(this._flushTimer)
181 clearInterval(this._keepaliveTimer)
182 this._flushTimer = null
183 this._keepaliveTimer = null
184 const sock = this._sock
185 this._sock = null
186 this._connected = false
187 if (sock) sock.destroy()
188 }
189
190 _onData (d) {
191 this._buf += d.toString()
192 let i
193 while ((i = this._buf.indexOf('\n')) !== -1) {
194 const line = this._buf.slice(0, i).trim()
195 this._buf = this._buf.slice(i + 1)
196 if (!line) continue
197 const name = parseGreeting(line)
198 if (name !== null) {
199 this.greetingName = name
200 this.emit('greeting', name)
201 continue
202 }
203 const st = parseState(line)
204 if (st !== null) {
205 this.actual = st
206 this.emit('state', st)
207 }
208 }
209 // A unit that never sends a newline must not grow this without bound.
210 if (this._buf.length > 4096) this._buf = ''
211 }
212
213 // ---- sending -----------------------------------------------------------
214
215 _flush () {
216 if (!this._connected || !this._dirty || this._flushTimer) return
217 const wait = this.minIntervalMs - (Date.now() - this._lastTx)
218 if (!this._force && wait > 0) {
219 this._flushTimer = setTimeout(() => {
220 this._flushTimer = null
221 this._flush()
222 }, wait)
223 return
224 }
225 const payload = this._desired
226 this._sock.write(payload)
227 this._lastTx = Date.now()
228 this._lastSent = payload
229 this._dirty = false
230 this._force = false
231 if (this._once) {
232 // Let the bytes leave before closing; end() flushes, then 'close' fires.
233 this._once = false
234 this._sock.end()
235 }
236 }
237
238 _armKeepalive () {
239 clearInterval(this._keepaliveTimer)
240 const every = Math.max(1000, Math.floor(this.keepaliveMs / 4))
241 this._keepaliveTimer = setInterval(() => {
242 if (this._connected && Date.now() - this._lastTx >= this.keepaliveMs) {
243 this._sock.write(KEEPALIVE_PAYLOAD)
244 this._lastTx = Date.now()
245 }
246 }, every)
247 }
248
249 _setStatus (status, error) {
250 if (this.status === status && this.error === error) return
251 this.status = status
252 this.error = error
253 this.emit('status', status, error)
254 }
255}
webui/package-lock.json +1021 -0
……@@ -0,0 +1,1021 @@
1{
2 "name": "cthuloid-web",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "cthuloid-web",
9 "version": "0.1.0",
10 "dependencies": {
11 "bare-events": "^2.9.2",
12 "bare-fs": "^4.8.1",
13 "bare-http1": "^4.6.1",
14 "bare-net": "^2.3.3",
15 "bare-os": "^3.9.3",
16 "bare-path": "^3.1.2",
17 "bare-process": "^4.5.1",
18 "bare-subprocess": "6.1.0"
19 },
20 "bin": {
21 "cthuloid-web": "index.js"
22 },
23 "devDependencies": {
24 "brittle": "4.1.1"
25 }
26 },
27 "node_modules/@jridgewell/resolve-uri": {
28 "version": "3.1.2",
29 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
30 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
31 "dev": true,
32 "license": "MIT",
33 "engines": {
34 "node": ">=6.0.0"
35 }
36 },
37 "node_modules/@jridgewell/sourcemap-codec": {
38 "version": "1.6.0",
39 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
40 "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
41 "dev": true,
42 "license": "MIT"
43 },
44 "node_modules/@jridgewell/trace-mapping": {
45 "version": "0.3.31",
46 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
47 "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
48 "dev": true,
49 "license": "MIT",
50 "dependencies": {
51 "@jridgewell/resolve-uri": "^3.1.0",
52 "@jridgewell/sourcemap-codec": "^1.4.14"
53 }
54 },
55 "node_modules/@types/istanbul-lib-coverage": {
56 "version": "2.0.6",
57 "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
58 "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
59 "dev": true,
60 "license": "MIT"
61 },
62 "node_modules/b4a": {
63 "version": "1.9.0",
64 "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
65 "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
66 "license": "Apache-2.0",
67 "peerDependencies": {
68 "react-native-b4a": "*"
69 },
70 "peerDependenciesMeta": {
71 "react-native-b4a": {
72 "optional": true
73 }
74 }
75 },
76 "node_modules/bare-abort": {
77 "version": "2.0.13",
78 "resolved": "https://registry.npmjs.org/bare-abort/-/bare-abort-2.0.13.tgz",
79 "integrity": "sha512-zdc8l88eB11Jsz5rDd6sCAgv2kUFXgdrZWoMlgU6JMkfAi1/uuGFC3IEHswKbIRQTk5H3T5CMuechsXYxiaHlQ==",
80 "license": "Apache-2.0"
81 },
82 "node_modules/bare-addon-resolve": {
83 "version": "1.10.1",
84 "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz",
85 "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==",
86 "dev": true,
87 "license": "Apache-2.0",
88 "dependencies": {
89 "bare-module-resolve": "^1.10.0",
90 "bare-semver": "^1.0.0"
91 },
92 "peerDependencies": {
93 "bare-url": "*"
94 },
95 "peerDependenciesMeta": {
96 "bare-url": {
97 "optional": true
98 }
99 }
100 },
101 "node_modules/bare-ansi-escapes": {
102 "version": "2.2.3",
103 "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz",
104 "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==",
105 "dev": true,
106 "license": "Apache-2.0",
107 "dependencies": {
108 "bare-stream": "^2.6.5"
109 },
110 "peerDependencies": {
111 "bare-buffer": "*"
112 },
113 "peerDependenciesMeta": {
114 "bare-buffer": {
115 "optional": true
116 }
117 }
118 },
119 "node_modules/bare-buffer": {
120 "version": "3.7.1",
121 "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.7.1.tgz",
122 "integrity": "sha512-cIjZnSO+y89ykVX9x96OVnA6tGORBn7vGJ/e3PCbBHMEyfMNG/ixzOS0lLa6saMlWcs69bSKjxFyDwjK152OMg==",
123 "license": "Apache-2.0",
124 "peer": true,
125 "engines": {
126 "bare": ">=1.20.0"
127 }
128 },
129 "node_modules/bare-bundle": {
130 "version": "1.11.0",
131 "resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.11.0.tgz",
132 "integrity": "sha512-msaGQLaojz+EszDhf0qvCTmVWgJFSqbC5bUmEb9AKyVeglmJpx1EbCGqQTiXycCV7oA++MYqTagh/YdIZ//bmQ==",
133 "dev": true,
134 "license": "Apache-2.0",
135 "peerDependencies": {
136 "bare-buffer": "*",
137 "bare-url": "*"
138 },
139 "peerDependenciesMeta": {
140 "bare-buffer": {
141 "optional": true
142 },
143 "bare-url": {
144 "optional": true
145 }
146 }
147 },
148 "node_modules/bare-channel": {
149 "version": "5.3.0",
150 "resolved": "https://registry.npmjs.org/bare-channel/-/bare-channel-5.3.0.tgz",
151 "integrity": "sha512-W42Pkl/IjfkIVObQnixidRThST6ZcqY0u6AMTLb2+Jc/enBVsXPxzmyhEDoQjNNYi4WBGkOG7YHR4X46Ah9xfA==",
152 "dev": true,
153 "license": "Apache-2.0",
154 "dependencies": {
155 "bare-events": "^2.0.0",
156 "bare-stream": "^2.7.0",
157 "bare-structured-clone": "^2.0.0"
158 },
159 "engines": {
160 "bare": ">=1.7.0"
161 }
162 },
163 "node_modules/bare-cov": {
164 "version": "1.2.3",
165 "resolved": "https://registry.npmjs.org/bare-cov/-/bare-cov-1.2.3.tgz",
166 "integrity": "sha512-M9XAEUxk3KKMMD2g8WwbrR0grc25RhkS95XnW6+PksSQdN1C43gYSOa0fQeun5Vo6rvTv501FEYyERKA43EzWg==",
167 "dev": true,
168 "license": "Apache-2.0",
169 "dependencies": {
170 "bare-fs": "^4.8.1",
171 "bare-inspector": "^6.1.0",
172 "bare-path": "^3.1.2",
173 "bare-process": "^4.5.1",
174 "bare-url": "^2.5.4",
175 "bare-v8-to-istanbul": "^1.0.4",
176 "picomatch": "^4.0.7",
177 "which-runtime": "^1.4.0"
178 }
179 },
180 "node_modules/bare-crypto": {
181 "version": "1.15.4",
182 "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.15.4.tgz",
183 "integrity": "sha512-4luuiLnxIJvM7ZyyW6pXbaaGoQDZcIkfcVZrPzJ3oIpdi2UIP37dROmzBWqI2lnrzfKoQlKWM8ryHmyG2svViw==",
184 "dev": true,
185 "license": "Apache-2.0",
186 "dependencies": {
187 "bare-assert": "^1.2.0",
188 "bare-stream": "^2.6.3"
189 },
190 "peerDependencies": {
191 "bare-buffer": "*"
192 },
193 "peerDependenciesMeta": {
194 "bare-buffer": {
195 "optional": true
196 }
197 }
198 },
199 "node_modules/bare-crypto/node_modules/bare-assert": {
200 "version": "1.2.0",
201 "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz",
202 "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==",
203 "dev": true,
204 "license": "Apache-2.0",
205 "dependencies": {
206 "bare-inspect": "^3.1.2"
207 }
208 },
209 "node_modules/bare-debug-log": {
210 "version": "2.0.0",
211 "resolved": "https://registry.npmjs.org/bare-debug-log/-/bare-debug-log-2.0.0.tgz",
212 "integrity": "sha512-Vi42PkMQsNV9PUpx2Gl1hikshx5O9FzMJ6o9Nnopseg7qLBBK7Nl31d0RHcfwLEAfmcPApytpc0ZFfq68u22FQ==",
213 "dev": true,
214 "license": "Apache-2.0",
215 "dependencies": {
216 "bare-os": "^3.0.1"
217 }
218 },
219 "node_modules/bare-dns": {
220 "version": "2.2.1",
221 "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.2.1.tgz",
222 "integrity": "sha512-76kta7f4YMiXwhdlK1q8AFnVvSlZ5yv4FL4AH83KoTRErgj0Ttm6Jo9993+gWEWQfl7VZTHtihPozstlknXszw==",
223 "license": "Apache-2.0",
224 "engines": {
225 "bare": ">=1.7.0"
226 },
227 "peerDependencies": {
228 "bare-buffer": "*"
229 },
230 "peerDependenciesMeta": {
231 "bare-buffer": {
232 "optional": true
233 }
234 }
235 },
236 "node_modules/bare-encoding": {
237 "version": "1.0.3",
238 "resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz",
239 "integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==",
240 "dev": true,
241 "license": "Apache-2.0",
242 "peerDependencies": {
243 "bare-buffer": "*"
244 },
245 "peerDependenciesMeta": {
246 "bare-buffer": {
247 "optional": true
248 }
249 }
250 },
251 "node_modules/bare-env": {
252 "version": "3.0.1",
253 "resolved": "https://registry.npmjs.org/bare-env/-/bare-env-3.0.1.tgz",
254 "integrity": "sha512-BdoLvnzaWR0YyyJreEx4zG2Od0AC/s9JSZ+EXyMZKunpn8zEgR/dGv37hdFUaY1bjDkcRKvcKkFyJIwlJ0CaYA==",
255 "license": "Apache-2.0",
256 "dependencies": {
257 "bare-os": "^3.0.1"
258 }
259 },
260 "node_modules/bare-events": {
261 "version": "2.9.2",
262 "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
263 "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
264 "license": "Apache-2.0",
265 "peerDependencies": {
266 "bare-abort-controller": "*"
267 },
268 "peerDependenciesMeta": {
269 "bare-abort-controller": {
270 "optional": true
271 }
272 }
273 },
274 "node_modules/bare-format": {
275 "version": "1.0.2",
276 "resolved": "https://registry.npmjs.org/bare-format/-/bare-format-1.0.2.tgz",
277 "integrity": "sha512-GswdhnOnP9QtwRbrf4wLApw5widkaLMsLe2XOs35fQD2YfEN1ApoGka+cZ7PfvzxMgfYXmMhj/2OGlVn5/Dxgw==",
278 "dev": true,
279 "license": "Apache-2.0",
280 "dependencies": {
281 "bare-inspect": "^3.0.0"
282 }
283 },
284 "node_modules/bare-fs": {
285 "version": "4.8.2",
286 "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.2.tgz",
287 "integrity": "sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w==",
288 "license": "Apache-2.0",
289 "dependencies": {
290 "bare-events": "^2.5.4",
291 "bare-path": "^3.0.0",
292 "bare-stream": "^2.6.4",
293 "bare-url": "^2.2.2",
294 "fast-fifo": "^1.3.2"
295 },
296 "engines": {
297 "bare": ">=1.28.0"
298 },
299 "peerDependencies": {
300 "bare-buffer": "*"
301 },
302 "peerDependenciesMeta": {
303 "bare-buffer": {
304 "optional": true
305 }
306 }
307 },
308 "node_modules/bare-hrtime": {
309 "version": "2.1.2",
310 "resolved": "https://registry.npmjs.org/bare-hrtime/-/bare-hrtime-2.1.2.tgz",
311 "integrity": "sha512-ePu0axonbewvuUv9TFqjfvnZTaw+mL4JNx1q79dw8T3DIQEjqHs+pXyj92ghepa8K14KLtX3GdUuAVB+faaGDw==",
312 "license": "Apache-2.0"
313 },
314 "node_modules/bare-http-parser": {
315 "version": "2.1.4",
316 "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-2.1.4.tgz",
317 "integrity": "sha512-6CRjxqMdrEoKKBCS6FbdZUuVsRtyqq18BUae9l9N5okSV75S9lUAF9f5aFSKTNybBS/hcnlxFNocdpXbd2Ohaw==",
318 "license": "Apache-2.0"
319 },
320 "node_modules/bare-http1": {
321 "version": "4.6.2",
322 "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.6.2.tgz",
323 "integrity": "sha512-fnvBPNUH6KfdcEG7sKKKtvc3/9BEn3vkmJPgdtSLqGl6i2J9Csnl14mVv78ODPn8p3Zu2ZfmlG5XQzQQ45fwLw==",
324 "license": "Apache-2.0",
325 "dependencies": {
326 "bare-events": "^2.6.0",
327 "bare-hrtime": "^2.1.1",
328 "bare-http-parser": "^2.1.0",
329 "bare-stream": "^2.10.0",
330 "bare-tcp": "^2.2.0"
331 },
332 "peerDependencies": {
333 "bare-buffer": "*",
334 "bare-url": "*"
335 },
336 "peerDependenciesMeta": {
337 "bare-buffer": {
338 "optional": true
339 },
340 "bare-url": {
341 "optional": true
342 }
343 }
344 },
345 "node_modules/bare-https": {
346 "version": "3.1.0",
347 "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-3.1.0.tgz",
348 "integrity": "sha512-HNAS0q7CBAXGDFRy/xaYJEsAaVcvyDRXKU1b/KxS/zdcfBWsQVB0TntSzz3IlqZ5ZWHiaOOrMYAO/oIDgpTLkQ==",
349 "dev": true,
350 "license": "Apache-2.0",
351 "dependencies": {
352 "bare-http1": "^4.4.0",
353 "bare-tcp": "^2.2.0",
354 "bare-tls": "^3.0.0"
355 }
356 },
357 "node_modules/bare-inspect": {
358 "version": "3.1.10",
359 "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.10.tgz",
360 "integrity": "sha512-Qu01XZNTIT2/bS9k7c6A3OXuAzBnWwDCejGri76BUltdFqVe6Et/IxVOSSbcDpreSSWwqQ5pAu/bxFE6hPDWRQ==",
361 "dev": true,
362 "license": "Apache-2.0",
363 "dependencies": {
364 "bare-ansi-escapes": "^2.1.0",
365 "bare-type": "^1.0.0"
366 },
367 "engines": {
368 "bare": ">=1.18.0"
369 }
370 },
371 "node_modules/bare-inspector": {
372 "version": "6.1.0",
373 "resolved": "https://registry.npmjs.org/bare-inspector/-/bare-inspector-6.1.0.tgz",
374 "integrity": "sha512-PRxmZ4gF+K3TLzGubgRFvzdECybTCSKackgNsAdd4e7SdvICxMkGj+p5iX7bSKYSmgDnxgCR+2Z6UXmWykKhvw==",
375 "dev": true,
376 "license": "Apache-2.0",
377 "dependencies": {
378 "bare-events": "^2.1.0",
379 "bare-http1": "^4.0.0",
380 "bare-stream": "^2.0.0",
381 "bare-url": "^2.0.0",
382 "bare-ws": "^3.0.0"
383 },
384 "engines": {
385 "bare": ">=1.29.0"
386 },
387 "peerDependencies": {
388 "bare-tcp": "*"
389 },
390 "peerDependenciesMeta": {
391 "bare-tcp": {
392 "optional": true
393 }
394 }
395 },
396 "node_modules/bare-module": {
397 "version": "6.4.0",
398 "resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.4.0.tgz",
399 "integrity": "sha512-Yn4V5g5EqGQL4LYUOmt7fjKzj2JPWyJOqE3lPoeZwfUH5rk4CKUfZj6JhDwbzhBYCqqmUgjgQ5aY8cihAPILLA==",
400 "dev": true,
401 "license": "Apache-2.0",
402 "dependencies": {
403 "bare-bundle": "^1.3.0",
404 "bare-module-lexer": "^1.0.0",
405 "bare-module-resolve": "^1.8.0",
406 "bare-path": "^3.0.0",
407 "bare-type-stripper": "^0.1.2",
408 "bare-url": "^2.0.1"
409 },
410 "engines": {
411 "bare": ">=1.29.4"
412 },
413 "peerDependencies": {
414 "bare-buffer": "*"
415 },
416 "peerDependenciesMeta": {
417 "bare-buffer": {
418 "optional": true
419 }
420 }
421 },
422 "node_modules/bare-module-lexer": {
423 "version": "1.7.0",
424 "resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.7.0.tgz",
425 "integrity": "sha512-LD5eiVhDyiMRfMtxK3E+0SeL95nkLNl37YUccXWEaErmfkJi596OECtUoH3X+YPomthwW2oQEch9c7aOOJ4ePA==",
426 "dev": true,
427 "license": "Apache-2.0",
428 "dependencies": {
429 "require-addon": "^1.0.2"
430 },
431 "engines": {
432 "bare": ">=1.0.0",
433 "node": "^22.21.0 || >=24.9.0"
434 },
435 "peerDependencies": {
436 "bare-buffer": "*"
437 },
438 "peerDependenciesMeta": {
439 "bare-buffer": {
440 "optional": true
441 }
442 }
443 },
444 "node_modules/bare-module-resolve": {
445 "version": "1.12.5",
446 "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.5.tgz",
447 "integrity": "sha512-VOncxVvVk8SQVw9vhcBnoTJD/74aR5DgdRPCm0gQ7uB5MsWpBJnoCeJrwEHKiz09O83ndf3NTjsz3LEuFxAq5A==",
448 "dev": true,
449 "license": "Apache-2.0",
450 "dependencies": {
451 "bare-semver": "^1.0.0"
452 },
453 "peerDependencies": {
454 "bare-url": "*"
455 },
456 "peerDependenciesMeta": {
457 "bare-url": {
458 "optional": true
459 }
460 }
461 },
462 "node_modules/bare-net": {
463 "version": "2.3.3",
464 "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.3.tgz",
465 "integrity": "sha512-q1noXFJKn+eNi6vXYv2Y+5FTnn+o7qpli4m2lUZTdK8cBjVs2zGaykuzTUIa29v70eOsQI9OAIyYwN3ODHtFaQ==",
466 "license": "Apache-2.0",
467 "dependencies": {
468 "bare-events": "^2.2.2",
469 "bare-pipe": "^4.0.0",
470 "bare-stream": "^2.0.0",
471 "bare-tcp": "^2.0.0"
472 }
473 },
474 "node_modules/bare-os": {
475 "version": "3.9.3",
476 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz",
477 "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==",
478 "license": "Apache-2.0",
479 "engines": {
480 "bare": ">=1.14.0"
481 }
482 },
483 "node_modules/bare-path": {
484 "version": "3.1.2",
485 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
486 "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
487 "license": "Apache-2.0"
488 },
489 "node_modules/bare-pipe": {
490 "version": "4.3.1",
491 "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.3.1.tgz",
492 "integrity": "sha512-3P4MYTgOys1Bbz5dgWzwfroNOOl8x1zWJHTHVlwsojThhV/uJPRf+qFrYYBOnI0vQiJJLJtHlw8a4Osj/kcsJQ==",
493 "license": "Apache-2.0",
494 "dependencies": {
495 "bare-events": "^2.5.4",
496 "bare-stream": "^2.6.4"
497 },
498 "engines": {
499 "bare": ">=1.16.0"
500 },
501 "peerDependencies": {
502 "bare-buffer": "*"
503 },
504 "peerDependenciesMeta": {
505 "bare-buffer": {
506 "optional": true
507 }
508 }
509 },
510 "node_modules/bare-posix": {
511 "version": "1.0.1",
512 "resolved": "https://registry.npmjs.org/bare-posix/-/bare-posix-1.0.1.tgz",
513 "integrity": "sha512-b4quA8dyRVvX0aFIAIiev5zFATHwIUGr42Gmt+CqiBNg/bdsNkrj4bbkeotMOMiljJS4DCME5kywJO/TFwKo1A==",
514 "license": "Apache-2.0"
515 },
516 "node_modules/bare-process": {
517 "version": "4.5.1",
518 "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.5.1.tgz",
519 "integrity": "sha512-CaAvy1trputD49mtwfJ6G75vydhnirLrW/F3Sznp4H556e1uZn8YMo9ELicBTrGYy7RBNUgPl9bpB/ERzRCiDw==",
520 "license": "Apache-2.0",
521 "dependencies": {
522 "bare-abort": "^2.0.13",
523 "bare-env": "^3.0.0",
524 "bare-events": "^2.3.1",
525 "bare-hrtime": "^2.0.0",
526 "bare-os": "^3.7.1",
527 "bare-posix": "^1.0.1",
528 "bare-signals": "^5.0.0",
529 "bare-stdio": "^1.0.1"
530 }
531 },
532 "node_modules/bare-semver": {
533 "version": "1.1.0",
534 "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz",
535 "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==",
536 "dev": true,
537 "license": "Apache-2.0"
538 },
539 "node_modules/bare-signals": {
540 "version": "5.0.0",
541 "resolved": "https://registry.npmjs.org/bare-signals/-/bare-signals-5.0.0.tgz",
542 "integrity": "sha512-8Gn8bBFUh2AUCJ9wWWFjSGWIo1HIUSIQnXRJGSm/f7GCDMqsuJhRmR1dT+HtDaDBkeu2l8Koxt2JLurfJ5yHVw==",
543 "license": "Apache-2.0",
544 "dependencies": {
545 "bare-events": "^2.5.3"
546 },
547 "engines": {
548 "bare": ">=1.7.0"
549 }
550 },
551 "node_modules/bare-stdio": {
552 "version": "1.0.3",
553 "resolved": "https://registry.npmjs.org/bare-stdio/-/bare-stdio-1.0.3.tgz",
554 "integrity": "sha512-8BRMx7RWMWCbBmKhyHgBco62J+Pgss7+EPI21L3R9+w0UpwDYWUUcbE0YTOesGyt7M6N7VtoshYbzwrP0pI52Q==",
555 "license": "Apache-2.0",
556 "dependencies": {
557 "bare-fs": "^4.5.2",
558 "bare-pipe": "^4.1.5",
559 "bare-tty": "^5.0.3"
560 }
561 },
562 "node_modules/bare-stream": {
563 "version": "2.13.4",
564 "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
565 "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
566 "license": "Apache-2.0",
567 "dependencies": {
568 "b4a": "^1.8.1",
569 "streamx": "^2.25.0",
570 "teex": "^1.0.1"
571 },
572 "peerDependencies": {
573 "bare-abort-controller": "*",
574 "bare-buffer": "*",
575 "bare-events": "*"
576 },
577 "peerDependenciesMeta": {
578 "bare-abort-controller": {
579 "optional": true
580 },
581 "bare-buffer": {
582 "optional": true
583 },
584 "bare-events": {
585 "optional": true
586 }
587 }
588 },
589 "node_modules/bare-structured-clone": {
590 "version": "1.6.0",
591 "resolved": "https://registry.npmjs.org/bare-structured-clone/-/bare-structured-clone-1.6.0.tgz",
592 "integrity": "sha512-AZjEERyqF7kAuudlmgrweT2KwwWM0LsGG4Gx/bWyFwJrKU7E3wNG8c09z2DIrdw93p3vDtfOX8fyHOcXfy5m+g==",
593 "license": "Apache-2.0",
594 "dependencies": {
595 "bare-buffer": "^3.6.0",
596 "bare-type": "^1.1.0",
597 "bare-url": "^2.4.0",
598 "compact-encoding": "^3.0.1"
599 },
600 "engines": {
601 "bare": ">=1.2.0"
602 }
603 },
604 "node_modules/bare-stylize": {
605 "version": "0.0.1",
606 "resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz",
607 "integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==",
608 "dev": true,
609 "license": "Apache-2.0",
610 "dependencies": {
611 "bare-ansi-escapes": "^2.2.3",
612 "bare-process": "^4.2.1"
613 }
614 },
615 "node_modules/bare-subprocess": {
616 "version": "6.1.0",
617 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-6.1.0.tgz",
618 "integrity": "sha512-8L6KtbmreDy4Fc1BdDqaDo9fg0nbedUmTSFQxYPV0uHIM2BBAX8+E0LyMDKgkk0I+mlKto80WYzOnIhT8VDdOg==",
619 "license": "Apache-2.0",
620 "dependencies": {
621 "bare-env": "^3.0.0",
622 "bare-events": "^2.5.4",
623 "bare-os": "^3.0.1",
624 "bare-pipe": "^4.2.0",
625 "bare-structured-clone": "^1.5.2",
626 "bare-tcp": "^2.4.1",
627 "bare-url": "^2.2.2"
628 },
629 "engines": {
630 "bare": ">=1.7.0"
631 },
632 "peerDependencies": {
633 "bare-buffer": "*"
634 },
635 "peerDependenciesMeta": {
636 "bare-buffer": {
637 "optional": true
638 }
639 }
640 },
641 "node_modules/bare-tcp": {
642 "version": "2.6.1",
643 "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.6.1.tgz",
644 "integrity": "sha512-8f8UFP7O27gIGaVWJPysQ2am8LSD6YCuVSbnTbOrQjUD0RkWEZexvi2Q66P99MLxSephlyjoU0mLxWmozRJMnA==",
645 "license": "Apache-2.0",
646 "dependencies": {
647 "bare-dns": "^2.0.4",
648 "bare-events": "^2.5.4",
649 "bare-stream": "^2.6.4"
650 },
651 "engines": {
652 "bare": ">=1.16.0"
653 },
654 "peerDependencies": {
655 "bare-pipe": "*"
656 },
657 "peerDependenciesMeta": {
658 "bare-pipe": {
659 "optional": true
660 }
661 }
662 },
663 "node_modules/bare-tls": {
664 "version": "3.1.11",
665 "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.11.tgz",
666 "integrity": "sha512-lCQMepLYPPmdunO4MtkVe6l619Kdrt47/0HDGOgJY6zVvEeFACZD1hkh1kRw6zJk2NQHLcnebggYQZ/z6J/cPg==",
667 "dev": true,
668 "license": "Apache-2.0",
669 "dependencies": {
670 "bare-net": "^2.0.1",
671 "bare-stream": "^2.6.4"
672 },
673 "engines": {
674 "bare": ">=1.7.0"
675 }
676 },
677 "node_modules/bare-tty": {
678 "version": "5.2.1",
679 "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.2.1.tgz",
680 "integrity": "sha512-vH9ggdu6q+HilCKcYCfSzwuN2QaR4+gGP+JiYN5zKMhMI6tKeKnZMCuE/AJoF9lbSo0FriPdZoKA5bO2b0Mk3w==",
681 "license": "Apache-2.0",
682 "dependencies": {
683 "bare-events": "^2.2.0",
684 "bare-signals": "^5.0.0",
685 "bare-stream": "^2.0.0"
686 },
687 "engines": {
688 "bare": ">=1.16.0"
689 }
690 },
691 "node_modules/bare-type": {
692 "version": "1.1.1",
693 "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.1.tgz",
694 "integrity": "sha512-5HwbjDbnYr+Lwu5I59IqwOFcRcVo4jxo408t42gKhKnikw0ojwyPXPlx58AQP4dR2VvGw57ppX/qxYCn60Wujw==",
695 "license": "Apache-2.0",
696 "engines": {
697 "bare": ">=1.2.0"
698 }
699 },
700 "node_modules/bare-type-stripper": {
701 "version": "0.1.7",
702 "resolved": "https://registry.npmjs.org/bare-type-stripper/-/bare-type-stripper-0.1.7.tgz",
703 "integrity": "sha512-kiYTY1PqJnCQmaGT4Q23gskKCTtxqinPdA0SKMO80rjyLMdZhkT+mALf+/UryvovV6ysXtYXGSNPZR4wFhCI/g==",
704 "dev": true,
705 "license": "Apache-2.0",
706 "dependencies": {
707 "require-addon": "^1.0.2"
708 },
709 "engines": {
710 "bare": ">=1.0.0",
711 "node": "^22.21.0 || >=24.9.0"
712 },
713 "peerDependencies": {
714 "bare-buffer": "*"
715 },
716 "peerDependenciesMeta": {
717 "bare-buffer": {
718 "optional": true
719 }
720 }
721 },
722 "node_modules/bare-url": {
723 "version": "2.5.4",
724 "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
725 "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
726 "license": "Apache-2.0",
727 "peer": true,
728 "dependencies": {
729 "bare-path": "^3.0.0"
730 }
731 },
732 "node_modules/bare-utils": {
733 "version": "1.6.0",
734 "resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz",
735 "integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==",
736 "dev": true,
737 "license": "Apache-2.0",
738 "dependencies": {
739 "bare-debug-log": "^2.0.0",
740 "bare-encoding": "^1.0.0",
741 "bare-format": "^1.0.0",
742 "bare-inspect": "^3.0.0",
743 "bare-stylize": "^0.0.1",
744 "bare-type": "^1.0.6"
745 }
746 },
747 "node_modules/bare-v8-to-istanbul": {
748 "version": "1.0.4",
749 "resolved": "https://registry.npmjs.org/bare-v8-to-istanbul/-/bare-v8-to-istanbul-1.0.4.tgz",
750 "integrity": "sha512-aYdse9SdVep4NT18jyMehBShfZgvyQ36Tm+oha2nQIHGcxvdBk0xZLbcYsDPzCTqGdJ5SfXa3VvZUSWyoa3L4w==",
751 "dev": true,
752 "license": "Apache-2.0",
753 "dependencies": {
754 "bare-assert": "^1.2.0",
755 "bare-fs": "^4.8.1",
756 "bare-module": "^6.4.0",
757 "bare-path": "^3.1.2",
758 "bare-process": "^4.5.1",
759 "bare-url": "^2.5.4",
760 "bare-utils": "^1.6.0",
761 "v8-to-istanbul": "^9.3.0",
762 "which-runtime": "^1.4.0"
763 }
764 },
765 "node_modules/bare-v8-to-istanbul/node_modules/bare-assert": {
766 "version": "1.2.0",
767 "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz",
768 "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==",
769 "dev": true,
770 "license": "Apache-2.0",
771 "dependencies": {
772 "bare-inspect": "^3.1.2"
773 }
774 },
775 "node_modules/bare-ws": {
776 "version": "3.2.0",
777 "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-3.2.0.tgz",
778 "integrity": "sha512-9PPJX5JrhuObWZGyhYCrQ1DbhjY1BLVALkZF56r0SYnfyN18ok84yUETcQ377aC2W6w8IqBewjQ1GlovtiXA1Q==",
779 "dev": true,
780 "license": "Apache-2.0",
781 "dependencies": {
782 "bare-crypto": "^1.2.0",
783 "bare-events": "^2.3.1",
784 "bare-http1": "^4.0.0",
785 "bare-https": "^3.0.0",
786 "bare-stream": "^2.1.2"
787 },
788 "peerDependencies": {
789 "bare-buffer": "*",
790 "bare-url": "*"
791 },
792 "peerDependenciesMeta": {
793 "bare-buffer": {
794 "optional": true
795 },
796 "bare-url": {
797 "optional": true
798 }
799 }
800 },
801 "node_modules/brittle": {
802 "version": "4.1.1",
803 "resolved": "https://registry.npmjs.org/brittle/-/brittle-4.1.1.tgz",
804 "integrity": "sha512-FHzlSZ5KqB+2uXN1GYboQDzUkR+Z1n6MOXCnrSal5t41IJ9TM/EB+zCyIaOrEYgBpToRQn6U6NAqTYe45CqE5g==",
805 "dev": true,
806 "license": "Apache-2.0",
807 "dependencies": {
808 "b4a": "^1.8.0",
809 "bare-assert": "^1.2.0",
810 "bare-buffer": "^3.6.0",
811 "bare-channel": "^5.2.3",
812 "bare-cov": "^1.2.1",
813 "bare-fs": "^4.5.5",
814 "bare-os": "^3.7.1",
815 "bare-path": "^3.0.0",
816 "bare-process": "^4.3.0",
817 "bare-subprocess": "^6.1.0",
818 "bare-url": "^2.3.2",
819 "bare-utils": "^1.6.0",
820 "error-stack-parser": "^2.1.4",
821 "globbie": "^1.0.3",
822 "paparam": "^1.10.0",
823 "same-object": "^1.0.2",
824 "streamx": "^2.25.0",
825 "test-tmp": "^1.4.0",
826 "tmatch": "^5.0.0",
827 "which-runtime": "^1.3.2"
828 },
829 "bin": {
830 "brittle": "advise.js",
831 "brittle-bare": "brittle-bare.js",
832 "brittle-make-test": "make-test.js",
833 "brittle-node": "brittle-node.js"
834 }
835 },
836 "node_modules/brittle/node_modules/bare-assert": {
837 "version": "1.2.0",
838 "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz",
839 "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==",
840 "dev": true,
841 "license": "Apache-2.0",
842 "dependencies": {
843 "bare-inspect": "^3.1.2"
844 }
845 },
846 "node_modules/compact-encoding": {
847 "version": "3.5.2",
848 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz",
849 "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==",
850 "license": "Apache-2.0",
851 "dependencies": {
852 "b4a": "^1.3.0"
853 }
854 },
855 "node_modules/convert-source-map": {
856 "version": "2.0.0",
857 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
858 "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
859 "dev": true,
860 "license": "MIT"
861 },
862 "node_modules/error-stack-parser": {
863 "version": "2.1.4",
864 "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
865 "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
866 "dev": true,
867 "license": "MIT",
868 "dependencies": {
869 "stackframe": "^1.3.4"
870 }
871 },
872 "node_modules/events-universal": {
873 "version": "1.0.1",
874 "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
875 "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
876 "license": "Apache-2.0",
877 "dependencies": {
878 "bare-events": "^2.7.0"
879 }
880 },
881 "node_modules/fast-fifo": {
882 "version": "1.3.2",
883 "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
884 "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
885 "license": "MIT"
886 },
887 "node_modules/globbie": {
888 "version": "1.0.6",
889 "resolved": "https://registry.npmjs.org/globbie/-/globbie-1.0.6.tgz",
890 "integrity": "sha512-GcxSSfWinZNZNW3+RsPBi2nW4jJcUJsOKiJvuXoeO3Y8pdd045NVPILCAoNCzYUsPw5XAUA/GXwRXF+ABcAeOQ==",
891 "dev": true,
892 "license": "Apache-2.0",
893 "dependencies": {
894 "bare-fs": "^4.7.4",
895 "bare-path": "^3.1.1",
896 "bare-process": "^4.5.1",
897 "picomatch": "^4.0.5"
898 }
899 },
900 "node_modules/paparam": {
901 "version": "1.13.0",
902 "resolved": "https://registry.npmjs.org/paparam/-/paparam-1.13.0.tgz",
903 "integrity": "sha512-WV8vCnEBPKcSSEIDSaAetppO+C0+OUJ6gjO1BGbhvIp2jc8zfV9C/8NARjEGgnUo5DDwdNsGOrFTZKLFTTeB0A==",
904 "dev": true,
905 "license": "Apache-2.0"
906 },
907 "node_modules/picomatch": {
908 "version": "4.0.7",
909 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
910 "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
911 "dev": true,
912 "license": "MIT",
913 "engines": {
914 "node": ">=12"
915 },
916 "funding": {
917 "url": "https://github.com/sponsors/jonschlinkert"
918 }
919 },
920 "node_modules/require-addon": {
921 "version": "1.3.0",
922 "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.3.0.tgz",
923 "integrity": "sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==",
924 "dev": true,
925 "license": "Apache-2.0",
926 "dependencies": {
927 "bare-addon-resolve": "^1.3.0"
928 },
929 "engines": {
930 "bare": ">=1.10.0"
931 }
932 },
933 "node_modules/same-object": {
934 "version": "1.0.2",
935 "resolved": "https://registry.npmjs.org/same-object/-/same-object-1.0.2.tgz",
936 "integrity": "sha512-csHWhvUsLbIOHDM/nP+KHWM+BLPsIzWkFa8HbzaI0G7BqKXgx+7FJpKTGgLXyz5amfdY2OVBcmXTqYOMEk04og==",
937 "dev": true,
938 "license": "MIT"
939 },
940 "node_modules/stackframe": {
941 "version": "1.3.4",
942 "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
943 "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
944 "dev": true,
945 "license": "MIT"
946 },
947 "node_modules/streamx": {
948 "version": "2.28.1",
949 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
950 "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
951 "license": "MIT",
952 "dependencies": {
953 "events-universal": "^1.0.0",
954 "fast-fifo": "^1.3.2",
955 "text-decoder": "^1.1.0"
956 }
957 },
958 "node_modules/teex": {
959 "version": "1.0.1",
960 "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
961 "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
962 "license": "MIT",
963 "dependencies": {
964 "streamx": "^2.12.5"
965 }
966 },
967 "node_modules/test-tmp": {
968 "version": "1.4.0",
969 "resolved": "https://registry.npmjs.org/test-tmp/-/test-tmp-1.4.0.tgz",
970 "integrity": "sha512-GVggxGg+jXqP2Wbju50JVLo+9E+nIOPPyWqgr63EbOnNItIKu1cEbJpTWAJeflnyGqXOtcMI7ijHRp88GUkfDA==",
971 "dev": true,
972 "license": "MIT",
973 "dependencies": {
974 "bare-fs": "^4.0.1",
975 "bare-os": "^3.3.0",
976 "bare-path": "^3.0.0"
977 }
978 },
979 "node_modules/text-decoder": {
980 "version": "1.2.7",
981 "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
982 "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
983 "license": "Apache-2.0",
984 "dependencies": {
985 "b4a": "^1.6.4"
986 }
987 },
988 "node_modules/tmatch": {
989 "version": "5.0.0",
990 "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-5.0.0.tgz",
991 "integrity": "sha512-Ib9OtBkpHn07tXP04SlN1SYRxFgTk6wSM2EBmjjxug4u5RXPRVLkdFJSS1PmrQidaSB8Lru9nRtViQBsbxzE5Q==",
992 "dev": true,
993 "license": "ISC",
994 "engines": {
995 "node": ">=8"
996 }
997 },
998 "node_modules/v8-to-istanbul": {
999 "version": "9.3.0",
1000 "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
1001 "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
1002 "dev": true,
1003 "license": "ISC",
1004 "dependencies": {
1005 "@jridgewell/trace-mapping": "^0.3.12",
1006 "@types/istanbul-lib-coverage": "^2.0.1",
1007 "convert-source-map": "^2.0.0"
1008 },
1009 "engines": {
1010 "node": ">=10.12.0"
1011 }
1012 },
1013 "node_modules/which-runtime": {
1014 "version": "1.4.0",
1015 "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.4.0.tgz",
1016 "integrity": "sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==",
1017 "dev": true,
1018 "license": "Apache-2.0"
1019 }
1020 }
1021}
webui/package.json +50 -0
……@@ -0,0 +1,50 @@
1{
2 "name": "cthuloid-web",
3 "version": "0.1.0",
4 "description": "Web control for cthuloid ESP32 light units (esp324xlight) \u2014 discovers units over mDNS and drives them from a browser on the LAN.",
5 "type": "module",
6 "main": "index.js",
7 "bin": {
8 "cthuloid-web": "index.js"
9 },
10 "scripts": {
11 "test": "brittle-bare test/*.test.js"
12 },
13 "dependencies": {
14 "bare-events": "^2.9.2",
15 "bare-fs": "^4.8.1",
16 "bare-http1": "^4.6.1",
17 "bare-net": "^2.3.3",
18 "bare-os": "^3.9.3",
19 "bare-path": "^3.1.2",
20 "bare-process": "^4.5.1",
21 "bare-subprocess": "6.1.0"
22 },
23 "devDependencies": {
24 "brittle": "4.1.1"
25 },
26 "pgh": {
27 "bin": "cthuloid-web",
28 "entrypoint": "index.js",
29 "type": "daemon",
30 "runtime": "bare",
31 "autostart": false,
32 "depends": [
33 "pgh-sidecar"
34 ],
35 "defaults": {
36 "env": {
37 "CTHULOID_WEB_PORT": "4050",
38 "CTHULOID_WEB_HOST": "0.0.0.0"
39 }
40 },
41 "service": {
42 "requiresNetwork": true
43 }
44 },
45 "overrides": {
46 "bare-type": "1.1.1",
47 "bare-structured-clone": "1.6.0",
48 "bare-assert": "1.2.0"
49 }
50}
webui/static/app.css +116 -0
……@@ -0,0 +1,116 @@
1:root {
2 --bg: #f6f6f4;
3 --panel: #ffffff;
4 --ink: #1d1d1f;
5 --muted: #6b6b70;
6 --line: #dcdcd8;
7 --accent: #2f6fde;
8 --hot: #c62828;
9 --hot-ink: #ffffff;
10 --ok: #2e7d32;
11 --warn: #b26a00;
12 --bad: #c62828;
13}
14@media (prefers-color-scheme: dark) {
15 :root {
16 --bg: #141416;
17 --panel: #1d1d21;
18 --ink: #ececef;
19 --muted: #9a9aa2;
20 --line: #34343a;
21 --accent: #6ea0ff;
22 --hot: #e53935;
23 --ok: #66bb6a;
24 --warn: #ffb74d;
25 --bad: #ef5350;
26 }
27}
28* { box-sizing: border-box; }
29/* `hidden` must win over the display rules below (e.g. .slot is a grid). */
30[hidden] { display: none !important; }
31body {
32 margin: 0;
33 background: var(--bg);
34 color: var(--ink);
35 font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
36}
37h1 { font-size: 1.15rem; margin: 0; }
38h2 { font-size: 1rem; margin: 0 0 .6rem; }
39button, input { font: inherit; color: inherit; }
40button {
41 border: 1px solid var(--line);
42 background: var(--panel);
43 border-radius: 8px;
44 padding: .45rem .8rem;
45 cursor: pointer;
46}
47button:disabled { opacity: .5; cursor: default; }
48input[type=number], input[type=text], input:not([type]) {
49 background: var(--bg);
50 border: 1px solid var(--line);
51 border-radius: 6px;
52 padding: .35rem .5rem;
53 min-width: 0;
54}
55input[type=number] { width: 6.5em; }
56.bar {
57 position: sticky;
58 top: 0;
59 z-index: 2;
60 display: flex;
61 flex-wrap: wrap;
62 gap: .6rem;
63 align-items: center;
64 justify-content: space-between;
65 padding: .75rem 16px;
66 background: var(--panel);
67 border-bottom: 1px solid var(--line);
68}
69.bar-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
70.connect { font-weight: 700; min-width: 9rem; }
71.connect.on { background: var(--hot); border-color: var(--hot); color: var(--hot-ink); }
72.danger { color: var(--bad); }
73.banner { margin: 0; padding: .5rem 16px; color: var(--muted); }
74.banner.on { color: var(--hot); font-weight: 600; }
75.notice { margin: .5rem 16px; padding: .5rem .75rem; border-radius: 8px; border: 1px solid var(--warn); color: var(--warn); }
76main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; }
77#units { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr)); margin: .75rem 0; }
78.unit, .panel {
79 background: var(--panel);
80 border: 1px solid var(--line);
81 border-radius: 12px;
82 padding: .8rem;
83}
84.panel { margin-top: 12px; }
85.unit.off { opacity: .75; }
86.unit-head { display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
87.include { display: flex; align-items: center; gap: .45rem; font-weight: 600; cursor: pointer; }
88.include input { width: 1.15rem; height: 1.15rem; }
89.status { font-size: .85rem; white-space: nowrap; }
90.status::before { content: "●"; margin-right: .3rem; }
91.status.connected { color: var(--ok); }
92.status.connecting { color: var(--warn); }
93.status.unreachable { color: var(--bad); }
94.status.idle, .status.off { color: var(--muted); }
95.status.idle::before, .status.off::before { content: "○"; }
96.where { margin: .15rem 0 .5rem; }
97.note { margin: 0 0 .5rem; color: var(--warn); }
98.slots { display: grid; gap: .4rem; }
99.slot { display: grid; grid-template-columns: 3.2rem 2.6rem 1fr 2.8rem 1.6rem; gap: .5rem; align-items: center; }
100.slot input[type=color] { width: 2.6rem; height: 2rem; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
101.slot input[type=range] { width: 100%; }
102.actual { width: 1.6rem; height: 1.6rem; border-radius: 50%; border: 1px solid var(--line); background: #000; }
103.slots.empty { color: var(--muted); }
104.unit-actions { margin-top: .6rem; }
105.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
106.row input:not([type]) { flex: 1; }
107.firmware { margin-top: .6rem; }
108.firmware summary { cursor: pointer; color: var(--muted); }
109.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: .5rem; margin: .5rem 0; }
110.grid label { display: grid; gap: .2rem; font-size: .85rem; color: var(--muted); }
111.grid input { width: 100%; }
112.looks { list-style: none; padding: 0; margin: .6rem 0 0; display: grid; gap: .4rem; }
113.looks li { display: flex; gap: .5rem; align-items: center; }
114.looks li span { flex: 1; overflow-wrap: anywhere; }
115.muted { color: var(--muted); }
116.small { font-size: .85rem; }
webui/static/app.js +263 -0
……@@ -0,0 +1,263 @@
1// Cthuloid Lights page. Server state arrives over /api/events (SSE) and is the
2// only source of truth; controls send changes and wait to be told the result.
3'use strict'
4
5const $ = (sel, el) => (el || document).querySelector(sel)
6
7// ---- talking to the server --------------------------------------------------
8
9async function post (path, body) {
10 const res = await fetch(path, {
11 method: 'POST',
12 // The custom header is the cross-site guard: the server refuses POSTs
13 // without it, and a foreign page cannot add it without a CORS preflight.
14 headers: { 'content-type': 'application/json', 'x-cthuloid': '1' },
15 body: JSON.stringify(body || {})
16 })
17 const out = await res.json().catch(() => ({}))
18 if (!res.ok) throw new Error(out.error || res.statusText)
19 return out
20}
21
22// Latest-wins sender per control: at most one request in flight, and only the
23// newest value is sent next. A drag can't queue a backlog, and a slow reply
24// can never land an older value after a newer one.
25const senders = new Map()
26function sendLatest (key, path, body) {
27 let s = senders.get(key)
28 if (!s) {
29 s = { busy: false, next: null }
30 senders.set(key, s)
31 }
32 s.next = { path, body }
33 if (!s.busy) pump(s)
34}
35async function pump (s) {
36 s.busy = true
37 while (s.next) {
38 const { path, body } = s.next
39 s.next = null
40 try {
41 await post(path, body)
42 } catch (err) {
43 console.warn(path, err.message)
44 }
45 }
46 s.busy = false
47}
48
49function act (fn) {
50 return (ev) => {
51 if (ev) ev.preventDefault()
52 fn().catch((err) => alert(err.message))
53 }
54}
55
56// ---- colour helpers ---------------------------------------------------------
57
58const hex2 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0')
59const toHex = (s) => '#' + hex2(s.r) + hex2(s.g) + hex2(s.b)
60function fromHex (h) {
61 const n = parseInt(h.slice(1), 16)
62 return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 }
63}
64
65// A unit's readback (BGR per slot, red capped) as a CSS colour.
66function actualCss (values, i, redMax) {
67 const red = Math.min(redMax, 45000) || 1
68 const b = values[i * 3] / 65535
69 const g = values[i * 3 + 1] / 65535
70 const r = Math.min(1, values[i * 3 + 2] / red)
71 return 'rgb(' + [r, g, b].map((v) => Math.round(v * 255)).join(',') + ')'
72}
73
74// Same words the Blender addon uses, so the two read alike.
75function stateWords (st, u) {
76 if (!u.enabled) return ['off', 'off']
77 if (!st.connected) return ['not connected', 'off']
78 if (u.status === 'connected') return ['connected', 'connected']
79 if (u.status === 'unreachable') return ['unreachable', 'unreachable']
80 return ['connecting…', 'connecting']
81}
82
83// Don't overwrite a control the user is touching with an echo of an older value.
84function setValue (el, v) {
85 if (document.activeElement === el) return
86 if (String(el.value) !== String(v)) el.value = v
87}
88
89// ---- rendering ----------------------------------------------------------------
90
91const cards = new Map()
92let state = null
93
94function render (st) {
95 state = st
96 const connect = $('#connect')
97 connect.textContent = st.connected ? '● CONNECTED — live (click to disconnect)' : 'CONNECT'
98 connect.classList.toggle('on', st.connected)
99 const banner = $('#banner')
100 banner.textContent = st.connected
101 ? 'Changes go straight to the included lights.'
102 : 'Not connected: nothing is sent to the lights.'
103 banner.classList.toggle('on', st.connected)
104
105 const disc = $('#discovery')
106 disc.hidden = st.discovery.available
107 disc.textContent = 'Automatic discovery is not working on the server: ' + st.discovery.reason
108
109 const seen = new Set()
110 for (const u of st.units) {
111 seen.add(u.id)
112 let card = cards.get(u.id)
113 if (!card) {
114 card = makeCard(u.id)
115 cards.set(u.id, card)
116 $('#units').appendChild(card.el)
117 }
118 updateCard(card, u, st)
119 }
120 for (const [id, card] of cards) {
121 if (!seen.has(id)) {
122 card.el.remove()
123 cards.delete(id)
124 }
125 }
126 $('#no-units').hidden = st.units.length > 0
127
128 const list = $('#looks')
129 list.replaceChildren(...st.looks.map((name) => {
130 const li = document.createElement('li')
131 const label = document.createElement('span')
132 label.textContent = name
133 const recall = document.createElement('button')
134 recall.type = 'button'
135 recall.textContent = 'Recall'
136 recall.disabled = !st.connected
137 recall.title = st.connected ? 'Fade the included units to this look' : 'Connect first'
138 recall.onclick = act(() => post('/api/looks/' + encodeURIComponent(name) + '/recall',
139 { fadeMs: Math.round(Number($('#recall-fade').value) * 1000) }))
140 const del = document.createElement('button')
141 del.type = 'button'
142 del.className = 'danger'
143 del.textContent = 'Delete'
144 del.onclick = act(async () => {
145 if (confirm('Delete the look "' + name + '"?')) await post('/api/looks/' + encodeURIComponent(name) + '/delete')
146 })
147 li.append(label, recall, del)
148 return li
149 }))
150}
151
152function makeCard (id) {
153 const el = $('#unit-tpl').content.firstElementChild.cloneNode(true)
154 const base = '/api/units/' + encodeURIComponent(id)
155 const slots = []
156 for (let i = 0; i < 4; i++) {
157 const row = $('#slot-tpl').content.firstElementChild.cloneNode(true)
158 $('.slot-n', row).textContent = 'Slot ' + (i + 1)
159 const color = $('.color', row)
160 const level = $('.level', row)
161 const send = () => {
162 const c = fromHex(color.value)
163 $('.pct', row).textContent = level.value + '%'
164 sendLatest(id + ':' + i, base + '/slot', { index: i, r: c.r, g: c.g, b: c.b, level: Number(level.value) / 100 })
165 }
166 color.addEventListener('input', send)
167 level.addEventListener('input', send)
168 $('.slots', el).appendChild(row)
169 slots.push(row)
170 }
171
172 $('.enabled', el).addEventListener('change', (ev) => sendLatest(id + ':enabled', base, { enabled: ev.target.checked }))
173 $('.fade', el).addEventListener('change', (ev) => sendLatest(id + ':fade', base, { fadeMs: Number(ev.target.value) }))
174 $('.label-in', el).addEventListener('change', (ev) => sendLatest(id + ':label', base, { label: ev.target.value }))
175 $('.redmax', el).addEventListener('change', (ev) => sendLatest(id + ':redmax', base, { redMax: Number(ev.target.value) }))
176 for (const k of ['gb', 'gg', 'gr']) {
177 $('.' + k, el).addEventListener('change', () => sendLatest(id + ':gamma', base, {
178 gamma: { b: Number($('.gb', el).value), g: Number($('.gg', el).value), r: Number($('.gr', el).value) }
179 }))
180 }
181 $('.freq', el).addEventListener('change', (ev) => sendLatest(id + ':freq', base, { freq: Number(ev.target.value) }))
182 $('.push-settings', el).onclick = act(() => post(base + '/settings'))
183 $('.adopt', el).onclick = act(() => post(base + '/adopt'))
184 $('.blackout', el).onclick = act(() => post(base + '/blackout'))
185 return { el, slots }
186}
187
188function updateCard (card, u, st) {
189 const el = card.el
190 el.classList.toggle('off', !u.enabled)
191 $('.label', el).textContent = u.label
192 $('.enabled', el).checked = u.enabled
193 const [words, cls] = stateWords(st, u)
194 const status = $('.status', el)
195 status.textContent = words
196 status.className = 'status ' + cls
197 status.title = u.error || ''
198
199 const where = u.online ? 'on the network at ' + u.addr : (u.manual ? 'added by hand: ' + u.host : 'not seen on the network')
200 $('.where', el).textContent = u.id + ' — ' + where
201
202 const note = $('.note', el)
203 const oldFw = u.online && u.proto < 2
204 note.hidden = !oldFw && u.slots !== null
205 note.textContent = oldFw
206 ? 'Older firmware: no readback, so the dots show nothing.'
207 : 'No look yet: include this unit and connect, and it will pick up what the light is showing now.'
208
209 const actual = u.actual ? u.actual.values : null
210 for (let i = 0; i < 4; i++) {
211 const row = card.slots[i]
212 const s = u.slots ? u.slots[i] : null
213 row.hidden = !u.slots
214 if (s) {
215 setValue($('.color', row), toHex(s))
216 setValue($('.level', row), Math.round(s.level * 100))
217 $('.pct', row).textContent = Math.round(s.level * 100) + '%'
218 }
219 $('.actual', row).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent'
220 }
221
222 setValue($('.fade', el), u.fadeMs)
223 setValue($('.label-in', el), u.label)
224 setValue($('.redmax', el), u.redMax)
225 setValue($('.gb', el), u.gamma.b)
226 setValue($('.gg', el), u.gamma.g)
227 setValue($('.gr', el), u.gamma.r)
228 setValue($('.freq', el), u.freq)
229 const live = st.connected && u.status === 'connected'
230 $('.push-settings', el).disabled = !live
231 $('.adopt', el).disabled = !u.actual
232}
233
234// ---- wiring -------------------------------------------------------------------
235
236$('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) }))
237$('#blackout-all').onclick = act(() => post('/api/blackout'))
238$('#save-look').onsubmit = act(async () => {
239 await post('/api/looks', { name: $('#look-name').value })
240 $('#look-name').value = ''
241})
242$('#add-unit').onsubmit = act(async () => {
243 const out = $('#add-result')
244 out.textContent = 'Asking the unit its name…'
245 try {
246 const r = await post('/api/units', { host: $('#add-host').value })
247 out.textContent = 'Added ' + r.id + '.'
248 $('#add-host').value = ''
249 } catch (err) {
250 out.textContent = err.message
251 }
252})
253
254function listen () {
255 const es = new EventSource('/api/events')
256 es.addEventListener('state', (ev) => {
257 $('#offline').hidden = true
258 render(JSON.parse(ev.data))
259 })
260 // EventSource reconnects by itself; just say so meanwhile.
261 es.onerror = () => { $('#offline').hidden = false }
262}
263listen()
webui/static/index.html +88 -0
……@@ -0,0 +1,88 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Cthuloid Lights</title>
7<link rel="stylesheet" href="/app.css">
8</head>
9<body>
10<header class="bar">
11 <h1>Cthuloid Lights</h1>
12 <div class="bar-actions">
13 <button id="connect" class="connect" type="button">CONNECT</button>
14 <button id="blackout-all" class="danger" type="button" title="Stop fades and drive every unit to zero, connected or not">Blackout all</button>
15 </div>
16</header>
17<p id="banner" class="banner">Not connected: nothing is sent to the lights.</p>
18<p id="discovery" class="notice" hidden></p>
19<p id="offline" class="notice" hidden>Lost contact with the server. Retrying…</p>
20
21<main>
22 <section id="units" aria-label="Light units"></section>
23 <p id="no-units" class="muted" hidden>No units found yet. Units with current firmware appear here on their own; older ones can be added below.</p>
24
25 <section class="panel" aria-labelledby="looks-h">
26 <h2 id="looks-h">Looks</h2>
27 <form id="save-look" class="row">
28 <input id="look-name" placeholder="Name this look" maxlength="64" required>
29 <button type="submit">Save current</button>
30 </form>
31 <label class="row">Recall fade <input id="recall-fade" type="number" min="0" max="600" step="0.1" value="1"> s</label>
32 <ul id="looks" class="looks"></ul>
33 </section>
34
35 <section class="panel" aria-labelledby="add-h">
36 <h2 id="add-h">Add a unit by address</h2>
37 <form id="add-unit" class="row">
38 <input id="add-host" placeholder="showlight01.local or 192.168.50.x" required>
39 <button type="submit">Add</button>
40 </form>
41 <p class="muted small">For units on firmware that does not announce itself. Connecting briefly takes over a unit on old single-client firmware.</p>
42 <p id="add-result" class="small"></p>
43 </section>
44</main>
45
46<template id="unit-tpl">
47 <article class="unit">
48 <div class="unit-head">
49 <label class="include" title="Include this unit: drive it while connected"><input type="checkbox" class="enabled"> <span class="label"></span></label>
50 <span class="status"></span>
51 </div>
52 <p class="where muted small"></p>
53 <p class="note small" hidden></p>
54 <div class="slots"></div>
55 <div class="unit-actions row">
56 <label>Fade <input type="number" class="fade" min="0" max="10000" step="10"> ms</label>
57 <button type="button" class="adopt" title="Set the controls to what the unit is actually showing">Match lights</button>
58 <button type="button" class="danger blackout">Blackout</button>
59 </div>
60 <details class="firmware">
61 <summary>Firmware settings</summary>
62 <div class="grid">
63 <label>Name <input type="text" class="label-in" maxlength="64"></label>
64 <label>Red max <input type="number" class="redmax" min="0" max="45000" step="500"></label>
65 <label>Gamma B <input type="number" class="gb" min="0.5" max="5" step="0.05"></label>
66 <label>Gamma G <input type="number" class="gg" min="0.5" max="5" step="0.05"></label>
67 <label>Gamma R <input type="number" class="gr" min="0.5" max="5" step="0.05"></label>
68 <label>PWM Hz <input type="number" class="freq" min="1" max="40000" step="100"></label>
69 </div>
70 <button type="button" class="push-settings">Send gamma &amp; frequency</button>
71 <p class="muted small">Red max caps the red diodes, which take less drive than green and blue. The firmware rejects anything above 45000. PWM above ~1.2 kHz dims this hardware.</p>
72 </details>
73 </article>
74</template>
75
76<template id="slot-tpl">
77 <div class="slot">
78 <span class="slot-n"></span>
79 <input type="color" class="color" aria-label="Colour">
80 <input type="range" class="level" min="0" max="100" step="1" aria-label="Brightness">
81 <span class="pct small"></span>
82 <span class="actual" title="What the unit is actually showing"></span>
83 </div>
84</template>
85
86<script src="/app.js"></script>
87</body>
88</html>
webui/test/discovery.test.js +51 -0
……@@ -0,0 +1,51 @@
1import test from 'brittle'
2import { parseAvahiLine, Discovery } from '../lib/discovery.js'
3import { waitFor } from './helpers.js'
4
5// Captured from `avahi-browse -rpt _cthuloid._tcp` on stortron.
6const RESOLVED = '=;enp2s0;IPv4;studiolight;_cthuloid._tcp;local;studiolight.local;192.168.50.89;1337;"proto=2"'
7
8test('resolved line becomes an up event', (t) => {
9 t.alike(parseAvahiLine(RESOLVED), {
10 type: 'up', name: 'studiolight', host: 'studiolight.local', addr: '192.168.50.89', port: 1337, proto: 2
11 })
12})
13
14test('removal, browse-only, IPv6 and junk lines', (t) => {
15 t.alike(parseAvahiLine('-;enp2s0;IPv4;studiolight;_cthuloid._tcp;local'), { type: 'down', name: 'studiolight' })
16 t.is(parseAvahiLine('+;enp2s0;IPv4;studiolight;_cthuloid._tcp;local'), null, 'unresolved add waits for =')
17 t.is(parseAvahiLine('=;enp2s0;IPv6;studiolight;_cthuloid._tcp;local;studiolight.local;fe80::1;1337;"proto=2"'), null)
18 t.is(parseAvahiLine('Failed to create client object: Daemon not running'), null)
19 t.is(parseAvahiLine(''), null)
20})
21
22test('avahi decimal escapes are decoded, missing TXT tolerated', (t) => {
23 const ev = parseAvahiLine('=;eth0;IPv4;stage\\032left;_cthuloid._tcp;local;stage-left.local;10.0.0.7;1337;')
24 t.is(ev.name, 'stage left')
25 t.is(ev.proto, 0)
26})
27
28test('Discovery emits up/down from a running browser process', async (t) => {
29 // A stand-in for avahi-browse: prints an add, a resolve, then a removal.
30 const script = 'printf "%s\\n" ' +
31 "'+;e;IPv4;u1;_cthuloid._tcp;local' " +
32 "'=;e;IPv4;u1;_cthuloid._tcp;local;u1.local;10.0.0.5;1337;\"proto=2\"' " +
33 "'-;e;IPv4;u1;_cthuloid._tcp;local'; exec sleep 5"
34 const d = new Discovery({ command: ['sh', '-c', script], respawnMs: 60000 })
35 const events = []
36 d.on('up', (u) => events.push(['up', u.name, u.addr]))
37 d.on('down', (u) => events.push(['down', u.name]))
38 d.start()
39 t.ok(await waitFor(() => events.length === 2), 'two events')
40 t.alike(events, [['up', 'u1', '10.0.0.5'], ['down', 'u1']])
41 d.stop()
42})
43
44test('a missing avahi-browse is reported, not fatal', async (t) => {
45 const d = new Discovery({ command: ['cthuloid-no-such-binary'], respawnMs: 60000 })
46 let why = null
47 d.on('unavailable', (msg) => { why = msg })
48 d.start()
49 t.ok(await waitFor(() => why !== null), 'unavailable emitted: ' + why)
50 d.stop()
51})
webui/test/helpers.js +68 -0
……@@ -0,0 +1,68 @@
1// A stand-in for an esp324xlight unit: greets "<name> ready", records every
2// line it receives, answers `state`, and can drop its clients on demand.
3import net from 'bare-net'
4
5export class FakeUnit {
6 constructor (name = 'fake') {
7 this.name = name
8 this.lines = []
9 this.conns = new Set()
10 this.accepted = 0
11 this.values = new Array(12).fill(0)
12 this.server = net.createServer((c) => this._onConn(c))
13 }
14
15 listen () {
16 return new Promise((resolve) => {
17 this.server.listen(0, '127.0.0.1', () => {
18 this.port = this.server.address().port
19 resolve(this)
20 })
21 })
22 }
23
24 _onConn (c) {
25 this.accepted++
26 this.conns.add(c)
27 c.on('close', () => this.conns.delete(c))
28 c.on('error', () => {})
29 c.write(this.name + ' ready\r\n')
30 let buf = ''
31 c.on('data', (d) => {
32 buf += d.toString()
33 let i
34 while ((i = buf.indexOf('\n')) !== -1) {
35 const line = buf.slice(0, i).trim()
36 buf = buf.slice(i + 1)
37 if (!line) continue
38 this.lines.push(line)
39 if (line === 'state') {
40 c.write('state 0 1000 1.00 1.00 1.00 ' + this.values.join(' ') + '\r\n')
41 }
42 }
43 })
44 }
45
46 dropClients () {
47 for (const c of this.conns) c.destroy()
48 }
49
50 close () {
51 this.dropClients()
52 return new Promise((resolve) => this.server.close(resolve))
53 }
54}
55
56export function waitFor (pred, timeout = 3000) {
57 return new Promise((resolve) => {
58 const end = Date.now() + timeout
59 const tick = () => {
60 if (pred()) return resolve(true)
61 if (Date.now() > end) return resolve(false)
62 setTimeout(tick, 10)
63 }
64 tick()
65 })
66}
67
68export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
webui/test/protocol.test.js +56 -0
……@@ -0,0 +1,56 @@
1// Vectors generated from blender-addon/cthuloid_lights/protocol.py, so the web
2// UI and the addon emit byte-identical commands for the same look.
3import test from 'brittle'
4import * as p from '../lib/protocol.js'
5
6const RGBE = [
7 [[1, 1, 1, 1000, 30000, false, 1000], [65535, 65535, 30000]],
8 [[0.5, 0.25, 0.125, 1000, 30000, false, 1000], [8192, 16384, 15000]],
9 [[1, 0, 0, 1000, 45000, false, 1000], [0, 0, 45000]],
10 [[1, 1, 1, 500, 30000, true, 1000], [32768, 32768, 15000]],
11 [[-0.3, 1.7, 0.02, 0, 30000, false, 1000], [1311, 65535, 9000]],
12 [[1, 0.5, 0, 1000, 60000, false, 1000], [0, 32768, 45000]]
13]
14
15const PICK = [
16 [0, [1, 1]], [1, [1, 1]], [60, [1, 60]], [999, [1, 999]], [1000, [1, 1000]],
17 [1001, [500, 2]], [2500, [833, 3]], [59999, [999, 60]], [1000000, [1000, 1000]],
18 [5000000, [1000, 1000]]
19]
20
21test('channels match protocol.py', (t) => {
22 for (const [args, want] of RGBE) t.alike(p.channelsFromRGBE(...args), want, JSON.stringify(args))
23})
24
25test('pwm2 period split matches protocol.py', (t) => {
26 for (const [ms, want] of PICK) t.alike(p.pickPwm2Args(ms), want, 'fade ' + ms)
27})
28
29test('command strings match protocol.py', (t) => {
30 t.is(p.pwmCommand([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65535]), 'pwm 0 1 2 3 4 5 6 7 8 9 10 65535\n')
31 t.is(p.pwm2Command([1, 2, 3], 2500), 'pwm2 833 3 1 2 3\n')
32 t.is(p.gammaCommand(0.1, 2.345, 9), 'gamma 0.50 2.35 5.00\n')
33 t.alike([0, 1000, 99999].map(p.freqCommand), ['freq 1\n', 'freq 1000\n', 'freq 40000\n'])
34 t.is(p.blackoutCommand(), 'stopfade\npwm 0 0 0 0 0 0 0 0 0 0 0 0\n')
35})
36
37test('halves round to even, as Python does', (t) => {
38 t.is(p.roundHalfEven(2.5), 2)
39 t.is(p.roundHalfEven(3.5), 4)
40 t.is(p.roundHalfEven(32767.5), 32768)
41 t.is(p.roundHalfEven(2.4999), 2)
42})
43
44test('unit values: slots in BGR order, red capped, empty slots dark', (t) => {
45 const slots = [{ r: 1, g: 0, b: 0, level: 1 }, { r: 0, g: 0, b: 1, level: 0.5 }]
46 t.alike(p.unitValues(slots, 30000), [0, 0, 30000, 32768, 0, 0, 0, 0, 0, 0, 0, 0])
47})
48
49test('state and greeting parsing', (t) => {
50 const s = p.parseState('state 1 1000 1.00 2.00 1.50 1 2 3 4 5 6 7 8 9 10 11 65535\r\n')
51 t.alike(s, { fading: true, freq: 1000, gamma: { b: 1, g: 2, r: 1.5 }, values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 65535] })
52 t.is(p.parseState('OK pwm 12'), null)
53 t.is(p.parseState('state 0 1000 1 1 1 1 2'), null)
54 t.is(p.parseGreeting('studiolight ready\r\n'), 'studiolight')
55 t.is(p.parseGreeting('hostname: studiolight'), null)
56})
webui/test/rig.test.js +138 -0
……@@ -0,0 +1,138 @@
1import test from 'brittle'
2import EventEmitter from 'bare-events'
3import { Rig } from '../lib/rig.js'
4import { FakeUnit, waitFor, sleep } from './helpers.js'
5
6// Discovery is injected: tests announce units directly.
7class FakeDiscovery extends EventEmitter {
8 constructor () { super(); this.available = true; this.reason = '' }
9 start () {}
10 stop () {}
11 announce (fake) {
12 this.emit('up', { name: fake.name, host: fake.name + '.local', addr: '127.0.0.1', port: fake.port, proto: 2 })
13 }
14}
15
16class MemStore {
17 constructor (data = {}) { this.data = data; this.saves = 0 }
18 load () { return this.data }
19 save (data) { this.data = JSON.parse(JSON.stringify(data)); this.saves++ }
20}
21
22function makeRig (store = new MemStore()) {
23 const discovery = new FakeDiscovery()
24 const rig = new Rig({ store, discovery, readbackMs: 100, unitOpts: { backoffStartMs: 100 } })
25 rig.start()
26 return { rig, discovery, store }
27}
28
29const pwms = (fake) => fake.lines.filter((l) => l.startsWith('pwm'))
30
31test('starts disconnected; a discovered unit is listed, unticked, and untouched', async (t) => {
32 const fake = await new FakeUnit('u1').listen()
33 const { rig, discovery } = makeRig()
34 discovery.announce(fake)
35 const u = rig.snapshot().units.find((x) => x.id === 'u1')
36 t.ok(u, 'listed')
37 t.is(u.enabled, false, 'new units start unticked')
38 t.is(rig.snapshot().connected, false)
39 rig.setConnected(true)
40 await sleep(300)
41 t.is(fake.accepted, 0, 'unticked unit never contacted')
42 rig.stop()
43 await fake.close()
44})
45
46test('first connect adopts the unit\'s actual look instead of overwriting it', async (t) => {
47 const fake = await new FakeUnit('u1').listen()
48 fake.values = [65535, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] // slot 1 blue, as Blender left it
49 const { rig, discovery } = makeRig()
50 discovery.announce(fake)
51 rig.updateUnit('u1', { enabled: true })
52 rig.setConnected(true)
53 t.ok(await waitFor(() => rig.snapshot().units[0].slots !== null), 'slots adopted from state')
54 const s = rig.snapshot().units[0].slots[0]
55 t.alike([s.r, s.g, s.b, s.level], [0, 0, 1, 1], 'slot 1 reads as full blue')
56 t.is(pwms(fake).length, 0, 'nothing pushed over the existing look')
57 rig.stop()
58 await fake.close()
59})
60
61test('editing a slot while connected drives the unit', async (t) => {
62 const fake = await new FakeUnit('u1').listen()
63 const { rig, discovery } = makeRig()
64 discovery.announce(fake)
65 rig.updateUnit('u1', { enabled: true, fadeMs: 0 })
66 rig.setConnected(true)
67 await waitFor(() => rig.snapshot().units[0].slots !== null)
68 rig.setSlot('u1', 1, { r: 1, g: 0, b: 0, level: 1 })
69 t.ok(await waitFor(() => pwms(fake).some((l) => l === 'pwm 0 0 0 0 0 30000 0 0 0 0 0 0')), 'slot 2 red at the 30000 cap')
70 rig.stop()
71 await fake.close()
72})
73
74test('disconnect closes every unit; edits while disconnected send nothing', async (t) => {
75 const fake = await new FakeUnit('u1').listen()
76 const { rig, discovery } = makeRig()
77 discovery.announce(fake)
78 rig.updateUnit('u1', { enabled: true })
79 rig.setConnected(true)
80 await waitFor(() => fake.conns.size === 1)
81 rig.setConnected(false)
82 t.ok(await waitFor(() => fake.conns.size === 0), 'closed')
83 const before = fake.lines.length
84 rig.setSlot('u1', 0, { r: 1, g: 1, b: 1, level: 1 })
85 await sleep(200)
86 t.is(fake.lines.length, before, 'nothing sent')
87 rig.stop()
88 await fake.close()
89})
90
91test('blackout while disconnected is one-shot and darkens the model', async (t) => {
92 const fake = await new FakeUnit('u1').listen()
93 const { rig, discovery } = makeRig()
94 discovery.announce(fake)
95 rig.updateUnit('u1', { slots: [{ r: 1, g: 1, b: 1, level: 1 }, null, null, null] })
96 rig.blackout()
97 t.ok(await waitFor(() => fake.lines.includes('stopfade')), 'stopfade sent')
98 t.ok(await waitFor(() => fake.conns.size === 0), 'released')
99 t.is(rig.snapshot().units[0].slots[0].level, 0, 'model shows dark')
100 rig.stop()
101 await fake.close()
102})
103
104test('settings survive a restart; connection state does not', async (t) => {
105 const fake = await new FakeUnit('u1').listen()
106 const store = new MemStore()
107 const a = makeRig(store)
108 a.discovery.announce(fake)
109 a.rig.updateUnit('u1', { enabled: true, label: 'Studio', redMax: 20000 })
110 a.rig.setConnected(true)
111 a.rig.saveLook('warm')
112 a.rig.flushSave()
113 a.rig.stop()
114 const b = makeRig(store)
115 const u = b.rig.snapshot().units.find((x) => x.id === 'u1')
116 t.is(u.label, 'Studio')
117 t.is(u.redMax, 20000)
118 t.is(u.enabled, true)
119 t.is(b.rig.snapshot().connected, false, 'a restart comes back disconnected')
120 t.alike(b.rig.snapshot().looks, ['warm'])
121 b.rig.stop()
122 await fake.close()
123})
124
125test('recalling a look fades every unit in it', async (t) => {
126 const fake = await new FakeUnit('u1').listen()
127 const { rig, discovery } = makeRig()
128 discovery.announce(fake)
129 rig.updateUnit('u1', { enabled: true, slots: [{ r: 0, g: 1, b: 0, level: 1 }, null, null, null] })
130 rig.saveLook('green')
131 rig.updateUnit('u1', { slots: [{ r: 0, g: 0, b: 0, level: 0 }, null, null, null] })
132 rig.setConnected(true)
133 await waitFor(() => fake.conns.size === 1)
134 rig.recallLook('green', 2000)
135 t.ok(await waitFor(() => fake.lines.some((l) => l.startsWith('pwm2 1000 2 0 65535 0'))), 'pwm2 over 2 s to green')
136 rig.stop()
137 await fake.close()
138})
webui/test/server.test.js +113 -0
……@@ -0,0 +1,113 @@
1// Drives the REAL routes over HTTP against a rig with a fake unit.
2import test from 'brittle'
3import http from 'bare-http1'
4import EventEmitter from 'bare-events'
5import { Rig } from '../lib/rig.js'
6import { createServer } from '../lib/server.js'
7import { FakeUnit, waitFor } from './helpers.js'
8
9class FakeDiscovery extends EventEmitter {
10 constructor () { super(); this.available = true; this.reason = '' }
11 start () {}
12 stop () {}
13}
14
15async function setup () {
16 const fake = await new FakeUnit('u1').listen()
17 const discovery = new FakeDiscovery()
18 const store = { load: () => ({}), save: () => {} }
19 const rig = new Rig({ store, discovery, readbackMs: 100, unitOpts: { backoffStartMs: 100 } })
20 rig.start()
21 discovery.emit('up', { name: 'u1', host: 'u1.local', addr: '127.0.0.1', port: fake.port, proto: 2 })
22 const server = createServer(rig)
23 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
24 const port = server.address().port
25 const teardown = async () => {
26 rig.stop()
27 await new Promise((resolve) => server.close(resolve))
28 await fake.close()
29 }
30 return { fake, rig, server, port, teardown }
31}
32
33function request (port, method, path, body, headers = {}) {
34 return new Promise((resolve, reject) => {
35 const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
36 let data = ''
37 res.on('data', (d) => { data += d })
38 res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
39 })
40 req.on('error', reject)
41 req.end(body)
42 })
43}
44
45const post = (port, path, obj) => request(port, 'POST', path, JSON.stringify(obj || {}), {
46 'content-type': 'application/json', 'x-cthuloid': '1'
47})
48
49test('page and assets are served', async (t) => {
50 const s = await setup()
51 const page = await request(s.port, 'GET', '/')
52 t.is(page.status, 200)
53 t.ok(page.body.includes('<title>'), 'html')
54 t.is((await request(s.port, 'GET', '/app.js')).status, 200)
55 t.is((await request(s.port, 'GET', '/app.css')).status, 200)
56 t.is((await request(s.port, 'GET', '/nope')).status, 404)
57 await s.teardown()
58})
59
60test('state lists the discovered unit, disconnected', async (t) => {
61 const s = await setup()
62 const res = await request(s.port, 'GET', '/api/state')
63 const st = JSON.parse(res.body)
64 t.is(st.connected, false)
65 t.is(st.units[0].id, 'u1')
66 await s.teardown()
67})
68
69test('a POST without the x-cthuloid header is refused (cross-site guard)', async (t) => {
70 const s = await setup()
71 const res = await request(s.port, 'POST', '/api/connect', '{"on":true}', { 'content-type': 'application/json' })
72 t.is(res.status, 403)
73 t.is(s.rig.connected, false)
74 await s.teardown()
75})
76
77test('tick, connect, set a slot: the unit receives it', async (t) => {
78 const s = await setup()
79 t.is((await post(s.port, '/api/units/u1', { enabled: true, fadeMs: 0 })).status, 200)
80 t.is((await post(s.port, '/api/connect', { on: true })).status, 200)
81 await waitFor(() => s.rig.snapshot().units[0].slots !== null)
82 const res = await post(s.port, '/api/units/u1/slot', { index: 0, r: 0, g: 1, b: 0, level: 1 })
83 t.is(res.status, 200)
84 t.ok(await waitFor(() => s.fake.lines.includes('pwm 0 65535 0 0 0 0 0 0 0 0 0 0')), 'green on slot 1')
85 await s.teardown()
86})
87
88test('bad input is a 400 with a reason, not a crash', async (t) => {
89 const s = await setup()
90 const res = await request(s.port, 'POST', '/api/units/u1', '{not json', { 'content-type': 'application/json', 'x-cthuloid': '1' })
91 t.is(res.status, 400)
92 const unknown = await post(s.port, '/api/units/zzz', { enabled: true })
93 t.is(unknown.status, 400)
94 t.ok(JSON.parse(unknown.body).error.includes('unknown unit'))
95 await s.teardown()
96})
97
98test('events stream sends the state at once and on change', async (t) => {
99 const s = await setup()
100 const frames = []
101 const req = http.request({ host: '127.0.0.1', port: s.port, path: '/api/events' }, (res) => {
102 t.is(res.headers['content-type'], 'text/event-stream')
103 res.on('data', (d) => frames.push(d.toString()))
104 })
105 req.end()
106 t.ok(await waitFor(() => frames.join('').includes('"connected":false')), 'initial state')
107 await post(s.port, '/api/connect', { on: true })
108 t.ok(await waitFor(() => frames.join('').includes('"connected":true')), 'change pushed')
109 t.is(s.rig.viewers, 1, 'one page watching')
110 req.destroy()
111 t.ok(await waitFor(() => s.rig.viewers === 0), 'viewer count drops when the page goes')
112 await s.teardown()
113})
webui/test/unit.test.js +118 -0
……@@ -0,0 +1,118 @@
1import test from 'brittle'
2import { Unit } from '../lib/unit.js'
3import { FakeUnit, waitFor, sleep } from './helpers.js'
4
5const DEAD = 'cthuloid-test-nonexistent.local'
6
7function unitFor (fake, opts = {}) {
8 return new Unit({ id: fake.name, host: '127.0.0.1', port: fake.port, backoffStartMs: 100, ...opts })
9}
10
11test('open connects, reads the greeting, delivers the desired state', async (t) => {
12 const fake = await new FakeUnit('alpha').listen()
13 const u = unitFor(fake)
14 u.set('pwm 1\n')
15 u.open()
16 t.ok(await waitFor(() => fake.lines.includes('pwm 1')), 'payload delivered')
17 t.is(u.status, 'connected')
18 t.is(u.greetingName, 'alpha')
19 u.close()
20 await fake.close()
21})
22
23test('a burst of sets coalesces to the latest, and repeats are skipped', async (t) => {
24 const fake = await new FakeUnit().listen()
25 const u = unitFor(fake, { minIntervalMs: 40 })
26 u.open()
27 await waitFor(() => u.status === 'connected')
28 for (let i = 0; i < 50; i++) u.set('pwm ' + i + '\n')
29 t.ok(await waitFor(() => fake.lines.includes('pwm 49')), 'latest arrives')
30 const pwm = fake.lines.filter((l) => l.startsWith('pwm'))
31 t.ok(pwm.length <= 3, 'intermediate frames dropped, sent ' + pwm.length)
32 u.set('pwm 49\n')
33 await sleep(150)
34 t.is(fake.lines.filter((l) => l === 'pwm 49').length, 1, 'identical frame not resent')
35 u.close()
36 await fake.close()
37})
38
39test('a dead unit never delays a live one', async (t) => {
40 const fake = await new FakeUnit().listen()
41 const dead = new Unit({ id: 'dead', host: DEAD, port: 1337 })
42 const live = unitFor(fake)
43 dead.set('pwm 0\n')
44 dead.open()
45 live.open()
46 await waitFor(() => live.status === 'connected')
47 const t0 = Date.now()
48 live.set('pwm 7\n')
49 t.ok(await waitFor(() => fake.lines.includes('pwm 7'), 500), 'live unit served while dead one resolves')
50 t.ok(Date.now() - t0 < 500, 'within ' + (Date.now() - t0) + ' ms')
51 dead.close()
52 live.close()
53 await fake.close()
54})
55
56test('a dropped connection reconnects and re-asserts the look', async (t) => {
57 const fake = await new FakeUnit().listen()
58 const u = unitFor(fake)
59 u.set('pwm 3\n')
60 u.open()
61 await waitFor(() => fake.lines.includes('pwm 3'))
62 fake.lines.length = 0
63 fake.dropClients()
64 t.ok(await waitFor(() => fake.lines.includes('pwm 3'), 3000), 're-asserted after reconnect')
65 t.is(fake.accepted, 2)
66 u.close()
67 await fake.close()
68})
69
70test('close() disconnects and stops retrying', async (t) => {
71 const fake = await new FakeUnit().listen()
72 const u = unitFor(fake)
73 u.open()
74 await waitFor(() => fake.conns.size === 1)
75 u.close()
76 t.ok(await waitFor(() => fake.conns.size === 0), 'socket closed')
77 await sleep(400)
78 t.is(fake.accepted, 1, 'no reconnect after close')
79 t.is(u.status, 'off')
80 await fake.close()
81})
82
83test('an unreachable unit reports it and keeps retrying', async (t) => {
84 const fake = await new FakeUnit().listen()
85 const port = fake.port
86 await fake.close()
87 const u = new Unit({ id: 'gone', host: '127.0.0.1', port, backoffStartMs: 100 })
88 u.open()
89 t.ok(await waitFor(() => u.status === 'unreachable'), 'reported unreachable')
90 u.close()
91})
92
93test('sendOnce delivers without staying connected', async (t) => {
94 const fake = await new FakeUnit().listen()
95 const u = unitFor(fake)
96 u.sendOnce('stopfade\npwm 0\n')
97 t.ok(await waitFor(() => fake.lines.includes('pwm 0')), 'delivered')
98 t.ok(await waitFor(() => fake.conns.size === 0), 'connection released')
99 await sleep(300)
100 t.is(fake.accepted, 1, 'never reconnects')
101 await fake.close()
102})
103
104test('state readback is parsed and emitted', async (t) => {
105 const fake = await new FakeUnit().listen()
106 fake.values[2] = 30000
107 const u = unitFor(fake)
108 let got = null
109 u.on('state', (s) => { got = s })
110 u.open()
111 await waitFor(() => u.status === 'connected')
112 u.requestState()
113 t.ok(await waitFor(() => got !== null), 'state emitted')
114 t.is(got.values[2], 30000)
115 t.alike(u.actual.values, got.values, 'kept as unit.actual')
116 u.close()
117 await fake.close()
118})