UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

392 lines 21.2 kB
// Homebridge plugin for Home Connect home appliances // Copyright © 2019-2025 Alexander Thoukydides import { assertIsDefined, assertIsNumber } from './utils.js'; import { AmbientLightColor, ColorTemperature } from './api-value-types.js'; // Setting keys used to control the light(s) const LIGHT_KEY = { 'Functional Light': { on: 'Cooking.Common.Setting.Lighting', brightness: 'Cooking.Common.Setting.LightingBrightness', colourtempenum: 'Cooking.Hood.Setting.ColorTemperature', colourtempperc: 'Cooking.Hood.Setting.ColorTemperaturePercent' }, 'Ambient Light': { on: 'BSH.Common.Setting.AmbientLightEnabled', brightness: 'BSH.Common.Setting.AmbientLightBrightness', colour: 'BSH.Common.Setting.AmbientLightColor', custom: 'BSH.Common.Setting.AmbientLightCustomColor' }, 'Internal Light': { on: 'Refrigeration.Common.Setting.Light.Internal.Power', brightness: 'Refrigeration.Common.Setting.Light.Internal.Brightness' }, 'External Light': { on: 'Refrigeration.Common.Setting.Light.External.Power', brightness: 'Refrigeration.Common.Setting.Light.External.Brightness' } }; // Test whether the settings support specific features function hasSettings(settings, ...keys) { return keys.every(key => settings[key]); } // HomeKit colour temperature range const MIREK_WARM = 400; // 2,500K = 0% (incandescent lamp) const MIREK_COLD = 50; // 20,000K = 100% (clear blue sky) // Enumerated colour temperatures to percentages const COLOUR_TEMP_PERCENTAGE = { [ColorTemperature.Warm]: 0, [ColorTemperature.WarnNeutral]: 25, [ColorTemperature.Neutral]: 50, [ColorTemperature.ColdNeutral]: 75, [ColorTemperature.Cold]: 100 }; // Add a light to an accessory export function HasLight(Base, lightTypes) { return class HasLight extends Base { // Accessory services lightService = []; // Mixin constructor constructor(...args) { super(...args); // Continue initialisation asynchronously this.asyncInitialise('Light', this.initHasLight()); } // Asynchronous initialisation async initHasLight() { // Add all supported light types for (const type of lightTypes) await this.addLightIfSupported(type, LIGHT_KEY[type]); // If multiple lights are supported then link their services const firstLightService = this.lightService[0]; if (firstLightService !== undefined) { for (const service of this.lightService.slice(1)) service.addLinkedService(firstLightService); } } // Check whether the appliance supports a light and then add it async addLightIfSupported(type, keys) { // Check which settings are supported const allSettings = await this.getCached('settings', () => this.device.getSettings()); // A light must at least support being switched on and off if (!allSettings.some(setting => setting.key === keys.on)) { this.log.info(`Does not support ${type}`); return; } if (!this.hasOptionalFeature('Lightbulb', type, 'Light')) return; // Retrieve any previously cached light details const settings = await this.cache.get(type) ?? {}; // Attempt to update the details of this light await this.device.waitConnected(true); await this.refreshLight(type, keys, settings, !settings.on); // Add the light this.lightService.push(this.addLight(type, settings)); } // Refresh details of a light async refreshLight(type, keys, settings, active = false) { // Some settings may not be readable in certain states const initialSettings = []; try { // Switch the light on, if necessary, to read its supported settings settings.on = { key: keys.on }; const on = this.device.getItem(keys.on); if (!on) { if (!active) return; this.log.warn(`Temporarily switching ${type} on to read its settings`); await this.device.setSetting(keys.on, true); if (on === false) initialSettings.unshift({ key: keys.on, value: on }); } // Special handling for lights that support colour temperature const keyColourtempenum = keys.colourtempenum; const keyColourtempperc = keys.colourtempperc; if (keyColourtempenum) { settings.colourtempenum = await this.getCached(`${type} colourtempenum`, () => this.device.getSetting(keyColourtempenum)); if (settings.colourtempenum) { // Check whether the light supports custom colour temperatures const colourtempenum = settings.colourtempenum.value; const colourtempenums = settings.colourtempenum.constraints?.allowedvalues ?? []; if (colourtempenums.includes(ColorTemperature.Individual) && colourtempenum !== ColorTemperature.Individual) { if (!active) return; this.log.warn(`Temporarily setting ${type} to a custom colour temperature to read its settings`); await this.device.setSetting(keyColourtempenum, ColorTemperature.Individual); if (colourtempenum) initialSettings.unshift({ key: keyColourtempenum, value: colourtempenum }); } } } if (keyColourtempperc) { settings.colourtempperc = await this.getCached(`${type} colourtemp`, () => this.device.getSetting(keyColourtempperc)); } // Special handling for lights that support colour const keyColour = keys.colour; const keyCustom = keys.custom; if (keyColour && keyCustom) { settings.colour = await this.getCached(`${type} colour`, () => this.device.getSetting(keyColour)); if (settings.colour) { // Check whether the light supports custom colours const colour = settings.colour.value; const colours = settings.colour.constraints?.allowedvalues ?? []; if (colours.includes(AmbientLightColor.CustomColor)) settings.custom = { key: keyCustom }; // Check whether the light supports non-custom colours const nonCustomColour = colours.find(c => c !== AmbientLightColor.CustomColor); if (nonCustomColour) { // Select a non-custom colour, if necessary, to read range if (colour === AmbientLightColor.CustomColor) { if (!active) return; this.log.warn(`Temporarily setting ${type} to a non-custom colour to read its settings`); await this.device.setSetting(keyColour, nonCustomColour); initialSettings.unshift({ key: keyColour, value: colour }); } } } } // Read the supported brightness range settings.brightness = await this.getCached(`${type} brightness`, () => this.device.getSetting(keys.brightness)); // Update the cache await this.cache.set(type, settings); } finally { // Best-effort attempt to restore the original light settings for (const setting of initialSettings) { try { await this.device.setSetting(setting.key, setting.value); } catch { /* empty */ } } } } // Add a light addLight(type, settings) { // Add a Lightbulb service const service = this.makeService(this.Service.Lightbulb, type, type); // Control the light const updateHC = this.makeSerialisedObject(value => this.updateLightHC(type, settings, service, value)); // Add the appropriate characteristics if (hasSettings(settings, 'on')) this.addLightOn(type, settings, service, updateHC); if (hasSettings(settings, 'brightness') || hasSettings(settings, 'colour', 'custom')) this.addLightBrightness(type, settings, service, updateHC); if (hasSettings(settings, 'colourtempperc') || hasSettings(settings, 'colourtempenum')) this.addLightColourTemp(type, settings, service, updateHC); if (hasSettings(settings, 'colour', 'custom')) this.addLightColour(type, settings, service, updateHC); // Return the service return service; } // Deferred update of Home Connect state from HomeKit characteristics async updateLightHC(type, settings, service, value) { // Switch the light on or off if (hasSettings(settings, 'on') && value.on !== undefined) { await this.setLightOn(type, settings, value.on); } if (value.on === false) return; // Set the colour temperature if ((hasSettings(settings, 'colourtempperc') || hasSettings(settings, 'colourtempenum')) && value.mirek !== undefined) { await this.setLightColourTemp(type, settings, value.mirek); } // Either set the colour (including brightness) or just brightness const isCustom = settings.colour && this.device.getItem(settings.colour.key) === AmbientLightColor.CustomColor; if (hasSettings(settings, 'colour', 'custom') && (value.hue !== undefined || value.saturation !== undefined || (value.brightness !== undefined && isCustom))) { await this.setLightColour(type, settings, service, value.hue, value.saturation, value.brightness); } else if (hasSettings(settings, 'brightness') && value.brightness !== undefined) { await this.setLightBrightness(type, settings, value.brightness); } } // Add on/off control of a light addLightOn(type, settings, service, updateLightHC) { // Update whether the light is on or off this.device.on(settings.on.key, on => { this.log.info(`Light ${type} ${on ? 'on' : 'off'}`); service.updateCharacteristic(this.Characteristic.On, on); }); service.getCharacteristic(this.Characteristic.On) .onSet(this.onSetBoolean(value => updateLightHC({ on: value }))); } // Set whether a light is on async setLightOn(type, settings, on) { this.log.info(`SET Light ${type} ${on ? 'on' : 'off'}`); await this.device.setSetting(settings.on.key, on); } // Add brightness control of a light addLightBrightness(type, settings, service, updateLightHC) { if (hasSettings(settings, 'brightness')) { // The light has explicit brightness support const constraints = settings.brightness.constraints; service.getCharacteristic(this.Characteristic.Brightness) .updateValue(Math.round(settings.brightness.value ?? 100)) .setProps({ minValue: constraints?.min ?? 10, maxValue: constraints?.max ?? 100 }); // Update the brightness this.device.on(settings.brightness.key, percent => { percent = Math.round(percent); this.log.info(`Light ${type} ${percent}% brightness`); service.updateCharacteristic(this.Characteristic.Brightness, percent); }); } else { // Using a custom colour to set arbitrary brightness service.getCharacteristic(this.Characteristic.Brightness) .setProps({ minValue: 0, maxValue: 100 }); } // Update the light's brightness when it changes in HomeKit service.getCharacteristic(this.Characteristic.Brightness) .onSet(this.onSetNumber(value => updateLightHC({ brightness: value }))); } // Set the brightness of a light async setLightBrightness(type, settings, brightness) { this.log.info(`SET Light ${type} ${brightness}% brightness`); await this.device.setSetting(settings.brightness.key, brightness); } // Add colour temperature control of a light addLightColourTemp(type, settings, service, updateLightHC) { const updateHK = this.makeSerialised(() => { // Convert Home Connect colour temperature to a simple percentage const colourtempenum = settings.colourtempenum && this.device.getItem(settings.colourtempenum.key); const colourtempperc = settings.colourtempperc && this.device.getItem(settings.colourtempperc.key); const percent = (colourtempenum && COLOUR_TEMP_PERCENTAGE[colourtempenum]) ?? Math.round(colourtempperc ?? 0); // Convert from Home Connect's percentage to reciprocal megakelvin const mirek = Math.round(MIREK_WARM + (percent / 100.0) * (MIREK_COLD - MIREK_WARM)); this.log.info(`Light ${type} ${mirek}MK^-1 (${percent}% cold)`); service.updateCharacteristic(this.Characteristic.ColorTemperature, mirek); }); if (settings.colourtempenum) this.device.on(settings.colourtempenum.key, updateHK); if (settings.colourtempperc) this.device.on(settings.colourtempperc.key, updateHK); // Convert from reciprocal megakelvin to Home Connect's percentage service.getCharacteristic(this.Characteristic.ColorTemperature) .onSet(this.onSetNumber(value => updateLightHC({ mirek: value }))); } // Set the colour temperature of a light async setLightColourTemp(type, settings, mirek) { // Convert from reciprocal megakelvin to percent cold const percent = 100.0 * (mirek - MIREK_WARM) / (MIREK_COLD - MIREK_WARM); if (settings.colourtempperc) { // Set a custom colour temperature this.log.info(`SET Light ${type} ${percent}% cold (${mirek}MK^-1)`); if (settings.colourtempenum) await this.device.setSetting(settings.colourtempenum.key, ColorTemperature.Individual); await this.device.setSetting(settings.colourtempperc.key, percent); } else if (settings.colourtempenum) { // Map to the closest supported colour temperature const values = settings.colourtempenum.constraints?.allowedvalues ?? []; const best = values.reduce((acc, value) => { if (COLOUR_TEMP_PERCENTAGE[value]) { const error = Math.abs(percent - COLOUR_TEMP_PERCENTAGE[value]); if (!acc || error < acc[1]) return [value, error]; } return acc; }, null); assertIsDefined(best); this.log.info(`SET Light ${type} ${best[0]}% (${mirek}MK^-1)`); await this.device.setSetting(settings.colourtempenum.key, best[0]); } } // Add colour control of a light addLightColour(type, settings, service, updateLightHC) { // Convert from Home Connect's RGB to HomeKit's hue and saturation // (ignore changes to 'BSH.Common.Setting.AmbientLightColor') this.device.on(settings.custom.key, rgb => { const { hue, saturation, brightness: value } = this.fromRGB(rgb); this.log.info(`Light ${type} ${rgb} (hue=${hue}, saturation=${saturation}%, value=${value}%)`); service.updateCharacteristic(this.Characteristic.Hue, hue); service.updateCharacteristic(this.Characteristic.Saturation, saturation); service.updateCharacteristic(this.Characteristic.Brightness, value); }); // Convert from HomeKit's hue and saturation to Home Connect's RGB // (value is handled separately, as brightness) service.getCharacteristic(this.Characteristic.Hue) .onSet(this.onSetNumber(value => updateLightHC({ hue: value }))); service.getCharacteristic(this.Characteristic.Saturation) .onSet(this.onSetNumber(value => updateLightHC({ saturation: value }))); } // Set the colour of a light async setLightColour(type, settings, service, hue, saturation, value) { // Read any missing parameters from the characteristics const read = (characteristic) => { const value = service.getCharacteristic(characteristic).value; assertIsNumber(value); return value; }; hue ??= read(this.Characteristic.Hue); saturation ??= read(this.Characteristic.Saturation); value ??= read(this.Characteristic.Brightness); // Set the colour const rgb = this.toRGB(hue, saturation, value); this.log.info(`SET Light ${type} ${rgb} (hue=${hue}, saturation=${saturation}%, value=${value}%)`); await this.device.setSetting(settings.colour.key, AmbientLightColor.CustomColor); await this.device.setSetting(settings.custom.key, rgb); } // Convert a colour from from hue/saturation to RGB toRGB(hue, saturation, value) { const maxRgb = value * 255 / 100; const chroma = maxRgb * saturation / 100; const minRgb = maxRgb - chroma; const deltaRgb = chroma * ((hue / 60) % 1); let rgb; if (hue < 60) rgb = [maxRgb, minRgb + deltaRgb, minRgb]; else if (hue < 120) rgb = [maxRgb - deltaRgb, maxRgb, minRgb]; else if (hue < 180) rgb = [minRgb, maxRgb, minRgb + deltaRgb]; else if (hue < 240) rgb = [minRgb, maxRgb - deltaRgb, maxRgb]; else if (hue < 300) rgb = [minRgb + deltaRgb, minRgb, maxRgb]; else /* (h < 360) */ rgb = [maxRgb, minRgb, maxRgb - deltaRgb]; // Convert the RGB value to hex const [r, g, b] = rgb.map(v => Math.round(v)); const numeric = 0x1000000 + r * 0x10000 + g * 0x100 + b; return '#' + numeric.toString(16).substring(1); } // Convert a colour from RGB to hue/saturation fromRGB(rgbHex) { // Convert from hex to individual RGB components const r = parseInt(rgbHex.substring(1, 3), 16); const g = parseInt(rgbHex.substring(3, 5), 16); const b = parseInt(rgbHex.substring(5, 7), 16); // Perform the conversion const minRgb = Math.min(r, g, b); const maxRgb = Math.max(r, g, b); const chroma = maxRgb - minRgb; let sector; if (chroma === 0) { sector = 0; // (dummy value for white, i.e. R=G=B=V) } else if (maxRgb === r) { // 0-60° or 300-360° sector = (b - g) / chroma; if (sector < 0) sector += 6; } else if (maxRgb === g) { // 60-180° sector = (g - r) / chroma + 2; } else { // (maxRgb === b) 180-300° sector = (r - b) / chroma + 4; } // Scale and return the hue, saturation, and value return { hue: Math.round(sector * 60), saturation: maxRgb ? Math.round((chroma / maxRgb) * 100) : 0, brightness: Math.round(maxRgb * 100 / 255) }; } }; } // Limit the types of light for different appliances export const HasCleaningLight = (Base) => HasLight(Base, ['Ambient Light']); export const HasHoodLight = (Base) => HasLight(Base, ['Ambient Light', 'Functional Light']); export const HasRefrigerationLight = (Base) => HasLight(Base, ['Internal Light', 'External Light']); //# sourceMappingURL=has-light.js.map