homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
227 lines • 12.8 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2019-2025 Alexander Thoukydides
import { assertIsBoolean, assertIsDefined, assertIsNumber } from './utils.js';
// Extractor fan programs
const FAN_PROGRAM_MANUAL = 'Cooking.Common.Program.Hood.Venting';
const FAN_PROGRAM_AUTO = 'Cooking.Common.Program.Hood.Automatic';
// Add an extractor fan to an accessory
export function HasFan(Base) {
return class HasFan extends Base {
// Accessory services
activeService;
boostService;
// Does the extractor fan support automatic speed control
fanSupportsAuto;
// Manual control fan speeds
fanLevels = [];
fanPercentStep = 0;
// 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('Fan', this.initHasFan());
}
// Asynchronous initialisation
async initHasFan() {
// Enable polling of selected/active programs when connected
this.device.pollPrograms();
// Read the fan program details
const programs = await this.getCached('fan', () => this.device.getAvailablePrograms());
if (!programs.length)
throw new Error('No fan programs are supported');
// Check which programs are supported by the extractor fan
// (DelayedShutOff (fan run-on) is not supported by this plugin)
const supportsProgram = (key) => programs.some(program => program.key === key);
const supportsManual = supportsProgram(FAN_PROGRAM_MANUAL);
this.fanSupportsAuto = supportsProgram(FAN_PROGRAM_AUTO);
if (!supportsManual)
throw new Error('No manual fan program');
// Read the options supported by the manual fan program
const manualProgram = await this.getCached('fan manual program', () => this.device.getAvailableProgram(FAN_PROGRAM_MANUAL));
const manualOptions = manualProgram.options ?? [];
// Determine the supported fan speeds
const getOption = (key, excludeOff) => {
const option = manualOptions.find(o => o.key === key);
const values = option?.constraints?.allowedvalues ?? [];
return values.filter(value => value !== excludeOff).map(value => ({ key, value }));
};
const levels = {
venting: getOption('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanOff'),
intensive: getOption('Cooking.Common.Option.Hood.IntensiveLevel', 'Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff')
};
const fanLevels = [...levels.venting, ...levels.intensive];
if (!fanLevels.length)
throw new Error('No fan speed levels');
this.log.info(`Fan supports ${levels.venting.length} venting levels + ${levels.intensive.length} intensive levels`
+ (this.fanSupportsAuto ? ' + auto mode' : ''));
// Select an appropriate rotation speed step size (suitable for Siri)
// (allow low=25%, medium=50%, and high=100% for Siri)
this.fanPercentStep = fanLevels.length <= 2 ? 100 / fanLevels.length
: (fanLevels.length <= 4 ? 25 : 5);
// Convert each rotation speed to a percentage
this.fanLevels = fanLevels.map((level, index) => {
const percent = (index + 1) * 100 / fanLevels.length;
const rounded = Math.floor(percent / this.fanPercentStep) * this.fanPercentStep;
return { key: level.key, value: level.value, percent: rounded };
});
// Tweak the percentage values to match Siri
const siriPercent = { low: 25, medium: 50, high: 100 };
for (const [siri, percent] of Object.entries(siriPercent)) {
const option = this.fromFanSpeedPercent(percent);
const level = this.fanLevels.find(o => o.key === option.key && o.value === option.value);
assertIsDefined(level);
Object.assign(level, { siri, percent });
}
// Verify that the fan speed mapping is stable
for (const level of this.fanLevels) {
const option = this.fromFanSpeedPercent(level.percent);
if (level.value !== option.value) {
this.log.error(`Unstable fan speed mapping: ${level.value} → ${level.percent}% → ${option.value}`);
}
this.log.info(` ${level.percent}% (${level.value})` + (level.siri ? ` = Siri '${level.siri}'` : ''));
}
// Control the fan
const updateHC = this.makeSerialisedObject(value => this.updateFanHC(value));
// Add the fan service
this.addFan(updateHC);
// If the fan has a boost mode then create a switch to control it
const hasBoostOption = manualOptions.some(o => o.key === 'Cooking.Common.Option.Hood.Boost');
if (hasBoostOption && this.hasOptionalFeature('Switch', 'Boost')) {
this.log.info('Fan supports boost mode');
this.boostService = this.addFanBoost(updateHC);
this.boostService.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
service.getCharacteristic(this.Characteristic.Active)
.onSet(this.onSetNumber(value => updateHC({ active: value })));
service.getCharacteristic(this.Characteristic.CurrentFanState);
service.getCharacteristic(this.Characteristic.TargetFanState)
.setProps(this.fanSupportsAuto ? { minValue: MANUAL, maxValue: AUTO, validValues: [MANUAL, AUTO] }
: { minValue: MANUAL, maxValue: MANUAL, validValues: [MANUAL] })
.onSet(this.onSetNumber(value => updateHC(value === AUTO ? { auto: value, active: ACTIVE } : { auto: value })));
// Add a rotation speed characteristic
service.getCharacteristic(this.Characteristic.RotationSpeed)
.setProps({ minValue: 0, maxValue: 100, minStep: this.fanPercentStep })
.onSet(this.onSetNumber(value => updateHC({ percent: value })));
// Update the status
const newLevel = (key, value) => {
const percent = this.toFanSpeedPercent({ key, value });
this.log.info(`Fan ${percent}%`);
service.updateCharacteristic(this.Characteristic.RotationSpeed, percent);
};
this.device.on('Cooking.Common.Option.Hood.VentingLevel', level => { newLevel('Cooking.Common.Option.Hood.VentingLevel', level); });
this.device.on('Cooking.Common.Option.Hood.IntensiveLevel', level => { newLevel('Cooking.Common.Option.Hood.IntensiveLevel', level); });
this.device.on('BSH.Common.Root.ActiveProgram', programKey => {
const manual = !programKey || programKey === FAN_PROGRAM_MANUAL;
this.log.info(`Fan ${manual ? 'manual' : 'automatic'} control`);
service.updateCharacteristic(this.Characteristic.TargetFanState, manual ? MANUAL : AUTO);
});
this.device.on('BSH.Common.Status.OperationState', () => {
const active = this.device.isOperationState('Run');
this.log.info(`Fan ${active ? 'running' : 'off'}`);
service.updateCharacteristic(this.Characteristic.Active, active ? ACTIVE : OFF);
service.updateCharacteristic(this.Characteristic.CurrentFanState, active ? BLOWING_AIR : INACTIVE);
});
}
// Add a boost switch
addFanBoost(updateHC) {
// Add a switch service for the boost option
const service = this.makeService(this.Service.Switch, 'Boost', 'boost');
// Add the boost characteristic
service.getCharacteristic(this.Characteristic.On)
.onSet(this.onSetBoolean(value => updateHC({ boost: value })));
// Update the status
this.device.on('Cooking.Common.Option.Hood.Boost', boost => {
this.log.info(`Boost ${boost ? 'on' : 'off'}`);
service.updateCharacteristic(this.Characteristic.On, boost);
});
return service;
}
// Deferred update of Home Connect state from HomeKit characteristics
updateFanHC({ active, auto, percent, boost }) {
// Read missing Fan service values
const read = (characteristic) => {
const value = this.activeService.getCharacteristic(characteristic).value;
assertIsNumber(value);
return value;
};
active ??= read(this.Characteristic.Active);
auto ??= read(this.Characteristic.TargetFanState);
percent ??= read(this.Characteristic.RotationSpeed);
// Read missing boost Switch service value
const readBoost = () => {
if (!this.boostService)
return;
const value = this.boostService.getCharacteristic(this.Characteristic.On).value;
assertIsBoolean(value);
return value;
};
boost ??= readBoost();
// Configure the fan
const { INACTIVE: OFF, ACTIVE } = this.Characteristic.Active;
const { AUTO } = this.Characteristic.TargetFanState;
if (auto !== AUTO && percent === 0)
active = OFF;
return this.setFan(active === ACTIVE, auto === AUTO, percent, boost);
}
// Configure the fan for a particular mode and speed
async setFan(active, auto, percent, boost) {
if (!active) {
// Turn the fan off
this.log.info('SET fan off');
await this.device.stopProgram();
}
else if (auto) {
// Start the automatic program
this.log.info('SET fan automatic');
await this.device.startProgram(FAN_PROGRAM_AUTO);
}
else {
const option = this.fromFanSpeedPercent(percent);
const snapPercent = this.toFanSpeedPercent(option);
this.log.info(`SET fan manual ${snapPercent}%${boost === true ? ' with boost' : ''}`);
if (this.device.isOperationState('Run')
&& this.device.getItem('BSH.Common.Root.ActiveProgram') === FAN_PROGRAM_MANUAL) {
// Try changing the options for the current program
await this.device.setActiveProgramOption(option.key, option.value);
if (boost !== undefined) {
await this.device.setActiveProgramOption('Cooking.Common.Option.Hood.Boost', boost);
}
}
else {
// Start the manual program at the requested speed
const options = { [option.key]: option.value };
if (boost !== undefined)
options['Cooking.Common.Option.Hood.Boost'] = boost;
await this.device.startProgram(FAN_PROGRAM_MANUAL, options);
}
}
}
// Convert from a rotation speed percentage to a program option
fromFanSpeedPercent(percent) {
if (!percent)
throw new Error('Attempted to convert 0% to fan program');
const index = Math.ceil(percent * this.fanLevels.length / 100) - 1;
const level = this.fanLevels[index];
assertIsDefined(level);
return level;
}
// Convert from a program option to a rotation speed percentage
toFanSpeedPercent(option) {
const level = this.fanLevels.find(o => o.key === option.key && o.value === option.value);
if (!level)
return 0; // (presumably FanOff or IntensiveStageOff)
return level.percent;
}
};
}
//# sourceMappingURL=has-fan.js.map