UNPKG

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
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