homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
134 lines • 7.23 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2026 Alexander Thoukydides
import { assertIsDefined, assertIsNumber, plural } from './utils.js';
import { Ventilation } from './api-value-types.js';
// Add a ventilation fan to an accessory
export function HasFanHob(Base) {
return class HasFanHob extends Base {
// Accessory services
fanService;
// Manual control fan speeds
fanLevels = [];
fanPercentStep = 0;
// Mixin constructor
constructor(...args) {
super(...args);
// Continue initialisation asynchronously
this.asyncInitialise('Fan', this.initHasVentilation());
}
// Asynchronous initialisation
async initHasVentilation() {
// Check whether the appliance supports ventilation fans
const allSettings = await this.getCached('settings', () => this.device.getSettings());
if (!allSettings.some(s => s.key === 'Cooking.Hob.Setting.Ventilation')) {
this.log.info('Does not support integrated ventilation');
return;
}
// Check whether a ventilation fan should be supported
if (!this.hasOptionalFeature('Fan', 'Ventilation'))
return;
// Check the supported fan speeds
const setting = await this.getCached('ventilation', () => this.device.getSetting('Cooking.Hob.Setting.Ventilation'));
const allLevels = setting?.constraints?.allowedvalues ?? [];
const fanLevels = allLevels.filter(level => level !== Ventilation.Off);
if (!fanLevels.length)
throw new Error('No ventilation levels');
this.log.info(`Fan supports ${plural(fanLevels.length, 'venting level')}`);
// 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 { level, 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.level === option.level);
assertIsDefined(level);
level.siri = siri;
level.percent = percent;
}
// Verify that the fan speed mapping is stable
for (const level of this.fanLevels) {
const option = this.fromFanSpeedPercent(level.percent);
if (level.level !== option.level) {
this.log.error(`Unstable fan speed mapping: ${level.level} → ${level.percent}% → ${option.level}`);
}
this.log.info(` ${level.percent}% (${level.level})` + (level.siri ? ` = Siri '${level.siri}'` : ''));
}
// Add the fan service
this.addFan();
}
// Add a fan
addFan() {
const { INACTIVE: OFF, ACTIVE } = this.Characteristic.Active;
const { INACTIVE, BLOWING_AIR } = this.Characteristic.CurrentFanState;
// Add a fan (v2) service for the hob ventilation
this.fanService = this.makeService(this.Service.Fanv2, 'Fan');
// Add the fan state characteristics
const activeCharacteristic = this.fanService.getCharacteristic(this.Characteristic.Active);
const rotationSpeedCharacteristic = this.fanService.getCharacteristic(this.Characteristic.RotationSpeed);
const currentFanStateCharacteristic = this.fanService.getCharacteristic(this.Characteristic.CurrentFanState);
rotationSpeedCharacteristic.setProps({ minValue: 0, maxValue: 100, minStep: this.fanPercentStep });
// Update from HomeKit to Home Connect
const updateHC = this.makeSerialisedObject(value => this.updateFanHC(value));
this.onSetNumber(activeCharacteristic, value => updateHC({ active: value }));
this.onSetNumber(rotationSpeedCharacteristic, value => updateHC({ percent: value }));
// Update from Home Connect to HomeKit
this.device.on('Cooking.Hob.Setting.Ventilation', level => {
const percent = this.toFanSpeedPercent(level);
const active = 0 < percent;
this.log.info(active ? `Fan ${percent}%` : 'Fan off');
activeCharacteristic.updateValue(active ? ACTIVE : OFF);
currentFanStateCharacteristic.updateValue(active ? BLOWING_AIR : INACTIVE);
rotationSpeedCharacteristic.updateValue(percent);
});
}
// Deferred update of Home Connect state from HomeKit characteristics
async updateFanHC({ active, percent }) {
// Read missing Fan service values
const read = (characteristic) => {
assertIsDefined(this.fanService);
const value = this.fanService.getCharacteristic(characteristic).value;
assertIsNumber(value);
return value;
};
active ??= read(this.Characteristic.Active);
percent ??= read(this.Characteristic.RotationSpeed);
// Configure the fan
const { INACTIVE: OFF } = this.Characteristic.Active;
if (active === OFF || percent === 0) {
this.log.info('SET fan off');
await this.device.setSetting('Cooking.Hob.Setting.Ventilation', Ventilation.Off);
}
else {
const { level } = this.fromFanSpeedPercent(percent);
const snapPercent = this.toFanSpeedPercent(level);
this.log.info(`SET fan ${snapPercent}%`);
await this.device.setSetting('Cooking.Hob.Setting.Ventilation', level);
}
}
// Convert from a rotation speed percentage to a program option
fromFanSpeedPercent(percent) {
if (!percent)
throw new Error('Attempted to convert 0% to ventilation level');
const index = Math.ceil(percent * this.fanLevels.length / 100) - 1;
const percentLevel = this.fanLevels[index];
assertIsDefined(percentLevel);
return percentLevel;
}
// Convert from a program option to a rotation speed percentage
toFanSpeedPercent(level) {
const percentLevel = this.fanLevels.find(o => o.level === level);
if (!percentLevel)
return 0; // (presumably Off)
return percentLevel.percent;
}
};
}
//# sourceMappingURL=has-fan-hob.js.map