Initial WLED CLI

This commit is contained in:
2026-05-10 21:04:44 +02:00
commit 2291c85f6a
4 changed files with 400 additions and 0 deletions

308
bin/wled.js Executable file
View File

@@ -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 <path>
Options:
--url <url> Override WLED base URL (default: ${DEFAULT_URL})
--range <start-end> 1-based inclusive LED range, e.g. 70-80
--color <name|#RRGGBB> Named color or hex color
--hex <#RRGGBB> Hex color shortcut
--brightness <value> 1-255 or percentage like 50%
--effect <name|id> Effect name or numeric effect id
--preset <name|id> Preset name or numeric preset id
--segment <id> 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)));