amaran-light-cli
Version:
Command line tool for controlling Aputure Amaran lights via WebSocket to a local Amaran desktop app.
376 lines • 17.5 kB
JavaScript
import chalk from 'chalk';
import { CCT_DEFAULTS, CURVE_HELP_TEXT } from '../../daylightSimulation/constants.js';
import { DEVICE_DEFAULTS, VALIDATION_RANGES } from '../../deviceControl/constants.js';
export function registerAutoCct(program, deps) {
const { asyncCommand } = deps;
program
.command('auto-cct [device]')
.usage('[device] [options]')
.description('Set CCT for lights (or specific device) based on current location and time (geoip)')
.option('-u, --url <url>', 'WebSocket URL')
.option('-c, --client-id <id>', 'Client ID')
.option('-d, --debug', 'Enable debug mode')
.option('-i, --ip <ip>', 'Override IP address for geoip lookup')
.option('-y, --lat <latitude>', 'Manual latitude (-90 to 90)')
.option('-x, --lon <longitude>', 'Manual longitude (-180 to 180)')
.option('-t, --time <time>', 'Manual time (ISO 8601 format, e.g., 2025-10-26T14:30:00)')
.option('-C, --curve <curve>', CURVE_HELP_TEXT, 'hann')
.option('-L, --max-lux <value>', 'Max lux output for scaling intensity')
.option('--cloud-cover <value>', 'Cloud cover (0-1), e.g. 0.5 for 50% clouds')
.option('--precipitation <type>', 'Precipitation type: none, rain, snow, drizzle')
.option('--weather', 'Automatically fetch weather from wttr.in and apply modifiers')
.option('--privacy-off', 'Show full IP address and precise coordinates', false)
.action(asyncCommand(handleAutoCct(deps)));
}
function handleAutoCct(deps) {
const { createController, loadConfig, findDevice } = deps;
return async (deviceQuery, optionsRaw) => {
const cfg = loadConfig?.() || {};
const { getLocationFromIP } = await import('../../daylightSimulation/geoipUtil.js');
const { calculateCCT, CurveType, parseCurveType } = await import('../../daylightSimulation/cctUtil.js');
const { interpolateMaxLux, parseMaxLuxMap } = await import('../../daylightSimulation/mathUtil.js');
const { formatLocation } = await import('../../daylightSimulation/privacyUtil.js');
const options = optionsRaw;
const controller = await createController(options.url, options.clientId, options.debug);
let lat;
let lon;
let time = new Date();
let source = '';
if (options.time) {
time = new Date(options.time);
if (Number.isNaN(time.getTime())) {
console.error(chalk.red('Invalid time format. Use ISO 8601 format (e.g., 2025-10-26T14:30:00)'));
process.exit(1);
}
}
// Validate curve option
let curveType;
if (options.curve) {
try {
curveType = parseCurveType(options.curve);
}
catch (error) {
console.error(chalk.red(error.message));
process.exit(1);
}
}
else if (cfg) {
// Try to get default curve from config
const config = cfg;
if (config?.defaultCurve) {
try {
curveType = parseCurveType(config.defaultCurve);
}
catch (_) {
console.warn(chalk.yellow(`Warning: Invalid default curve in config: ${config.defaultCurve}. Using 'hann' as fallback.`));
curveType = 'HANN';
}
}
else {
curveType = 'HANN'; // Default fallback
}
}
else {
curveType = 'HANN'; // Fallback if loadConfig is not available
}
if (options.lat !== undefined && options.lon !== undefined) {
lat = parseFloat(options.lat);
lon = parseFloat(options.lon);
if (Number.isNaN(lat) || lat < -90 || lat > 90) {
console.error(chalk.red('Latitude must be between -90 and 90'));
process.exit(1);
}
if (Number.isNaN(lon) || lon < -180 || lon > 180) {
console.error(chalk.red('Longitude must be between -180 and 180'));
process.exit(1);
}
source = 'manual';
}
else if (cfg) {
const storedLat = cfg.latitude;
const storedLon = cfg.longitude;
if (typeof storedLat === 'number' && typeof storedLon === 'number') {
lat = storedLat;
lon = storedLon;
source = 'config';
}
}
if (lat === undefined || lon === undefined) {
let ip = options.ip;
if (!ip) {
try {
const res = await fetch('https://api.ipify.org?format=json');
const data = await res.json();
ip = data.ip;
}
catch (_err) {
ip = '127.0.0.1';
}
}
const location = getLocationFromIP(ip);
if (!location || !location.ll) {
console.error(chalk.red('Could not determine location from IP. Use --lat and --lon to specify manually, or set defaults with: amaran config --lat <lat> --lon <lon>'));
process.exit(1);
}
[lat, lon] = location.ll;
source = `geoip (${ip})`;
}
// Handle automatic weather if requested
let weatherOptions;
const configWeather = cfg.weather === true;
if (options.weather || (options.weather === undefined && configWeather)) {
const { getWeatherData } = await import('../../daylightSimulation/weatherUtil.js');
const weather = await getWeatherData(lat, lon, time, options.debug);
weatherOptions = {
cloudCover: weather.cloudCover,
precipitation: weather.precipitation,
};
if (options.debug) {
console.log(chalk.gray(` Auto-weather: cloudCover=${weather.cloudCover}, precipitation=${weather.precipitation} (from ${weather.source})`));
}
}
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const minKRaw = cfg.cctMin;
const maxKRaw = cfg.cctMax;
// maxLux logic handled later with interpolation support
const minKCfg = typeof minKRaw === 'number' ? minKRaw : undefined;
const maxKCfg = typeof maxKRaw === 'number' ? maxKRaw : undefined;
const loK = minKCfg !== undefined
? clamp(minKCfg, VALIDATION_RANGES.cct.min, VALIDATION_RANGES.cct.max)
: CCT_DEFAULTS.cctMinK;
const hiK = maxKCfg !== undefined
? clamp(maxKCfg, VALIDATION_RANGES.cct.min, VALIDATION_RANGES.cct.max)
: CCT_DEFAULTS.cctMaxK;
// For auto-cct defaults: use CCT defaults if not configured
const minPctRaw = cfg.intensityMin;
const maxPctRaw = cfg.intensityMax;
const minPctCfg = typeof minPctRaw === 'number' ? minPctRaw : CCT_DEFAULTS.intensityMinPct;
const maxPctCfg = typeof maxPctRaw === 'number' ? maxPctRaw : CCT_DEFAULTS.intensityMaxPct;
const loPct = clamp(Math.min(minPctCfg, maxPctCfg), VALIDATION_RANGES.intensity.min, VALIDATION_RANGES.intensity.max);
const hiPct = clamp(Math.max(minPctCfg, maxPctCfg), VALIDATION_RANGES.intensity.min, VALIDATION_RANGES.intensity.max);
// Determine maxLux value (number or map), prioritizing CLI option over config
let maxLuxMap;
// 1. Try CLI option
if (options.maxLux) {
// Check for map format "cct:lux,cct:lux"
if (options.maxLux.includes(':')) {
const map = parseMaxLuxMap(options.maxLux);
if (map)
maxLuxMap = map;
}
else {
const parsed = parseFloat(options.maxLux);
if (!Number.isNaN(parsed) && parsed > 0) {
maxLuxMap = parsed;
}
}
}
// 2. Fallback to Config
else if (cfg.maxLux !== undefined) {
maxLuxMap = cfg.maxLux;
}
const result = calculateCCT(lat, lon, time, {
cctMinK: Math.min(loK, hiK),
cctMaxK: Math.max(loK, hiK),
intensityMinPct: loPct,
intensityMaxPct: hiPct,
maxLux: maxLuxMap,
weather: weatherOptions || {
cloudCover: options.cloudCover ? parseFloat(options.cloudCover) : undefined,
precipitation: options.precipitation,
},
}, CurveType[curveType]);
let percent;
let modeDescription = 'intensity curve';
// Calculate effective max lux only for logging purposes now, as calculateCCT handled the value
let effectiveMaxLux;
if (maxLuxMap !== undefined && result.lightOutput !== undefined) {
if (typeof maxLuxMap === 'number') {
effectiveMaxLux = maxLuxMap;
}
else {
effectiveMaxLux = interpolateMaxLux(result.cct, maxLuxMap);
}
modeDescription = `max lux output of light system (${Math.round(effectiveMaxLux)} lux @ ${result.cct}K)`;
}
percent = result.intensity / 10;
// Final clamp to configured intensity boundaries
percent = Math.min(hiPct, Math.max(loPct, percent));
percent = Math.round(percent * 10) / 10;
console.log(chalk.blue(`Setting CCT to ${result.cct}K at ${percent}% for active lights`));
console.log(chalk.gray(` Location: ${formatLocation(lat, lon, source, options.privacyOff)}`));
const formattedDate = time.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const formattedTime = time.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
console.log(chalk.gray(` Time: ${formattedDate}, ${formattedTime}`));
console.log(chalk.gray(` Curve: ${curveType.toLowerCase()}`));
console.log(chalk.gray(` Mode: ${modeDescription}`));
if (effectiveMaxLux !== undefined && result.lightOutput !== undefined) {
console.log(chalk.gray(` Target Output: ${result.lightOutput} lux`));
}
if (options.cloudCover || options.precipitation) {
const weatherInfo = [];
if (options.cloudCover)
weatherInfo.push(`Clouds: ${options.cloudCover}`);
if (options.precipitation)
weatherInfo.push(`Precip: ${options.precipitation}`);
console.log(chalk.gray(` Weather: ${weatherInfo.join(', ')}`));
}
else if (weatherOptions && (options.weather || configWeather)) {
const weatherInfo = [];
if (weatherOptions.cloudCover !== undefined)
weatherInfo.push(`Clouds: ${weatherOptions.cloudCover}`);
if (weatherOptions.precipitation)
weatherInfo.push(`Precip: ${weatherOptions.precipitation}`);
console.log(chalk.gray(` Weather (Auto): ${weatherInfo.join(', ')}`));
}
let candidateDevices = [];
if (deviceQuery && deviceQuery.toLowerCase() !== 'all') {
const device = findDevice(controller, deviceQuery);
if (!device) {
console.error(chalk.red(`Device "${deviceQuery}" not found`));
await controller.disconnect();
process.exit(1);
}
candidateDevices = [device];
}
else {
candidateDevices = controller.getDevices?.() ?? [];
}
const lightPattern = /^[A-Z0-9]+-[A-Z0-9]+$/i;
const lightDevices = candidateDevices.filter((device) => {
if (!device || typeof device !== 'object') {
return false;
}
const candidate = device;
return typeof candidate.node_id === 'string' && lightPattern.test(candidate.node_id);
});
if (lightDevices.length === 0) {
console.log(chalk.yellow('No light devices found; skipping auto CCT update.'));
await controller.disconnect();
return;
}
const waitMs = DEVICE_DEFAULTS.statusCheckDelay;
const offDevices = [];
const activeDevices = [];
const hasSleep = (o) => typeof o === 'object' && o !== null && 'sleep' in o;
const getSleepStatus = async (nodeId) => {
return new Promise((resolve) => {
let settled = false;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
resolve(undefined);
}
}, 3000);
controller.getLightSleepStatus?.(nodeId, (success, _message, data) => {
if (settled)
return;
settled = true;
clearTimeout(timeout);
if (!success) {
resolve(undefined);
return;
}
// Normalize various possible representations of sleep state
if (data) {
// Direct sleep field
if (hasSleep(data)) {
const v = data.sleep;
if (typeof v === 'boolean') {
resolve(v);
return;
}
if (typeof v === 'number') {
resolve(v !== 0);
return;
}
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
resolve(s === 'true' || s === '1' || s === 'on' || s === 'sleep');
return;
}
}
// Server may return { data: boolean }
const inner = data.data;
if (typeof inner === 'boolean') {
resolve(inner);
return;
}
if (typeof inner === 'number') {
resolve(inner !== 0);
return;
}
if (typeof inner === 'string') {
const s = inner.trim().toLowerCase();
resolve(s === 'true' || s === '1' || s === 'on' || s === 'sleep');
return;
}
if (hasSleep(inner)) {
const v = inner.sleep;
if (typeof v === 'boolean') {
resolve(v);
return;
}
if (typeof v === 'number') {
resolve(v !== 0);
return;
}
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
resolve(s === 'true' || s === '1' || s === 'on' || s === 'sleep');
return;
}
}
}
resolve(undefined);
});
});
};
for (const device of lightDevices) {
const nodeId = device.node_id;
const sleep = await getSleepStatus(nodeId);
if (sleep === false) {
activeDevices.push(device);
}
else {
offDevices.push(device);
}
}
if (activeDevices.length === 0) {
console.log(chalk.yellow('All discovered lights are off; nothing to update.'));
if (offDevices.length > 0) {
console.log(chalk.gray(` Skipped ${offDevices.length} light(s) to avoid turning them on.`));
}
await controller.disconnect();
return;
}
console.log(chalk.gray(` Updating ${activeDevices.length} light(s)${offDevices.length ? `, skipped ${offDevices.length} off light(s)` : ''}`));
for (let i = 0; i < activeDevices.length; i++) {
const device = activeDevices[i];
const displayName = typeof device.device_name === 'string'
? device.device_name
: typeof device.name === 'string'
? device.name
: typeof device.id === 'string'
? device.id
: device.node_id;
console.log(` Setting ${displayName} (${device.node_id}) to ${result.cct}K at ${percent}%`);
controller.setCCT(device.node_id, result.cct, percent * 10);
if (i < activeDevices.length - 1) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
await controller.disconnect();
};
}
export default registerAutoCct;
//# sourceMappingURL=autoCct.js.map