UNPKG

homebridge-kef-speaker

Version:

Homebridge plugin for KEF wireless speakers (LS50 Wireless II, LSX II, LS60)

304 lines 9.96 kB
/** * KEF Speaker Connector * Based on pykefcontrol Python library */ export class KefConnector { host; log; baseUrl; constructor(host, log) { this.host = host; this.log = log; this.baseUrl = `http://${host}/api`; } /** * Power control */ async powerOn() { await this.setStatus('powerOn'); } async shutdown() { await this.setSource('standby'); } /** * Volume control */ async getVolume() { const response = await this.apiRequest('getData', { path: 'player:volume', roles: 'value', }); return response[0]?.i32_ || 0; } async setVolume(volume) { await this.apiRequest('setData', { path: 'player:volume', roles: 'value', value: JSON.stringify({ type: 'i32_', i32_: volume }), }); } async mute() { await this.setVolume(0); } async unmute(previousVolume) { await this.setVolume(previousVolume); } /** * Source control */ async getSource() { const response = await this.apiRequest('getData', { path: 'settings:/kef/play/physicalSource', roles: 'value', }); return response[0]?.kefPhysicalSource || 'standby'; } async setSource(source) { await this.apiRequest('setData', { path: 'settings:/kef/play/physicalSource', roles: 'value', value: JSON.stringify({ type: 'kefPhysicalSource', kefPhysicalSource: source }), }); } /** * Status control */ async getStatus() { const response = await this.apiRequest('getData', { path: 'settings:/kef/host/speakerStatus', roles: 'value', }); return response[0]?.kefSpeakerStatus || 'standby'; } async setStatus(status) { await this.apiRequest('setData', { path: 'settings:/kef/play/physicalSource', roles: 'value', value: JSON.stringify({ type: 'kefPhysicalSource', kefPhysicalSource: status }), }); } /** * Playback control */ async togglePlayPause() { await this.trackControl('pause'); } async nextTrack() { await this.trackControl('next'); } async previousTrack() { await this.trackControl('previous'); } async trackControl(command) { await this.apiRequest('setData', { path: 'player:player/control', roles: 'activate', value: JSON.stringify({ control: command }), }); } /** * Media information */ async getPlayerData() { const response = await this.apiRequest('getData', { path: 'player:player/data', roles: 'value', }); return response[0] || {}; } async getSongInformation() { const playerData = await this.getPlayerData(); return { title: playerData.trackRoles?.title, artist: playerData.trackRoles?.mediaData?.metaData?.artist, album: playerData.trackRoles?.mediaData?.metaData?.album, coverUrl: playerData.trackRoles?.icon, }; } async isPlaying() { const playerData = await this.getPlayerData(); return playerData.state === 'playing'; } async getSongLength() { const playerData = await this.getPlayerData(); return playerData.status?.duration; } async getSongProgress() { const response = await this.apiRequest('getData', { path: 'player:player/data/playTime', roles: 'value', }); return response[0]?.i64_ || 0; } /** * Speaker information */ async getSpeakerName() { const response = await this.apiRequest('getData', { path: 'settings:/deviceName', roles: 'value', }); return response[0]?.string_ || 'KEF Speaker'; } async getMacAddress() { const response = await this.apiRequest('getData', { path: 'settings:/system/primaryMacAddress', roles: 'value', }); return response[0]?.string_ || ''; } async getSpeakerModel() { const response = await this.apiRequest('getData', { path: 'settings:/releasetext', roles: 'value', }); const releaseText = response[0]?.string_ || ''; return releaseText.split('_')[0] || 'Unknown'; } async getFirmwareVersion() { const response = await this.apiRequest('getData', { path: 'settings:/releasetext', roles: 'value', }); const releaseText = response[0]?.string_ || ''; return releaseText.split('_')[1] || 'Unknown'; } extractSongInfo(playerData) { return { title: playerData.trackRoles?.title, artist: playerData.trackRoles?.mediaData?.metaData?.artist, album: playerData.trackRoles?.mediaData?.metaData?.album, coverUrl: playerData.trackRoles?.icon, }; } /** * Get complete speaker status for periodic checking */ async getCompleteStatus() { const status = { power: 'standby', source: 'wifi', volume: 0, muted: false, isPlaying: false, songInfo: undefined, }; try { // Get power status try { status.power = await this.getStatus(); } catch (error) { this.log.debug('Failed to get power status:', error); } // Get source try { status.source = await this.getSource(); } catch (error) { this.log.debug('Failed to get source:', error); } // Get volume try { status.volume = await this.getVolume(); // Determine mute status based on volume (more reliable than API call) status.muted = status.volume === 0; } catch (error) { this.log.debug('Failed to get volume:', error); } // Get playing status try { status.isPlaying = await this.isPlaying(); } catch (error) { this.log.debug('Failed to get playing status:', error); } // Get song information try { status.songInfo = await this.getSongInformation(); } catch (error) { this.log.debug('Failed to get song information:', error); } return status; } catch (error) { this.log.error('Error getting complete status:', error); throw error; } } /** * Check if speaker status has changed since last check */ async checkForChanges(lastStatus) { try { const currentStatus = await this.getCompleteStatus(); const changes = {}; // Only check for changes if we have valid data if (currentStatus.power !== lastStatus.power) { changes.power = currentStatus.power; this.log.debug(`Power changed: ${lastStatus.power} -> ${currentStatus.power}`); } if (currentStatus.source !== lastStatus.source) { changes.source = currentStatus.source; this.log.debug(`Source changed: ${lastStatus.source} -> ${currentStatus.source}`); } if (currentStatus.volume !== lastStatus.volume) { changes.volume = currentStatus.volume; this.log.debug(`Volume changed: ${lastStatus.volume} -> ${currentStatus.volume}`); } if (currentStatus.muted !== lastStatus.muted) { changes.muted = currentStatus.muted; this.log.debug(`Mute changed: ${lastStatus.muted} -> ${currentStatus.muted}`); } if (currentStatus.isPlaying !== lastStatus.isPlaying) { changes.isPlaying = currentStatus.isPlaying; this.log.debug(`Playing changed: ${lastStatus.isPlaying} -> ${currentStatus.isPlaying}`); } // Log summary if changes detected if (Object.keys(changes).length > 0) { this.log.debug(`Detected ${Object.keys(changes).length} changes:`, Object.keys(changes)); } return changes; } catch (error) { this.log.warn('Error checking for changes (will retry on next interval):', error); return {}; } } /** * API request helper */ async apiRequest(endpoint, params, method = 'GET') { const url = `${this.baseUrl}/${endpoint}`; try { let response; if (method === 'POST') { response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(params), }); } else { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { searchParams.append(key, String(value)); } response = await fetch(`${url}?${searchParams}`); } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { this.log.error(`API request failed: ${error}`); throw error; } } } //# sourceMappingURL=kefConnector.js.map