homebridge-kef-speaker
Version:
Homebridge plugin for KEF wireless speakers (LS50 Wireless II, LSX II, LS60)
304 lines • 9.96 kB
JavaScript
/**
* 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