#!/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)));