UNPKG

homebridge-homeconnect

Version:
194 lines 10.8 kB
// Homebridge plugin for Home Connect home appliances // Copyright © 2019-2023 Alexander Thoukydides import { 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; // 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)); // Determine the supported fan speeds const getOption = (key, excludeOff) => { const manualOptions = manualProgram.options ?? []; 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}'` : '')); } // Add the fan service this.addFan(); } // Add a fan addFan() { const service = this.activeService; const { INACTIVE: OFF, ACTIVE } = this.Characteristic.Active; const { INACTIVE, BLOWING_AIR } = this.Characteristic.CurrentFanState; const { MANUAL, AUTO } = this.Characteristic.TargetFanState; // Control the fan const updateHC = this.makeSerialisedObject(value => this.updateFanHC(value)); // 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({ 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 }); if (!percent) return; 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 => { if (!programKey) return; const manual = 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); }); } // Deferred update of Home Connect state from HomeKit characteristics updateFanHC({ active, auto, percent }) { // Read missing 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); // 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); } // Configure the fan for a particular mode and speed async setFan(active, auto, percent) { 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}%`); 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); } else { // Start the manual program at the requested speed await this.device.startProgram(FAN_PROGRAM_MANUAL, { [option.key]: option.value }); } } } // 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