amaran-light-cli
Version:
Command line tool for controlling Aputure Amaran lights via WebSocket to a local Amaran desktop app.
510 lines (507 loc) • 20.5 kB
JavaScript
/*
MIT License
Copyright (c) 2024 S. Zachariah Sprackett <zac@sprackett.com>
Copyright (c) 2025 Mahyar McDonald <github@hmmfn.com>
Control Aputure Amaran Lights via websocket to the amaran Desktop application.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Downloaded from: https://gist.github.com/zsprackett/29334b9be1e2bd90c1737bd0ba0eaf5c
*/
import chalk from 'chalk';
import WebSocket from 'ws';
import { DEVICE_DEFAULTS } from './constants.js';
/**
* Control Aputure Amaran Lights via websocket to the amaran Desktop application.
*
* @class LightController
*/
class LightController {
ws;
clientId = 'unknown_client';
deviceList = [];
sceneList = [];
nodeConfigs = new Map();
commandCallbacks = new Map();
pendingQueue = [];
onInitializedCallback;
debug = false;
constructor(wsUrl, clientId, onInitialized, debug = false) {
this.ws = new WebSocket(wsUrl);
if (clientId) {
this.clientId = clientId;
}
this.debug = debug;
if (onInitialized) {
this.onInitializedCallback = onInitialized;
}
this.ws.on('open', () => {
this.log('Connected to WebSocket server');
this.flushPending();
this.onConnectionOpen();
});
this.ws.on('message', (data) => this.handleMessage(data));
this.ws.on('error', (error) => {
if (this.debug) {
console.error('WebSocket error:', error);
}
else {
const addressMatch = error.message.match(/(\S+:\d+)/);
const addressPort = addressMatch ? addressMatch[1] : this.ws.url;
console.error(chalk.red(`WebSocket connection failed to ${addressPort}`));
}
});
this.ws.on('close', () => {
this.log('Disconnected from WebSocket server');
});
}
// --- Initialization & Message Handling ---
onConnectionOpen() {
this.getDeviceList();
}
handleMessage(data) {
try {
const parsedData = JSON.parse(data.toString());
this.log('Received message from server:', parsedData);
const action = parsedData.action || parsedData.request?.type;
const nodeId = parsedData.node_id || parsedData.request?.node_id || parsedData.data?.node_id;
let callback;
let usedKey;
if (action && nodeId) {
const key = `${action}_${nodeId}`;
if (this.commandCallbacks.has(key)) {
callback = this.commandCallbacks.get(key);
usedKey = key;
}
}
if (!callback && action && this.commandCallbacks.has(action)) {
callback = this.commandCallbacks.get(action);
usedKey = action;
}
if (parsedData.code !== 0) {
console.error('Error from server:', parsedData.message);
if (callback && usedKey) {
callback(false, parsedData.message);
this.commandCallbacks.delete(usedKey);
}
return;
}
if (callback && usedKey) {
callback(true, parsedData.message, parsedData.data);
this.commandCallbacks.delete(usedKey);
}
if (action) {
this.processResponseType(action, parsedData.data, nodeId);
}
}
catch (error) {
console.error('Error parsing message:', error);
}
}
// biome-ignore lint/suspicious/noExplicitAny: Dynamic data from server needs flexible typing before validation
processResponseType(type, data, nodeId) {
switch (type) {
case 'get_device_list':
case 'get_fixture_list':
this.handleDeviceList(data);
break;
case 'get_scene_list':
this.handleSceneList(data);
break;
case 'get_node_config':
this.handleNodeConfig({ node_id: nodeId, data });
break;
default:
this.log('Response type processed:', type);
}
}
handleDeviceList(data) {
this.deviceList = data.data;
this.log('Device List:', JSON.stringify(this.deviceList, null, 2));
this.getSceneList();
}
handleSceneList(data) {
this.sceneList = data.data;
this.log('Scene List:', JSON.stringify(this.sceneList, null, 2));
this.getNodeConfigs();
}
// biome-ignore lint/suspicious/noExplicitAny: Payload can be flat or nested from different server versions
handleNodeConfig(payload) {
const nodeId = payload.node_id || payload.data?.node_id;
if (nodeId) {
let config = payload.data;
// If server returned nested data: { node_id, data: { ...config } }
if (config && typeof config === 'object' && 'data' in config && ('node_id' in config || 'id' in config)) {
config = config.data;
}
// Normalize CCT range keys from Amaran API
if (config && typeof config === 'object') {
if (config.product_cct_min !== undefined && config.cct_min === undefined) {
config.cct_min = config.product_cct_min;
}
if (config.product_cct_max !== undefined && config.cct_max === undefined) {
config.cct_max = config.product_cct_max;
}
}
this.nodeConfigs.set(nodeId, config);
this.log('Node Config:', JSON.stringify(config, null, 2));
if (this.nodeConfigs.size === this.deviceList.length) {
this.log('All node configurations have been gathered');
if (this.onInitializedCallback) {
this.onInitializedCallback();
}
}
}
}
getNodeConfigs() {
this.deviceList.forEach((device) => {
if (device.node_id) {
this.getNodeConfig(device.node_id);
}
});
}
// --- Convenience Methods (Applied to All Lights) ---
/**
* Apply a command to all light devices with throttling.
*/
async applyToAllLights(commandFn, commandName, getDisplayArgs) {
try {
if (!this.deviceList || this.deviceList.length === 0) {
this.log('No devices found');
return;
}
const lightDevices = this.deviceList.filter((device) => device.node_id ? this.isLightNodeId(device.node_id) : false);
if (lightDevices.length === 0) {
this.log('No light devices found');
return;
}
this.log(`${commandName} for ${lightDevices.length} light(s)`);
const waitTimeMs = DEVICE_DEFAULTS.commandThrottleDelay;
for (let i = 0; i < lightDevices.length; i++) {
const device = lightDevices[i];
const displayName = device.device_name || device.name || device.node_id || 'Unknown';
const displayArgs = getDisplayArgs ? getDisplayArgs(device) : '';
if (process.env.NODE_ENV !== 'test') {
console.log(` ${commandName} ${displayName} (${device.node_id})${displayArgs}`);
}
if (device.node_id) {
commandFn(device.node_id);
}
if (i < lightDevices.length - 1) {
await this.sleep(waitTimeMs);
}
}
}
catch (error) {
console.error('Error in applyToAllLights:', error);
throw error;
}
}
async setCCTAndIntensityForAllLights(cct, intensity, callback) {
await this.applyToAllLights((nodeId) => this.setCCT(nodeId, cct, intensity, callback), 'Setting CCT', () => ` to ${cct}K${intensity !== undefined ? ` at ${intensity / 10}%` : ''}`);
}
async turnOnAllLights(callback) {
await this.applyToAllLights((nodeId) => this.turnLightOn(nodeId, callback), 'Turning on');
}
async turnOffAllLights(callback) {
await this.applyToAllLights((nodeId) => this.turnLightOff(nodeId, callback), 'Turning off');
}
async toggleAllLights(callback) {
await this.applyToAllLights((nodeId) => this.toggleLight(nodeId, callback), 'Toggling');
}
async setIntensityForAllLights(intensity, callback) {
await this.applyToAllLights((nodeId) => this.setIntensity(nodeId, intensity, callback), 'Setting intensity', () => ` to ${intensity / 10}%`);
}
async incrementIntensityForAllLights(delta, callback) {
await this.applyToAllLights((nodeId) => this.incrementIntensity(nodeId, delta, callback), 'Incrementing intensity', () => ` by ${delta > 0 ? '+' : ''}${delta / 10}%`);
}
async incrementCCTForAllLights(delta, intensity, callback) {
await this.applyToAllLights((nodeId) => this.incrementCCT(nodeId, delta, intensity, callback), 'Incrementing CCT', () => ` by ${delta > 0 ? '+' : ''}${delta}K${intensity !== undefined ? ` at ${intensity / 10}%` : ''}`);
}
async setHSIForAllLights(hue, sat, intensity, cct, gm, callback) {
await this.applyToAllLights((nodeId) => this.setHSI(nodeId, hue, sat, intensity, cct, gm, callback), 'Setting HSI', () => ` to H:${hue} S:${sat} I:${intensity / 10}%`);
}
async setColorForAllLights(color, intensity, callback) {
await this.applyToAllLights((nodeId) => this.setColor(nodeId, color, intensity, callback), 'Setting color', () => ` to ${color}${intensity !== undefined ? ` at ${intensity / 10}%` : ''}`);
}
async setSystemEffectForAllLights(effectType, intensity, callback) {
await this.applyToAllLights((nodeId) => this.setSystemEffect(nodeId, effectType, intensity, callback), 'Setting effect', () => ` to ${effectType}${intensity !== undefined ? ` at ${intensity / 10}%` : ''}`);
}
// --- Individual Light Control Methods ---
getFixtureList(callback) {
this.sendCommand(undefined, 'get_fixture_list', {}, callback);
}
getDeviceList(callback) {
this.sendCommand(undefined, 'get_device_list', {}, callback);
}
getSceneList(callback) {
this.sendCommand(undefined, 'get_scene_list', {}, callback);
}
saveScene(name, callback) {
this.sendCommand(undefined, 'save_scene', { name }, callback);
}
deleteScene(sceneId, callback) {
this.sendCommand(undefined, 'delete_scene', { id: sceneId }, callback);
}
recallScene(sceneId, callback) {
this.sendCommand(undefined, 'recall_scene', { id: sceneId }, callback);
}
updateScene(sceneId, name, callback) {
this.sendCommand(undefined, 'update_scene', { id: sceneId, name }, callback);
}
getPresetList(callback) {
this.sendCommand(undefined, 'get_preset_list', {}, callback);
}
recallPreset(nodeId, presetId, callback) {
this.sendCommand(nodeId, 'recall_preset', { id: presetId }, callback);
}
setPreset(nodeId, presetId, callback) {
this.sendCommand(nodeId, 'set_preset', { preset_id: presetId }, callback);
}
getSystemEffectList(callback) {
this.sendCommand(undefined, 'get_system_effect_list', {}, callback);
}
getQuickshotList(callback) {
this.sendCommand(undefined, 'get_quickshot_list', {}, callback);
}
setQuickshot(quickshotId, callback) {
this.sendCommand(undefined, 'set_quickshot', { quickshot_id: quickshotId }, callback);
}
getGroupList(callback) {
this.sendCommand(undefined, 'get_group_list', {}, callback);
}
createGroup(name, callback) {
this.sendCommand(undefined, 'create_group', { name }, callback);
}
deleteGroup(groupId, callback) {
this.sendCommand(undefined, 'delete_group', { id: groupId }, callback);
}
addToGroup(groupId, nodeId, callback) {
this.sendCommand(undefined, 'add_to_group', { group_id: groupId, node_id: nodeId }, callback);
}
removeFromGroup(groupId, nodeId, callback) {
this.sendCommand(undefined, 'remove_from_group', { group_id: groupId, node_id: nodeId }, callback);
}
getNodeConfig(nodeId, callback) {
this.sendCommand(nodeId, 'get_node_config', {}, callback);
}
turnLightOn(nodeId, callback) {
this.sendCommand(nodeId, 'set_sleep', { sleep: false }, callback);
}
turnLightOff(nodeId, callback) {
this.sendCommand(nodeId, 'set_sleep', { sleep: true }, callback);
}
getLightSleepStatus(nodeId, callback) {
this.sendCommand(nodeId, 'get_sleep', {}, callback);
}
toggleLight(nodeId, callback) {
this.sendCommand(nodeId, 'toggle_sleep', undefined, callback);
}
getIntensity(nodeId, callback) {
this.sendCommand(nodeId, 'get_intensity', {}, callback);
}
setIntensity(nodeId, intensity, callback) {
this.sendCommand(nodeId, 'set_intensity', { intensity }, callback);
}
incrementIntensity(nodeId, delta, callback) {
this.sendCommand(nodeId, 'increase_intensity', { delta }, callback);
}
getCCT(nodeId, callback) {
this.sendCommand(nodeId, 'get_cct', {}, callback);
}
setCCT(nodeId, cct, intensity, callback) {
const args = { cct };
if (intensity !== undefined) {
args.intensity = intensity;
}
this.sendCommand(nodeId, 'set_cct', args, callback);
}
incrementCCT(nodeId, delta, intensity, callback) {
const args = { delta };
if (intensity !== undefined) {
args.intensity = intensity;
}
this.sendCommand(nodeId, 'increase_cct', args, callback);
}
getHSI(nodeId, callback) {
this.sendCommand(nodeId, 'get_hsi', {}, callback);
}
setHSI(nodeId, hue, sat, intensity, cct, gm, callback) {
const args = { hue, sat, intensity };
if (cct !== undefined) {
args.cct = cct;
}
if (gm !== undefined) {
args.gm = gm;
}
this.sendCommand(nodeId, 'set_hsi', args, callback);
}
getRGB(nodeId, callback) {
this.sendCommand(nodeId, 'get_rgb', {}, callback);
}
setRGB(nodeId, r, g, b, intensity, callback) {
const args = { r, g, b };
if (intensity !== undefined) {
args.intensity = intensity;
}
this.sendCommand(nodeId, 'set_rgb', args, callback);
}
getXY(nodeId, callback) {
this.sendCommand(nodeId, 'get_xy', {}, callback);
}
setXY(nodeId, x, y, intensity, callback) {
const args = { x, y };
if (intensity !== undefined) {
args.intensity = intensity;
}
this.sendCommand(nodeId, 'set_xy', args, callback);
}
setColor(nodeId, color, intensity, callback) {
const args = { color };
if (intensity !== undefined) {
args.intensity = intensity;
}
this.sendCommand(nodeId, 'set_color', args, callback);
}
getSystemEffect(nodeId, callback) {
this.sendCommand(nodeId, 'get_system_effect', {}, callback);
}
setSystemEffect(nodeId, effectType, intensity, callback) {
const args = { effect_type: effectType };
if (intensity !== undefined) {
args.intensity = intensity;
}
this.sendCommand(nodeId, 'set_system_effect', args, callback);
}
getEffect(nodeId, callback) {
this.sendCommand(nodeId, 'get_effect', {}, callback);
}
setEffect(nodeId, effectName, args, callback) {
const combinedArgs = { name: effectName, ...args };
this.sendCommand(nodeId, 'set_effect', combinedArgs, callback);
}
getFanMode(nodeId, callback) {
this.sendCommand(nodeId, 'get_fan_mode', {}, callback);
}
setFanMode(nodeId, mode, callback) {
this.sendCommand(nodeId, 'set_fan_mode', { mode }, callback);
}
getFanSpeed(nodeId, callback) {
this.sendCommand(nodeId, 'get_fan_speed', {}, callback);
}
setFanSpeed(nodeId, speed, callback) {
this.sendCommand(nodeId, 'set_fan_speed', { speed }, callback);
}
setEffectSpeed(nodeId, speed, callback) {
this.sendCommand(nodeId, 'set_effect_speed', { speed }, callback);
}
setEffectIntensity(nodeId, intensity, callback) {
this.sendCommand(nodeId, 'set_effect_intensity', { intensity }, callback);
}
getDeviceInfo(nodeId, callback) {
this.sendCommand(nodeId, 'get_device_info', {}, callback);
}
updateFirmware(nodeId, callback) {
this.sendCommand(nodeId, 'update_firmware', {}, callback);
}
// --- Utility & Infrastructure ---
async disconnect() {
if (this.ws.readyState === WebSocket.OPEN) {
await this.waitForPendingCommands(5000);
this.ws.close();
this.log('WebSocket connection closed');
}
else {
console.error('WebSocket is not open or already closed');
}
}
waitForPendingCommands(timeout) {
return new Promise((resolve) => {
const start = Date.now();
const checkPendingCommands = () => {
if (this.commandCallbacks.size === 0 || Date.now() - start >= timeout) {
resolve();
}
else {
setTimeout(checkPendingCommands, 100);
}
};
checkPendingCommands();
});
}
sendCommand(nodeId, type, args, callback) {
if (this.ws.readyState === WebSocket.OPEN) {
const command = {
version: 1,
client_id: this.clientId,
type,
action: type,
node_id: nodeId,
args,
};
// biome-ignore lint/suspicious/noExplicitAny: Backward compatibility for mock server
command.request = { type };
if (callback) {
const callbackKey = nodeId ? `${type}_${nodeId}` : type;
this.commandCallbacks.set(callbackKey, callback);
}
this.ws.send(JSON.stringify(command));
this.log(`Sent command: ${type}`);
}
else {
this.pendingQueue.push({ nodeId, type, args, callback });
this.log(`Queued command (socket not open yet): ${type}`);
}
}
flushPending() {
if (this.pendingQueue.length === 0)
return;
const pending = [...this.pendingQueue];
this.pendingQueue.length = 0;
for (const p of pending) {
this.sendCommand(p.nodeId, p.type, p.args, p.callback);
}
}
isLightNodeId(nodeId) {
const lightPattern = /^[A-Z0-9]+-[A-Z0-9]+$/i;
return lightPattern.test(nodeId);
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
log(...args) {
if (this.debug) {
console.log(...args);
}
}
// Getters & Public Utilities
getWebSocket() {
return this.ws;
}
setClientId(clientId) {
this.clientId = clientId;
}
getDevices() {
return this.deviceList;
}
getScenes() {
return this.sceneList;
}
getNode(nodeId) {
return this.nodeConfigs.get(nodeId);
}
}
export default LightController;
//# sourceMappingURL=lightControl.js.map