352 lines
11 KiB
JavaScript
Executable File
352 lines
11 KiB
JavaScript
Executable File
#!/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
|
|
--rest-color <color> Paint LEDs outside --range with a named or hex color
|
|
--rest-hex <#RRGGBB> Hex shortcut for --rest-color
|
|
--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 --color green --range 90-100 --effect blink --rest-color white
|
|
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 restColor = parseColor(options['rest-color'] ?? options['rest-hex']);
|
|
const effect = await resolveEffect(baseUrl, options.effect);
|
|
const range = parseRange(options.range);
|
|
const segmentId = options.segment != null ? Number.parseInt(options.segment, 10) : 0;
|
|
|
|
if (restColor && !range) fail('--rest-color requires --range');
|
|
|
|
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) {
|
|
if (range && restColor) {
|
|
const info = await apiGet(baseUrl, '/json/info');
|
|
const ledCount = info?.leds?.count;
|
|
if (!Number.isInteger(ledCount) || ledCount < 1) fail('Could not determine LED count for --rest-color');
|
|
if (range.stop > ledCount) fail(`Range end ${range.stop} exceeds LED count ${ledCount}`);
|
|
|
|
const segments = [];
|
|
let nextId = 0;
|
|
|
|
if (range.start > 0) {
|
|
segments.push({
|
|
id: nextId++,
|
|
start: 0,
|
|
stop: range.start,
|
|
col: [restColor],
|
|
fx: 0,
|
|
});
|
|
}
|
|
|
|
segments.push({ ...segment, id: nextId++ });
|
|
|
|
if (range.stop < ledCount) {
|
|
segments.push({
|
|
id: nextId++,
|
|
start: range.stop,
|
|
stop: ledCount,
|
|
col: [restColor],
|
|
fx: 0,
|
|
});
|
|
}
|
|
|
|
payload.mainseg = 0;
|
|
payload.seg = segments;
|
|
} else {
|
|
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)));
|