| … | … | @@ -0,0 +1 @@ |
| 1 | node_modules/ |
| … | … | @@ -0,0 +1,58 @@ |
| 1 | # cthuloid-web | |
| 2 | ||
| 3 | Web control for cthuloid light units (`esp324xlight` firmware). A Bare app, | |
| 4 | deployed with pgh, meant to run on stortron and be used from any browser on the | |
| 5 | LAN at **http://stortron.local:4050**. | |
| 6 | ||
| 7 | ## Behaviour | |
| 8 | ||
| 9 | Same 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 | ||
| 28 | LAN-only with no login, by decision. POSTs must carry `x-cthuloid: 1` with a | |
| 29 | JSON body, which a cross-site page cannot send without a CORS preflight that this | |
| 30 | server never answers. | |
| 31 | ||
| 32 | Settings and looks live in `~/.config/cthuloid-web/config.json`. | |
| 33 | ||
| 34 | ## Run / test | |
| 35 | ||
| 36 | ```bash | |
| 37 | npm install | |
| 38 | npm test # brittle-bare, 32 tests | |
| 39 | bare index.js # 127.0.0.1:4050 by default | |
| 40 | CTHULOID_WEB_HOST=0.0.0.0 bare index.js | |
| 41 | ``` | |
| 42 | ||
| 43 | Env: `CTHULOID_WEB_PORT` (4050), `CTHULOID_WEB_HOST` (127.0.0.1; the pgh | |
| 44 | defaults set 0.0.0.0), `CTHULOID_WEB_CONFIG` (config dir). | |
| 45 | ||
| 46 | Dependencies 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 | |
| 48 | refuse to load (see `overrides` in package.json). | |
| 49 | ||
| 50 | ## Deploy (pgh) | |
| 51 | ||
| 52 | ```bash | |
| 53 | pgh publish . # from this directory | |
| 54 | # on stortron: | |
| 55 | pgh app install pgh://<registry-key>/cthuloid-web --name cthuloid-web | |
| 56 | pgh app start cthuloid-web | |
| 57 | pgh app autostart cthuloid-web on | |
| 58 | ``` |
| … | … | @@ -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 | ||
| 12 | import os from 'bare-os' | |
| 13 | import path from 'bare-path' | |
| 14 | import process from 'bare-process' | |
| 15 | import { Store } from './lib/store.js' | |
| 16 | import { Discovery } from './lib/discovery.js' | |
| 17 | import { Rig } from './lib/rig.js' | |
| 18 | import { createServer } from './lib/server.js' | |
| 19 | ||
| 20 | const port = Number(process.env.CTHULOID_WEB_PORT || 4050) | |
| 21 | const host = process.env.CTHULOID_WEB_HOST || '127.0.0.1' | |
| 22 | const configDir = process.env.CTHULOID_WEB_CONFIG || path.join(os.homedir(), '.config', 'cthuloid-web') | |
| 23 | ||
| 24 | const store = new Store(configDir) | |
| 25 | const discovery = new Discovery() | |
| 26 | const rig = new Rig({ store, discovery }) | |
| 27 | ||
| 28 | discovery.on('unavailable', (reason) => console.error(JSON.stringify({ msg: 'discovery unavailable', reason }))) | |
| 29 | discovery.on('up', (u) => console.log(JSON.stringify({ msg: 'unit up', name: u.name, addr: u.addr, proto: u.proto }))) | |
| 30 | discovery.on('down', (u) => console.log(JSON.stringify({ msg: 'unit down', name: u.name }))) | |
| 31 | ||
| 32 | rig.start() | |
| 33 | const server = createServer(rig) | |
| 34 | server.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. | |
| 39 | const shutdown = () => { | |
| 40 | rig.stop() | |
| 41 | server.close() | |
| 42 | process.exit(0) | |
| 43 | } | |
| 44 | process.on('SIGTERM', shutdown) | |
| 45 | process.on('SIGINT', shutdown) |
| … | … | @@ -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 | ||
| 14 | import EventEmitter from 'bare-events' | |
| 15 | import { spawn } from 'bare-subprocess' | |
| 16 | ||
| 17 | export 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. | |
| 20 | function 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. | |
| 26 | export 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 | ||
| 47 | export 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 | } |
| … | … | @@ -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 | ||
| 11 | export const CHANNELS_PER_SLOT = 3 | |
| 12 | export const SLOTS_PER_UNIT = 4 | |
| 13 | export 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. | |
| 17 | export const FIRMWARE_MAX_PWM = 65535 | |
| 18 | export const FIRMWARE_RED_MAX_PWM = 45000 | |
| 19 | ||
| 20 | export 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. | |
| 26 | export 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 | ||
| 34 | export 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. | |
| 40 | export 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. | |
| 51 | export 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 }). | |
| 65 | export 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 | ||
| 75 | export 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. | |
| 82 | export 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 | ||
| 90 | export 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. | |
| 96 | export 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 | ||
| 101 | export function freqCommand (hz) { | |
| 102 | return 'freq ' + Math.trunc(clamp(hz, 1, 40000)) + '\n' | |
| 103 | } | |
| 104 | ||
| 105 | export 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. | |
| 111 | export 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. | |
| 125 | export function parseGreeting (line) { | |
| 126 | const m = /^(\S+) ready$/.exec(line.trim()) | |
| 127 | return m ? m[1] : null | |
| 128 | } |
| … | … | @@ -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 | ||
| 18 | import EventEmitter from 'bare-events' | |
| 19 | import { Unit } from './unit.js' | |
| 20 | import * as P from './protocol.js' | |
| 21 | ||
| 22 | const DEFAULT_RED_MAX = 30000 // the original rig's ceiling; the addon's default | |
| 23 | const DEFAULT_FADE_MS = 60 // a short pwm2 smooths slider steps | |
| 24 | const SAVE_DELAY_MS = 300 | |
| 25 | const CHANGE_DELAY_MS = 50 | |
| 26 | ||
| 27 | function 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. | |
| 35 | export 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 | ||
| 50 | export 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 | } |
| … | … | @@ -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 | ||
| 9 | import http from 'bare-http1' | |
| 10 | import fs from 'bare-fs' | |
| 11 | import path from 'bare-path' | |
| 12 | ||
| 13 | const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static') | |
| 14 | const 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 | } | |
| 19 | const MAX_BODY = 64 * 1024 | |
| 20 | const PING_MS = 15000 | |
| 21 | ||
| 22 | class HttpError extends Error { | |
| 23 | constructor (status, message) { | |
| 24 | super(message) | |
| 25 | this.status = status | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | function 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 | ||
| 35 | function 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 | ||
| 57 | export 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 | } |
| … | … | @@ -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 | ||
| 8 | import fs from 'bare-fs' | |
| 9 | import path from 'bare-path' | |
| 10 | ||
| 11 | export 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 | } |
| … | … | @@ -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 | ||
| 19 | import EventEmitter from 'bare-events' | |
| 20 | import net from 'bare-net' | |
| 21 | import { parseGreeting, parseState } from './protocol.js' | |
| 22 | ||
| 23 | const KEEPALIVE_PAYLOAD = 'hostname\n' | |
| 24 | ||
| 25 | export 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 | } |
| … | … | @@ -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 | } |
| … | … | @@ -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 | } |
| … | … | @@ -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; } | |
| 31 | body { | |
| 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 | } | |
| 37 | h1 { font-size: 1.15rem; margin: 0; } | |
| 38 | h2 { font-size: 1rem; margin: 0 0 .6rem; } | |
| 39 | button, input { font: inherit; color: inherit; } | |
| 40 | button { | |
| 41 | border: 1px solid var(--line); | |
| 42 | background: var(--panel); | |
| 43 | border-radius: 8px; | |
| 44 | padding: .45rem .8rem; | |
| 45 | cursor: pointer; | |
| 46 | } | |
| 47 | button:disabled { opacity: .5; cursor: default; } | |
| 48 | input[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 | } | |
| 55 | input[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); } | |
| 76 | main { 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; } |
| … | … | @@ -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 | ||
| 5 | const $ = (sel, el) => (el || document).querySelector(sel) | |
| 6 | ||
| 7 | // ---- talking to the server -------------------------------------------------- | |
| 8 | ||
| 9 | async 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. | |
| 25 | const senders = new Map() | |
| 26 | function 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 | } | |
| 35 | async 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 | ||
| 49 | function act (fn) { | |
| 50 | return (ev) => { | |
| 51 | if (ev) ev.preventDefault() | |
| 52 | fn().catch((err) => alert(err.message)) | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | // ---- colour helpers --------------------------------------------------------- | |
| 57 | ||
| 58 | const hex2 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0') | |
| 59 | const toHex = (s) => '#' + hex2(s.r) + hex2(s.g) + hex2(s.b) | |
| 60 | function 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. | |
| 66 | function 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. | |
| 75 | function 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. | |
| 84 | function setValue (el, v) { | |
| 85 | if (document.activeElement === el) return | |
| 86 | if (String(el.value) !== String(v)) el.value = v | |
| 87 | } | |
| 88 | ||
| 89 | // ---- rendering ---------------------------------------------------------------- | |
| 90 | ||
| 91 | const cards = new Map() | |
| 92 | let state = null | |
| 93 | ||
| 94 | function 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 | ||
| 152 | function 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 | ||
| 188 | function 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 | ||
| 254 | function 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 | } | |
| 263 | listen() |
| … | … | @@ -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 & 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> |
| … | … | @@ -0,0 +1,51 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { parseAvahiLine, Discovery } from '../lib/discovery.js' | |
| 3 | import { waitFor } from './helpers.js' | |
| 4 | ||
| 5 | // Captured from `avahi-browse -rpt _cthuloid._tcp` on stortron. | |
| 6 | const RESOLVED = '=;enp2s0;IPv4;studiolight;_cthuloid._tcp;local;studiolight.local;192.168.50.89;1337;"proto=2"' | |
| 7 | ||
| 8 | test('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 | ||
| 14 | test('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 | ||
| 22 | test('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 | ||
| 28 | test('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 | ||
| 44 | test('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 | }) |
| … | … | @@ -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. | |
| 3 | import net from 'bare-net' | |
| 4 | ||
| 5 | export 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 | ||
| 56 | export 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 | ||
| 68 | export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) |
| … | … | @@ -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. | |
| 3 | import test from 'brittle' | |
| 4 | import * as p from '../lib/protocol.js' | |
| 5 | ||
| 6 | const 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 | ||
| 15 | const 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 | ||
| 21 | test('channels match protocol.py', (t) => { | |
| 22 | for (const [args, want] of RGBE) t.alike(p.channelsFromRGBE(...args), want, JSON.stringify(args)) | |
| 23 | }) | |
| 24 | ||
| 25 | test('pwm2 period split matches protocol.py', (t) => { | |
| 26 | for (const [ms, want] of PICK) t.alike(p.pickPwm2Args(ms), want, 'fade ' + ms) | |
| 27 | }) | |
| 28 | ||
| 29 | test('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 | ||
| 37 | test('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 | ||
| 44 | test('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 | ||
| 49 | test('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 | }) |
| … | … | @@ -0,0 +1,138 @@ |
| 1 | import test from 'brittle' | |
| 2 | import EventEmitter from 'bare-events' | |
| 3 | import { Rig } from '../lib/rig.js' | |
| 4 | import { FakeUnit, waitFor, sleep } from './helpers.js' | |
| 5 | ||
| 6 | // Discovery is injected: tests announce units directly. | |
| 7 | class 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 | ||
| 16 | class 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 | ||
| 22 | function 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 | ||
| 29 | const pwms = (fake) => fake.lines.filter((l) => l.startsWith('pwm')) | |
| 30 | ||
| 31 | test('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 | ||
| 46 | test('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 | ||
| 61 | test('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 | ||
| 74 | test('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 | ||
| 91 | test('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 | ||
| 104 | test('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 | ||
| 125 | test('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 | }) |
| … | … | @@ -0,0 +1,113 @@ |
| 1 | // Drives the REAL routes over HTTP against a rig with a fake unit. | |
| 2 | import test from 'brittle' | |
| 3 | import http from 'bare-http1' | |
| 4 | import EventEmitter from 'bare-events' | |
| 5 | import { Rig } from '../lib/rig.js' | |
| 6 | import { createServer } from '../lib/server.js' | |
| 7 | import { FakeUnit, waitFor } from './helpers.js' | |
| 8 | ||
| 9 | class FakeDiscovery extends EventEmitter { | |
| 10 | constructor () { super(); this.available = true; this.reason = '' } | |
| 11 | start () {} | |
| 12 | stop () {} | |
| 13 | } | |
| 14 | ||
| 15 | async 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 | ||
| 33 | function 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 | ||
| 45 | const post = (port, path, obj) => request(port, 'POST', path, JSON.stringify(obj || {}), { | |
| 46 | 'content-type': 'application/json', 'x-cthuloid': '1' | |
| 47 | }) | |
| 48 | ||
| 49 | test('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 | ||
| 60 | test('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 | ||
| 69 | test('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 | ||
| 77 | test('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 | ||
| 88 | test('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 | ||
| 98 | test('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 | }) |
| … | … | @@ -0,0 +1,118 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { Unit } from '../lib/unit.js' | |
| 3 | import { FakeUnit, waitFor, sleep } from './helpers.js' | |
| 4 | ||
| 5 | const DEAD = 'cthuloid-test-nonexistent.local' | |
| 6 | ||
| 7 | function unitFor (fake, opts = {}) { | |
| 8 | return new Unit({ id: fake.name, host: '127.0.0.1', port: fake.port, backoffStartMs: 100, ...opts }) | |
| 9 | } | |
| 10 | ||
| 11 | test('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 | ||
| 23 | test('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 | ||
| 39 | test('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 | ||
| 56 | test('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 | ||
| 70 | test('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 | ||
| 83 | test('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 | ||
| 93 | test('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 | ||
| 104 | test('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 | }) |