From 2291c85f6a08c0ff97a6e42debaffd9c8c11c586 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 10 May 2026 21:04:44 +0200 Subject: [PATCH] Initial WLED CLI --- .gitignore | 1 + README.md | 73 ++++++++++++ bin/wled.js | 308 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 +++ 4 files changed, 400 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/wled.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..62d3d49 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# wled-cli + +Tiny no-dependency CLI for talking to a WLED box over its JSON API. + +## Features + +- turn lights on/off +- set brightness +- set named or hex colors +- target 1-based inclusive LED ranges +- set effects by name or id +- list effects +- list presets +- use presets by name or id +- inspect raw WLED JSON endpoints + +## Install + +```bash +npm install +npm link +``` + +Or just run it directly: + +```bash +node ./bin/wled.js status +``` + +## Default target + +By default the CLI talks to: + +- `http://wled.local` + +Override with: + +```bash +wled --url http://another-device.local status +``` + +Or set an env var: + +```bash +export WLED_URL=http://another-device.local +``` + +## Usage + +```bash +wled on +wled off +wled status +wled effects +wled effects rainbow +wled presets +wled set --color blue --range 70-80 --brightness 50% +wled set --hex '#00ff88' --effect solid +wled set --preset 1 +wled raw /json/info +``` + +## Notes + +- `--range` is **1-based inclusive**. So `70-80` means LEDs 70 through 80. +- `--brightness` accepts `1-255` or a percentage like `50%`. +- `--effect` accepts a numeric id or a fuzzy name match. +- `--preset` accepts a numeric id or a fuzzy preset name match. +- For new integrations, prefer WLED's JSON API docs: https://kno.wled.ge/interfaces/json-api/ + +## License + +MIT diff --git a/bin/wled.js b/bin/wled.js new file mode 100755 index 0000000..a314a25 --- /dev/null +++ b/bin/wled.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node + +const DEFAULT_URL = process.env.WLED_URL || 'http://wled.local'; + +const NAMED_COLORS = { + red: [255, 0, 0], + green: [0, 255, 0], + blue: [0, 0, 255], + white: [255, 255, 255], + warmwhite: [255, 214, 170], + yellow: [255, 255, 0], + orange: [255, 165, 0], + purple: [128, 0, 128], + pink: [255, 105, 180], + cyan: [0, 255, 255], + teal: [0, 128, 128], + magenta: [255, 0, 255], + lime: [0, 255, 0], + amber: [255, 191, 0], +}; + +function printHelp() { + console.log(`wled - tiny WLED CLI + +Usage: + wled on [options] + wled off [options] + wled set [options] + wled effects [filter] + wled presets + wled status + wled raw + +Options: + --url Override WLED base URL (default: ${DEFAULT_URL}) + --range 1-based inclusive LED range, e.g. 70-80 + --color Named color or hex color + --hex <#RRGGBB> Hex color shortcut + --brightness 1-255 or percentage like 50% + --effect Effect name or numeric effect id + --preset Preset name or numeric preset id + --segment Segment id to modify (default: 0) + --json Print JSON output + --help Show this help + +Examples: + wled on + wled set --color blue --range 70-80 --brightness 50% + wled set --hex '#00ff88' --effect solid + wled effects rainbow + wled presets +`); +} + +function fail(message, code = 1) { + console.error(`Error: ${message}`); + process.exit(code); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const positionals = []; + const options = {}; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg.startsWith('--')) { + positionals.push(arg); + continue; + } + + const [key, inline] = arg.split('=', 2); + const name = key.slice(2); + if (name === 'help' || name === 'json') { + options[name] = true; + continue; + } + + const value = inline ?? args[++i]; + if (value == null) fail(`Missing value for --${name}`); + options[name] = value; + } + + return { command: positionals[0], rest: positionals.slice(1), options }; +} + +async function apiGet(baseUrl, path) { + const res = await fetch(new URL(path, ensureSlash(baseUrl)), { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) fail(`GET ${path} failed with ${res.status}`); + return res.json(); +} + +async function apiPost(baseUrl, path, body) { + const res = await fetch(new URL(path, ensureSlash(baseUrl)), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + fail(`POST ${path} failed with ${res.status}: ${text}`); + } + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +function ensureSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +function parseBrightness(input) { + if (input == null) return undefined; + if (typeof input === 'number') return clampByte(input); + const text = String(input).trim(); + if (text.endsWith('%')) { + const percent = Number.parseFloat(text.slice(0, -1)); + if (Number.isNaN(percent)) fail(`Invalid brightness: ${input}`); + return clampByte(Math.round((percent / 100) * 255)); + } + const value = Number.parseInt(text, 10); + if (Number.isNaN(value)) fail(`Invalid brightness: ${input}`); + return clampByte(value); +} + +function clampByte(value) { + if (value < 0 || value > 255) fail(`Brightness out of range: ${value}`); + return value; +} + +function parseRange(input) { + if (!input) return undefined; + const match = String(input).trim().match(/^(\d+)-(\d+)$/); + if (!match) fail(`Invalid range '${input}'. Use start-end, e.g. 70-80`); + const start = Number.parseInt(match[1], 10); + const end = Number.parseInt(match[2], 10); + if (start < 1 || end < start) fail(`Invalid range '${input}'`); + return { start: start - 1, stop: end }; +} + +function parseColor(input) { + if (!input) return undefined; + const text = String(input).trim().toLowerCase(); + if (text.startsWith('#')) return hexToRgb(text); + if (NAMED_COLORS[text]) return NAMED_COLORS[text]; + fail(`Unknown color '${input}'. Use a named color or #RRGGBB.`); +} + +function hexToRgb(hex) { + const clean = hex.replace('#', ''); + const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean; + if (!/^[0-9a-f]{6}$/i.test(full)) fail(`Invalid hex color '${hex}'`); + return [0, 2, 4].map((i) => Number.parseInt(full.slice(i, i + 2), 16)); +} + +function normalizeName(text) { + return String(text).trim().toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +async function resolveEffect(baseUrl, effectInput) { + if (effectInput == null) return undefined; + if (/^\d+$/.test(String(effectInput))) return Number.parseInt(effectInput, 10); + const effects = await apiGet(baseUrl, '/json/eff'); + const target = normalizeName(effectInput); + const exact = effects.findIndex((name) => normalizeName(name) === target); + if (exact >= 0) return exact; + const partial = effects.findIndex((name) => normalizeName(name).includes(target)); + if (partial >= 0) return partial; + fail(`Effect '${effectInput}' not found`); +} + +async function resolvePreset(baseUrl, presetInput) { + if (presetInput == null) return undefined; + if (/^\d+$/.test(String(presetInput))) return Number.parseInt(presetInput, 10); + const presets = await apiGet(baseUrl, '/presets.json'); + const entries = Object.entries(presets) + .filter(([id, value]) => id !== '0' && value && typeof value === 'object') + .map(([id, value]) => ({ id: Number.parseInt(id, 10), name: value.n || `Preset ${id}` })); + const target = normalizeName(presetInput); + const exact = entries.find((entry) => normalizeName(entry.name) === target); + if (exact) return exact.id; + const partial = entries.find((entry) => normalizeName(entry.name).includes(target)); + if (partial) return partial.id; + fail(`Preset '${presetInput}' not found`); +} + +async function buildState(baseUrl, options, command) { + const payload = {}; + + if (command === 'on') payload.on = true; + if (command === 'off') payload.on = false; + + const brightness = parseBrightness(options.brightness); + if (brightness != null) payload.bri = brightness; + + const preset = await resolvePreset(baseUrl, options.preset); + if (preset != null) payload.ps = preset; + + const color = parseColor(options.color ?? options.hex); + const effect = await resolveEffect(baseUrl, options.effect); + const range = parseRange(options.range); + const segmentId = options.segment != null ? Number.parseInt(options.segment, 10) : 0; + + const segment = { id: segmentId }; + let needsSegment = false; + + if (range) { + segment.start = range.start; + segment.stop = range.stop; + needsSegment = true; + } + if (color) { + segment.col = [color]; + needsSegment = true; + } + if (effect != null) { + segment.fx = effect; + needsSegment = true; + } + + if (needsSegment) payload.seg = [segment]; + return payload; +} + +function summarizeState(response) { + const state = response.state || response; + return { + on: state.on, + bri: state.bri, + seg: state.seg, + ps: state.ps, + pl: state.pl, + }; +} + +async function listEffects(baseUrl, filter, json) { + const effects = await apiGet(baseUrl, '/json/eff'); + const rows = effects + .map((name, id) => ({ id, name })) + .filter((row) => !filter || normalizeName(row.name).includes(normalizeName(filter))); + if (json) console.log(JSON.stringify(rows, null, 2)); + else rows.forEach((row) => console.log(`${String(row.id).padStart(3, ' ')} ${row.name}`)); +} + +async function listPresets(baseUrl, json) { + const presets = await apiGet(baseUrl, '/presets.json'); + const rows = Object.entries(presets) + .filter(([id, value]) => id !== '0' && value && typeof value === 'object') + .map(([id, value]) => ({ id: Number.parseInt(id, 10), name: value.n || `Preset ${id}` })) + .sort((a, b) => a.id - b.id); + if (json) console.log(JSON.stringify(rows, null, 2)); + else if (rows.length === 0) console.log('No saved presets.'); + else rows.forEach((row) => console.log(`${String(row.id).padStart(3, ' ')} ${row.name}`)); +} + +async function showStatus(baseUrl, json) { + const status = await apiGet(baseUrl, '/json'); + if (json) console.log(JSON.stringify(status, null, 2)); + else { + console.log(`URL: ${baseUrl}`); + console.log(`Name: ${status.info.name}`); + console.log(`Version: ${status.info.ver}`); + console.log(`LEDs: ${status.info.leds.count}`); + console.log(`On: ${status.state.on ? 'yes' : 'no'}`); + console.log(`Brightness: ${status.state.bri}/255`); + } +} + +async function showRaw(baseUrl, path, json) { + if (!path) fail('raw requires a path, e.g. wled raw /json/info'); + const data = await apiGet(baseUrl, path.startsWith('/') ? path : `/${path}`); + console.log(json ? JSON.stringify(data) : JSON.stringify(data, null, 2)); +} + +async function run() { + const { command, rest, options } = parseArgs(process.argv); + if (!command || options.help) { + printHelp(); + return; + } + + const baseUrl = options.url || DEFAULT_URL; + + if (command === 'effects') return listEffects(baseUrl, rest[0], options.json); + if (command === 'presets') return listPresets(baseUrl, options.json); + if (command === 'status') return showStatus(baseUrl, options.json); + if (command === 'raw') return showRaw(baseUrl, rest[0], options.json); + + if (['on', 'off', 'set'].includes(command)) { + const payload = await buildState(baseUrl, options, command); + if (command === 'set' && Object.keys(payload).length === 0) fail('set needs at least one option'); + const response = await apiPost(baseUrl, '/json/state', payload); + const needsRefetch = !response + || (typeof response === 'object' && Object.keys(response).length === 0) + || (response && typeof response === 'object' && response.success === true); + const finalState = needsRefetch ? await apiGet(baseUrl, '/json/state') : response; + console.log(JSON.stringify(options.json ? finalState : summarizeState(finalState), null, 2)); + return; + } + + fail(`Unknown command '${command}'`); +} + +run().catch((error) => fail(error instanceof Error ? error.message : String(error))); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7bbc358 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@space/wled-cli", + "version": "0.1.0", + "description": "Tiny CLI for controlling WLED devices", + "type": "module", + "bin": { + "wled": "./bin/wled.js" + }, + "scripts": { + "test": "node ./bin/wled.js --help" + }, + "keywords": [ + "wled", + "cli", + "led" + ], + "license": "MIT" +}