Skip to main content
Glama
hue-client.ts19.6 kB
import { createRequire } from 'module'; const require = createRequire(import.meta.url); const hueApi = require('node-hue-api'); import { HueConfig, LightState } from './types/index.js'; import { parseColorDescription, parseColorTemp, hsvToRgb, rgbToXy } from './utils/colors.js'; import { log } from './utils/logger.js'; const { api: hueApiLib, lightStates, discovery } = hueApi.v3; export class HueClient { private api: any | null = null; private config: HueConfig; private lastSync: Date | null = null; // Cache for reducing API calls private lightsCache: Map<string, any> = new Map(); private roomsCache: Map<string, any> = new Map(); private zonesCache: Map<string, any> = new Map(); private scenesCache: Map<string, any> = new Map(); constructor(config: HueConfig) { this.config = config; } // Force cache refresh for critical operations async refreshCacheIfStale(maxAge: number = 60000): Promise<void> { if (this.shouldRefreshCache(maxAge)) { await this.refreshCache(); } } async connect(): Promise<void> { try { this.api = await hueApiLib.createLocal(this.config.HUE_BRIDGE_IP).connect(this.config.HUE_API_KEY); log.hue('connected', { bridgeIp: this.config.HUE_BRIDGE_IP }); // Initial cache population await this.refreshCache(); } catch (error) { log.error('Failed to connect to Hue Bridge', error, { bridgeIp: this.config.HUE_BRIDGE_IP }); throw error; } } async refreshCache(): Promise<void> { if (!this.api) throw new Error('Not connected to bridge'); try { // Fetch all data in parallel const [lights, groups, scenes] = await Promise.all([ this.api.lights.getAll(), this.api.groups.getAll(), this.api.scenes.getAll(), ]); // Update caches this.lightsCache.clear(); lights.forEach((light: any) => this.lightsCache.set(String(light.id), light)); this.roomsCache.clear(); this.zonesCache.clear(); groups.forEach((group: any) => { if (group.type === 'Room') { this.roomsCache.set(String(group.id), group); } else if (group.type === 'Zone') { this.zonesCache.set(String(group.id), group); } }); this.scenesCache.clear(); scenes.forEach((scene: any) => this.scenesCache.set(String(scene.id), scene)); this.lastSync = new Date(); } catch (error) { log.error('Failed to refresh cache', error); throw error; } } // Bridge discovery static async discoverBridges(): Promise<any[]> { try { // Try nupnp (cloud discovery) first - this is more reliable log.hue('discovery_start', { method: 'nupnp' }); const results = await Promise.race([ discovery.nupnpSearch(), new Promise((_, reject) => setTimeout(() => reject(new Error('nupnp timeout')), 5000)) ]) as any[]; if (results && results.length > 0) { log.hue('discovery_success', { method: 'nupnp', count: results.length }); return results.map((bridge: any) => ({ id: bridge.config?.name || 'unknown', ipaddress: bridge.ipaddress, internalipaddress: bridge.ipaddress, // For compatibility name: bridge.config?.name || 'Hue Bridge', modelid: bridge.config?.modelid, })); } log.hue('discovery_start', { method: 'upnp' }); // Fall back to upnp (local discovery) with timeout const upnpResults = await Promise.race([ discovery.upnpSearch(), new Promise((_, reject) => setTimeout(() => reject(new Error('upnp timeout')), 5000)) ]) as any[]; if (upnpResults && upnpResults.length > 0) { log.hue('discovery_success', { method: 'upnp', count: upnpResults.length }); return upnpResults.map((bridge: any) => ({ id: bridge.config?.name || bridge.id || 'unknown', ipaddress: bridge.ipaddress || bridge.internalipaddress, internalipaddress: bridge.ipaddress || bridge.internalipaddress, name: bridge.config?.name || bridge.name || 'Hue Bridge', modelid: bridge.config?.modelid || bridge.modelid, })); } log.warn('No bridges found via discovery'); return []; } catch (error) { log.error('Bridge discovery failed', error); return []; } } // Create user for authentication static async createUser(bridgeIp: string, appName: string = 'hue-mcp'): Promise<any> { const unauthenticatedApi = await hueApiLib.createLocal(bridgeIp).connect(); try { const createdUser = await unauthenticatedApi.users.createUser(appName, 'MCP Server'); return createdUser; } catch (error: any) { if (error.getHueErrorType && error.getHueErrorType() === 101) { throw new Error('Link button not pressed. Please press the button on your Hue bridge and try again.'); } throw error; } } // Light operations async getLights(): Promise<any[]> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return Array.from(this.lightsCache.values()); } async getLight(id: string): Promise<any | null> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return this.lightsCache.get(id) || null; } async setLightState(id: string, state: LightState): Promise<boolean> { if (!this.api) throw new Error('Not connected to bridge'); const lightState = this.convertToHueLightState(state); try { await this.api.lights.setLightState(parseInt(id), lightState); // Optimistically update cache without extra API call const cachedLight = this.lightsCache.get(id); if (cachedLight && cachedLight.state) { const updatedLight = { ...cachedLight }; updatedLight.state = { ...cachedLight.state }; if (state.on !== undefined) updatedLight.state.on = state.on; if (state.brightness !== undefined) updatedLight.state.bri = Math.round((state.brightness / 100) * 254); if (state.hue !== undefined) updatedLight.state.hue = Math.round((state.hue / 360) * 65535); if (state.saturation !== undefined) updatedLight.state.sat = Math.round((state.saturation / 100) * 254); if (state.colorTemp !== undefined) updatedLight.state.ct = state.colorTemp; this.lightsCache.set(id, updatedLight); } return true; } catch (error) { log.error('Failed to set light state', error, { lightId: id, state }); return false; } } // Room operations async getRooms(): Promise<any[]> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return Array.from(this.roomsCache.values()); } async getRoom(id: string): Promise<any | null> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return this.roomsCache.get(id) || null; } async setRoomState(id: string, state: LightState): Promise<boolean> { if (!this.api) throw new Error('Not connected to bridge'); const groupState = this.convertToGroupState(state); try { await this.api.groups.setGroupState(parseInt(id), groupState); // Optimistically update room cache and mark cache as slightly stale const cachedRoom = this.roomsCache.get(id); if (cachedRoom && state.on !== undefined) { const updatedRoom = { ...cachedRoom }; updatedRoom.state.all_on = state.on; updatedRoom.state.any_on = state.on; this.roomsCache.set(id, updatedRoom); } return true; } catch (error) { log.error('Failed to set room state', error, { roomId: id, state }); return false; } } // Zone operations async getZones(): Promise<any[]> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return Array.from(this.zonesCache.values()); } async getZone(id: string): Promise<any | null> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return this.zonesCache.get(id) || null; } async setZoneState(id: string, state: LightState): Promise<boolean> { if (!this.api) throw new Error('Not connected to bridge'); const groupState = this.convertToGroupState(state); try { await this.api.groups.setGroupState(parseInt(id), groupState); // Optimistically update zone cache const cachedZone = this.zonesCache.get(id); if (cachedZone && state.on !== undefined) { const updatedZone = { ...cachedZone }; updatedZone.state.all_on = state.on; updatedZone.state.any_on = state.on; this.zonesCache.set(id, updatedZone); } return true; } catch (error) { log.error('Failed to set zone state', error, { zoneId: id, state }); return false; } } // Helper to get lights in a zone async getLightsInZone(zoneId: string): Promise<any[]> { const zone = await this.getZone(zoneId); if (!zone) return []; const lights: any[] = []; for (const lightId of zone.lights) { const light = await this.getLight(lightId); if (light) { lights.push(light); } } return lights; } // Scene operations async getScenes(): Promise<any[]> { if (this.shouldRefreshCache()) { await this.refreshCache(); } return Array.from(this.scenesCache.values()); } async activateScene(id: string): Promise<boolean> { if (!this.api) throw new Error('Not connected to bridge'); try { await this.api.scenes.activateScene(id); return true; } catch (error) { log.error('Failed to activate scene', error, { sceneId: id }); return false; } } // Helper to get lights in a room async getLightsInRoom(roomId: string): Promise<any[]> { const room = await this.getRoom(roomId); if (!room) return []; const lights: any[] = []; for (const lightId of room.lights) { const light = await this.getLight(lightId); if (light) { lights.push(light); } } return lights; } // Get comprehensive system summary async getSystemSummary(): Promise<any> { await this.refreshCache(); const lights = Array.from(this.lightsCache.values()); const rooms = Array.from(this.roomsCache.values()); const zones = Array.from(this.zonesCache.values()); const scenes = Array.from(this.scenesCache.values()); const lightsOn = lights.filter(l => l.state.on).length; const reachableLights = lights.filter(l => l.state.reachable).length; return { bridge: { ip: this.config.HUE_BRIDGE_IP, connected: !!this.api, lastSync: this.lastSync, }, statistics: { totalLights: lights.length, lightsOn, lightsOff: lights.length - lightsOn, reachableLights, unreachableLights: lights.length - reachableLights, rooms: rooms.length, zones: zones.length, scenes: scenes.length, }, rooms: rooms.map(room => ({ id: room.id, name: room.name, lightCount: room.lights.length, allOn: room.state.all_on, anyOn: room.state.any_on, })), zones: zones.map(zone => ({ id: zone.id, name: zone.name, lightCount: zone.lights.length, })), scenes: this.categorizeScenes(scenes), }; } // Helper methods private shouldRefreshCache(forceIfOlderThan: number = 300000): boolean { if (!this.lastSync) return true; const cacheAge = Date.now() - this.lastSync.getTime(); return cacheAge > forceIfOlderThan; // Default: 5 minutes instead of 1 minute } private convertToHueLightState(state: LightState): any { const hueState = new lightStates.LightState(); if (state.on !== undefined) { hueState.on(state.on); } if (state.brightness !== undefined) { hueState.brightness(state.brightness); } if (state.hue !== undefined && state.saturation !== undefined) { // Convert HSV to XY for better color accuracy const rgb = hsvToRgb({ h: state.hue, s: state.saturation, v: state.brightness || 100 }); const xy = rgbToXy(rgb); hueState.xy(xy.x, xy.y); } if (state.colorTemp !== undefined) { hueState.ct(state.colorTemp); } if (state.transitionTime !== undefined) { hueState.transitiontime(Math.round(state.transitionTime / 100)); // Convert ms to deciseconds } return hueState; } private convertToGroupState(state: LightState): any { const groupState = new lightStates.GroupLightState(); if (state.on !== undefined) { groupState.on(state.on); } if (state.brightness !== undefined) { groupState.brightness(state.brightness); } if (state.hue !== undefined && state.saturation !== undefined) { const rgb = hsvToRgb({ h: state.hue, s: state.saturation, v: state.brightness || 100 }); const xy = rgbToXy(rgb); groupState.xy(xy.x, xy.y); } if (state.colorTemp !== undefined) { groupState.ct(state.colorTemp); } if (state.transitionTime !== undefined) { groupState.transitiontime(Math.round(state.transitionTime / 100)); } return groupState; } private categorizeScenes(scenes: any[]): Record<string, any[]> { const categorized: Record<string, any[]> = { living: [], relaxing: [], energizing: [], focus: [], custom: [], }; scenes.forEach(scene => { const sceneLower = scene.name.toLowerCase(); const sceneInfo = { id: scene.id, name: scene.name, type: scene.type, group: scene.group, lights: scene.lights, }; if (sceneLower.includes('relax') || sceneLower.includes('calm')) { categorized.relaxing?.push(sceneInfo); } else if (sceneLower.includes('energize') || sceneLower.includes('bright')) { categorized.energizing?.push(sceneInfo); } else if (sceneLower.includes('focus') || sceneLower.includes('concentrate')) { categorized.focus?.push(sceneInfo); } else if (sceneLower.includes('living') || sceneLower.includes('natural')) { categorized.living?.push(sceneInfo); } else { categorized.custom?.push(sceneInfo); } }); return categorized; } // Parse natural language color/state descriptions static parseNaturalLanguageState(description: string): LightState { const state: LightState = {}; const lower = description.toLowerCase(); // Check for on/off if (lower.includes('off') || lower.includes('turn off')) { state.on = false; return state; // If turning off, ignore other properties } else if (lower.includes('on') || lower.includes('turn on')) { state.on = true; } // Check for brightness const brightnessMatch = lower.match(/(\d+)%|brightness\s+(\d+)/); if (brightnessMatch) { state.brightness = parseInt(brightnessMatch[1] || brightnessMatch[2] || '0'); } else if (lower.includes('dim')) { state.brightness = 30; } else if (lower.includes('bright')) { state.brightness = 100; } // Check for color const colorHsv = parseColorDescription(lower); if (colorHsv) { state.hue = colorHsv.h; state.saturation = colorHsv.s; if (!state.brightness && colorHsv.v < 100) { state.brightness = colorHsv.v; } } // Check for color temperature const colorTemp = parseColorTemp(lower); if (colorTemp) { state.colorTemp = colorTemp; } // Check for transition time if (lower.includes('slowly')) { state.transitionTime = 3000; // 3 seconds } else if (lower.includes('quickly')) { state.transitionTime = 200; // 0.2 seconds } return state; } // Bridge configuration methods async getBridgeConfiguration(includeUsers: boolean = false): Promise<any> { if (!this.api) throw new Error('Not connected to bridge'); try { // Temporarily capture and redirect console output to prevent STDOUT pollution const originalStdoutWrite = process.stdout.write; const originalConsoleLog = console.log; const originalConsoleWarn = console.warn; // Redirect all output to stderr during this API call process.stdout.write = process.stderr.write.bind(process.stderr); console.log = (...args: any[]) => console.error(...args); console.warn = (...args: any[]) => console.error(...args); try { if (includeUsers) { // Get complete configuration including users const fullConfig = await this.api.configuration.getAll(); return fullConfig; } else { // Get basic configuration without users const config = await this.api.configuration.get(); return config; } } finally { // Restore original console methods process.stdout.write = originalStdoutWrite; console.log = originalConsoleLog; console.warn = originalConsoleWarn; } } catch (error) { log.error('Failed to get bridge configuration', error); throw error; } } // User management methods async getBridgeUsers(): Promise<any> { if (!this.api) throw new Error('Not connected to bridge'); try { // Temporarily capture and redirect console output to prevent STDOUT pollution const originalStdoutWrite = process.stdout.write; const originalConsoleLog = console.log; const originalConsoleWarn = console.warn; // Redirect all output to stderr during this API call process.stdout.write = process.stderr.write.bind(process.stderr); console.log = (...args: any[]) => console.error(...args); console.warn = (...args: any[]) => console.error(...args); try { const users = await this.api.users.getAll(); return users; } finally { // Restore original console methods process.stdout.write = originalStdoutWrite; console.log = originalConsoleLog; console.warn = originalConsoleWarn; } } catch (error) { log.error('Failed to get bridge users', error); throw error; } } async getBridgeUser(username: string): Promise<any> { if (!this.api) throw new Error('Not connected to bridge'); try { // Temporarily capture and redirect console output to prevent STDOUT pollution const originalStdoutWrite = process.stdout.write; const originalConsoleLog = console.log; const originalConsoleWarn = console.warn; // Redirect all output to stderr during this API call process.stdout.write = process.stderr.write.bind(process.stderr); console.log = (...args: any[]) => console.error(...args); console.warn = (...args: any[]) => console.error(...args); try { const user = await this.api.users.get(username); return user; } finally { // Restore original console methods process.stdout.write = originalStdoutWrite; console.log = originalConsoleLog; console.warn = originalConsoleWarn; } } catch (error) { log.error('Failed to get bridge user', error, { username }); throw error; } } // Helper method to get current API username getCurrentUsername(): string { return this.config.HUE_API_KEY; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rmrfslashbin/hue-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server