UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

238 lines 15.4 kB
// Homebridge plugin for Home Connect home appliances // Copyright © 2025-2026 Alexander Thoukydides import { setTimeout as setTimeoutP } from 'timers/promises'; import { MS, assertIsDefined, assertIsNumber, formatList, plural } from './utils.js'; import { PowerState } from './api-value-types.js'; // HomeKit Target/Current Heating Cooling State (matches Characteristic values) export var ThermostatState; (function (ThermostatState) { ThermostatState[ThermostatState["Off"] = 0] = "Off"; ThermostatState[ThermostatState["Heat"] = 1] = "Heat"; ThermostatState[ThermostatState["Cool"] = 2] = "Cool"; ThermostatState[ThermostatState["Auto"] = 3] = "Auto"; // Maps to Cool for Current Heating Cooling State })(ThermostatState || (ThermostatState = {})); // Preference order of AC programs and associated Target Heating Cooling States const THERMOSTAT_PROGRAMS = [ ['HeatingVentilationAirConditioning.AirConditioner.Program.Fan', ThermostatState.Off], // All controls available ['HeatingVentilationAirConditioning.AirConditioner.Program.Cool', ThermostatState.Cool], // All controls available ['HeatingVentilationAirConditioning.AirConditioner.Program.Heat', ThermostatState.Heat], // All controls available ['HeatingVentilationAirConditioning.AirConditioner.Program.Auto', ThermostatState.Auto], // No manual fan speed ['HeatingVentilationAirConditioning.AirConditioner.Program.Dry', ThermostatState.Cool], // No manual fan speed ['HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean', ThermostatState.Off] // No fan or temperature control ]; // Timings for switching on appliance power const READY_TIMEOUT = 10 * MS; // Maximum time for appliance to power on const READY_DELAY = 100; // Delay before (re)checking appliance state // Add an air conditioner fan and thermostat to an accessory export function HasFanAC(Base) { return class HasFan extends Base { // Accessory services activeService; thermostatService; // Supported air conditioner programs in preference order acPrograms = []; // Mixin constructor constructor(...args) { super(...args); // Add a fan (v2) service (instead of the usual HasActive service) this.activeService = this.makeService(this.Service.Fanv2, 'Fan'); // Continue initialisation asynchronously this.asyncInitialise('FanAC', this.initHasFanAC()); } // Asynchronous initialisation async initHasFanAC() { // Enable polling of selected/active programs when connected this.device.pollPrograms(); // Read the air conditioner program details const programs = await this.getCached('fan', () => this.device.getAvailablePrograms()); const programsList = (programs) => formatList(programs.map(program => program.key.replace(/^.*\./, ''))); this.log.info(`Air conditioner supports ${plural(programs.length, 'program')}: ${programsList(programs)}`); // Read the options supported by each program (in preference order) for (const [key, thermostatState] of THERMOSTAT_PROGRAMS) { if (!programs.some(program => program.key === key)) continue; const { options } = await this.getCached(`fan ${key}`, () => this.device.getAvailableProgram(key)); const details = { key, thermostatState }; for (const option of options ?? []) { switch (option.key) { case 'HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedMode': details.fanAuto = option; break; case 'HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedPercentage': details.fanManual = option; break; case 'HeatingVentilationAirConditioning.AirConditioner.Option.SetpointTemperature': details.temperature = option; break; default: this.log.warn(`Unexpected air conditioner option ${option.key}`); } } const temperatureUnit = details.temperature?.unit; if (temperatureUnit !== '°C') throw new Error(`Unsupported temperature unit for program ${key}: ${temperatureUnit}`); this.acPrograms.push(details); } const unsupported = programs.filter(program => !THERMOSTAT_PROGRAMS.some(([key]) => key === program.key)); if (unsupported.length) this.log.warn(`${plural(unsupported.length, 'unsupported program')}: ${programsList(unsupported)}`); if (!this.acPrograms.length) throw new Error('No supported air conditioner programs'); // Coalesce HomeKit updates to control the air conditioner const updateHC = this.makeSerialisedObject(value => this.updateACHC(value)); // Add the fan service this.addFan(updateHC); // If temperature setpoint is supported then create a thermostat if (this.acPrograms.some(details => details.temperature)) { this.log.info('Air conditioner supports temperature setpoint'); this.thermostatService = this.addThermostat(updateHC); this.thermostatService.addLinkedService(this.activeService); } } // Add a fan addFan(updateHC) { const service = this.activeService; const { INACTIVE: OFF, ACTIVE } = this.Characteristic.Active; const { INACTIVE, BLOWING_AIR } = this.Characteristic.CurrentFanState; const { MANUAL, AUTO } = this.Characteristic.TargetFanState; // Add the fan state characteristics const activeCharacteristic = service.getCharacteristic(this.Characteristic.Active); const currentFanStateCharacteristic = service.getCharacteristic(this.Characteristic.CurrentFanState); const targetFanStateCharacteristic = service.getCharacteristic(this.Characteristic.TargetFanState); const rotationSpeedCharacteristic = service.getCharacteristic(this.Characteristic.RotationSpeed); // Update from HomeKit to Home Connect this.onSetNumber(activeCharacteristic, value => updateHC({ active: value })); this.onSetNumber(targetFanStateCharacteristic, value => updateHC(value === AUTO ? { fanAuto: value, active: ACTIVE } : { fanAuto: value })); this.onSetNumber(rotationSpeedCharacteristic, value => updateHC({ fanPercent: value })); // Update from Home Connect to HomeKit this.device.on('HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedMode', mode => { const manual = mode === 'HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual'; this.log.info(`Fan ${manual ? 'manual' : 'automatic'} control`); targetFanStateCharacteristic.updateValue(manual ? MANUAL : AUTO); }); this.device.on('HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedPercentage', percent => { this.log.info(`Fan ${percent}%`); rotationSpeedCharacteristic.updateValue(percent); }); this.device.on('BSH.Common.Status.OperationState', () => { const active = this.device.isOperationState('Run'); this.log.info(`Fan ${active ? 'running' : 'off'}`); activeCharacteristic.updateValue(active ? ACTIVE : OFF); currentFanStateCharacteristic.updateValue(active ? BLOWING_AIR : INACTIVE); }); } // Add a thermostat addThermostat(updateHC) { const service = this.makeService(this.Service.Thermostat, 'Thermostat'); // Determine the superset of supported program options const getTemperatureConstraint = (key) => this.acPrograms.map(details => details.temperature?.constraints?.[key]).filter(Boolean); const gcd = (x, y) => y ? gcd(y, x % y) : x; const temperatureMinValue = Math.min(...getTemperatureConstraint('min')); const temperatureMaxValue = Math.max(...getTemperatureConstraint('max')); const temperatureMinStep = getTemperatureConstraint('stepsize').reduce(gcd); // Add the thermostat characteristics const currentHeatingCoolingStateCharacteristic = service.getCharacteristic(this.Characteristic.CurrentHeatingCoolingState); const targetHeatingCoolingStateCharacteristic = service.getCharacteristic(this.Characteristic.TargetHeatingCoolingState); const currentTemperatureCharacteristic = service.getCharacteristic(this.Characteristic.CurrentTemperature); const targetTemperatureCharacteristic = service.getCharacteristic(this.Characteristic.TargetTemperature); targetHeatingCoolingStateCharacteristic.setProps({ validValues: [...new Set(this.acPrograms.map(details => details.thermostatState))] }); targetTemperatureCharacteristic.setProps({ minValue: temperatureMinValue, maxValue: temperatureMaxValue, minStep: temperatureMinStep }); // Update from HomeKit to Home Connect this.onSetNumber(targetHeatingCoolingStateCharacteristic, value => updateHC({ state: value })); this.onSetNumber(targetTemperatureCharacteristic, value => updateHC({ temperature: value })); // Update from Home Connect to HomeKit this.device.on('HeatingVentilationAirConditioning.AirConditioner.Option.SetpointTemperature', temperature => { this.log.info(`Setpoint temperature ${temperature}°C`); currentTemperatureCharacteristic.updateValue(temperature); targetTemperatureCharacteristic.updateValue(temperature); }); this.device.on('BSH.Common.Root.ActiveProgram', programKey => { const details = this.acPrograms.find(details => details.key === programKey); if (details) { const { thermostatState } = details; const currentState = thermostatState === ThermostatState.Auto ? ThermostatState.Cool : thermostatState; this.log.info(`Target state ${ThermostatState[thermostatState]} (current state ${ThermostatState[currentState]})`); targetHeatingCoolingStateCharacteristic.updateValue(thermostatState); currentHeatingCoolingStateCharacteristic.updateValue(currentState); } else { this.log.info(programKey ? `Unsupported program: ${programKey}` : 'No active program'); currentHeatingCoolingStateCharacteristic.updateValue(ThermostatState.Off); } }); return service; } // Deferred update of Home Connect state from HomeKit characteristics updateACHC({ active, fanAuto, fanPercent, state, temperature }) { // Read missing Fan and Thermostat service values const read = (service, characteristic) => { const value = service.getCharacteristic(characteristic).value; assertIsNumber(value); return value; }; active ??= read(this.activeService, this.Characteristic.Active); fanAuto ??= read(this.activeService, this.Characteristic.TargetFanState); fanPercent ??= read(this.activeService, this.Characteristic.RotationSpeed); if (this.thermostatService) { state ??= read(this.thermostatService, this.Characteristic.TargetHeatingCoolingState); temperature ??= read(this.thermostatService, this.Characteristic.TargetTemperature); } // Configure the air conditioner const { INACTIVE: OFF, ACTIVE } = this.Characteristic.Active; const { AUTO } = this.Characteristic.TargetFanState; if (fanAuto !== AUTO && fanPercent === 0) active = OFF; return this.setAC(active === ACTIVE, fanAuto === AUTO, fanPercent, state, temperature); } // Configure the air conditioner for a particular mode and speed async setAC(active, auto, percent, state, temperature) { if (!active) { // Turn the fan off this.log.info('SET A/C off'); await this.device.stopProgram(); return; } // If there is an active supported program then identify its details let program; if (this.device.isOperationState('Run')) { const programKey = this.device.getItem('BSH.Common.Root.ActiveProgram'); program = this.acPrograms.find(p => p.key === programKey); } // Choose the preferred program(s) for the HomeKit state const preferred = this.acPrograms.filter(p => state !== undefined ? state === p.thermostatState : auto || p.fanManual); // Keep active program if suitable, otherwise select first preferred if (!program || !preferred.includes(program)) program = preferred[0]; assertIsDefined(program); let description = `SET program ${program.key.replace(/^.*\./, '')}`; // Select the appropriate program options const options = {}; if (program.temperature && temperature !== undefined) { description += ` setpoint ${temperature}°C`; options['HeatingVentilationAirConditioning.AirConditioner.Option.SetpointTemperature'] = temperature; } if (program.fanManual && !auto) { description += ` fan manual ${percent}%`; options['HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedMode'] = 'HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual'; options['HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedPercentage'] = percent; } else if (program.fanAuto) { description += ' fan automatic'; options['HeatingVentilationAirConditioning.AirConditioner.Option.FanSpeedMode'] = 'HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic'; } this.log.info(description); // Ensure that the appliance is on before starting a program if (!this.device.isOperationState('Run')) { await this.device.setSetting('BSH.Common.Setting.PowerState', PowerState.On); await setTimeoutP(READY_DELAY); await this.device.waitOperationState(['Run'], READY_TIMEOUT); await setTimeoutP(READY_DELAY); } // Start the program with the requested options await this.device.startOrModifyProgram(program.key, options); } }; } //# sourceMappingURL=has-fan-ac.js.map