import _ from 'lodash'; import { API, APIEvent, DynamicPlatformPlugin, HAP, Logging, CharacteristicSetCallback, CharacteristicEventTypes, CharacteristicValue, PlatformAccessory, PlatformAccessoryEvent, PlatformConfig, } from 'homebridge'; import { AxiosInstance } from 'axios'; import { createClient } from './api'; import { Appliance, WorkModes, WellbeingApi } from './types'; const PLUGIN_NAME = 'electrolux-wellbeing'; const PLATFORM_NAME = 'ElectroluxWellbeing'; // Pure A9 fans support speeds from [1, 9]. const FAN_SPEED_MULTIPLIER = 100 / 9; let hap: HAP, Service, Characteristic; let Accessory: typeof PlatformAccessory; export = (api: API) => { hap = api.hap; Accessory = api.platformAccessory; Service = hap.Service; Characteristic = hap.Characteristic; api.registerPlatform(PLATFORM_NAME, ElectroluxWellbeingPlatform); }; class ElectroluxWellbeingPlatform implements DynamicPlatformPlugin { private client?: AxiosInstance; private readonly log: Logging; private readonly api: API; private readonly config: PlatformConfig; private readonly accessories: PlatformAccessory[] = []; constructor(log: Logging, config: PlatformConfig, api: API) { this.log = log; this.api = api; this.config = config; api.on(APIEvent.DID_FINISH_LAUNCHING, async () => { if (this.needsConfiguration()) { this.log('Please configure this plugin first.'); return; } //this.removeAccessories(); try { this.client = await createClient({ username: this.config.username, password: this.config.password, }); } catch (err) { this.log.debug('Error while creating client', err); return; } const appliances = await this.getAllAppliances(); const applianceData = await Promise.all( appliances.map((appliance) => this.fetchApplianceData(appliance.pncId)), ); this.log.debug('Fetched: ', applianceData); appliances.map(({ applianceName, modelName, pncId }, i) => { this.addAccessory({ pncId, name: applianceName, modelName, firmwareVersion: applianceData[i]?.firmwareVersion, }); }); this.updateValues(applianceData); setInterval( () => this.checkAppliances(), this.getPollTime(this.config.pollTime), ); }); } needsConfiguration(): boolean { return !this.config.username || !this.config.password; } getPollTime(pollTime): number { if (!pollTime || pollTime < 5) { this.log.info('Set poll time is below 5s, forcing 5s'); return 5 * 1000; } this.log.debug(`Refreshing every ${pollTime}s`); return pollTime * 1000; } async checkAppliances() { const data = await this.fetchAppliancesData(); this.log.debug('Fetched: ', data); this.updateValues(data); } async fetchAppliancesData() { return await Promise.all( this.accessories.map((accessory) => this.fetchApplianceData(accessory.context.pncId), ), ); } async fetchApplianceData(pncId: string): Promise<Appliance | undefined> { try { const response: { data: WellbeingApi.ApplianceData } = await this.client!.get(`/Appliances/${pncId}`); const reported = response.data.twin.properties.reported; return { pncId, name: response.data.applianceData.applianceName, modelName: response.data.applianceData.modelName, firmwareVersion: reported.FrmVer_NIU, workMode: reported.Workmode, filterRFID: reported.FilterRFID, filterLife: reported.FilterLife, fanSpeed: reported.Fanspeed, UILight: reported.UILight, safetyLock: reported.SafetyLock, ionizer: reported.Ionizer, sleep: reported.Sleep, scheduler: reported.Scheduler, filterType: reported.FilterType, version: reported['$version'], pm1: reported.PM1, pm25: reported.PM2_5, pm10: reported.PM10, tvoc: reported.TVOC, co2: reported.CO2, temp: reported.Temp, humidity: reported.Humidity, envLightLevel: reported.EnvLightLvl, rssi: reported.RSSI, }; } catch (err) { this.log.info('Could not fetch appliances data: ' + err); } } async getAllAppliances() { try { const response: { data: WellbeingApi.Appliance[] } = await this.client!.get('/Domains/Appliances'); return response.data; } catch (err) { this.log.info('Could not fetch appliances: ' + err); return []; } } async sendCommand( pncId: string, command: string, value: CharacteristicValue, ) { this.log.debug('sending command', { [command]: value, }); try { const response = await this.client!.put(`/Appliances/${pncId}/Commands`, { [command]: value, }); this.log.debug('command responded', response.data); } catch (err) { this.log.info('Could run command', err); } } updateValues(data) { this.accessories.map((accessory) => { const { pncId } = accessory.context; const state = this.getApplianceState(pncId, data); // Guard against missing data due to API request failure. if (!state) { return; } // Keep firmware revision up-to-date in case the device is updated. accessory .getService(Service.AccessoryInformation)! .setCharacteristic( Characteristic.FirmwareRevision, state.firmwareVersion, ); accessory .getService(Service.TemperatureSensor)! .updateCharacteristic(Characteristic.CurrentTemperature, state.temp); accessory .getService(Service.HumiditySensor)! .updateCharacteristic( Characteristic.CurrentRelativeHumidity, state.humidity, ); accessory .getService(Service.CarbonDioxideSensor)! .updateCharacteristic(Characteristic.CarbonDioxideLevel, state.co2); if (state.envLightLevel) { // Env Light Level needs to be tested with lux meter accessory .getService(Service.LightSensor)! .updateCharacteristic( Characteristic.CurrentAmbientLightLevel, state.envLightLevel, ); } accessory .getService(Service.AirQualitySensor)! .updateCharacteristic( Characteristic.AirQuality, this.getAirQualityLevel(state.pm25), ) .updateCharacteristic(Characteristic.PM2_5Density, state.pm25) .updateCharacteristic(Characteristic.PM10Density, state.pm10) .updateCharacteristic( Characteristic.VOCDensity, this.convertTVOCToDensity(state.tvoc), ); accessory .getService(Service.AirPurifier)! .updateCharacteristic(Characteristic.FilterLifeLevel, state.filterLife) .updateCharacteristic( Characteristic.FilterChangeIndication, state.filterLife < 10 ? Characteristic.FilterChangeIndication.CHANGE_FILTER : Characteristic.FilterChangeIndication.FILTER_OK, ) .updateCharacteristic( Characteristic.Active, state.workMode !== WorkModes.Off, ) .updateCharacteristic( Characteristic.CurrentAirPurifierState, this.getAirPurifierState(state.workMode), ) .updateCharacteristic( Characteristic.TargetAirPurifierState, this.getAirPurifierStateTarget(state.workMode), ) .updateCharacteristic( Characteristic.RotationSpeed, state.fanSpeed * FAN_SPEED_MULTIPLIER, ) .updateCharacteristic( Characteristic.LockPhysicalControls, state.safetyLock, ) .updateCharacteristic(Characteristic.SwingMode, state.ionizer); }); } getApplianceState(pncId: string, data): Appliance { return _.find(data, { pncId }); } configureAccessory(accessory: PlatformAccessory): void { this.log('Configuring accessory %s', accessory.displayName); const { pncId } = accessory.context; accessory.on(PlatformAccessoryEvent.IDENTIFY, () => { this.log('%s identified!', accessory.displayName); }); accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.Active) .on( CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { const workMode = value === 1 ? WorkModes.Auto : WorkModes.Off; if ( accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.Active).value !== value ) { this.sendCommand(pncId, 'WorkMode', workMode); this.log.info( '%s AirPurifier Active was set to: ' + workMode, accessory.displayName, ); } callback(); }, ); accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.TargetAirPurifierState) .on( CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { const workMode = value === Characteristic.TargetAirPurifierState.MANUAL ? WorkModes.Manual : WorkModes.Auto; this.sendCommand(pncId, 'WorkMode', workMode); this.log.info( '%s AirPurifier Work Mode was set to: ' + workMode, accessory.displayName, ); callback(); }, ); accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.RotationSpeed) .on( CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { const fanSpeed = Math.floor( parseInt(value.toString()) / FAN_SPEED_MULTIPLIER, ); this.sendCommand(pncId, 'FanSpeed', fanSpeed); this.log.info( '%s AirPurifier Fan Speed set to: ' + fanSpeed, accessory.displayName, ); callback(); }, ); accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.LockPhysicalControls) .on( CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { if ( accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.LockPhysicalControls).value !== value ) { this.sendCommand(pncId, 'SafetyLock', value); this.log.info( '%s AirPurifier Saftey Lock set to: ' + value, accessory.displayName, ); } callback(); }, ); accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.SwingMode) .on( CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { if ( accessory .getService(Service.AirPurifier)! .getCharacteristic(Characteristic.SwingMode).value !== value ) { this.sendCommand(pncId, 'Ionizer', value); this.log.info( '%s AirPurifier Ionizer set to: ' + value, accessory.displayName, ); } callback(); }, ); this.accessories.push(accessory); } addAccessory({ name, modelName, pncId, firmwareVersion }) { const uuid = hap.uuid.generate(pncId); if (!this.isAccessoryRegistered(name, uuid)) { this.log.info('Adding new accessory with name %s', name); const accessory = new Accessory(name, uuid); accessory.context.pncId = pncId; accessory.addService(Service.AirPurifier); accessory.addService(Service.AirQualitySensor); accessory.addService(Service.TemperatureSensor); accessory.addService(Service.CarbonDioxideSensor); accessory.addService(Service.HumiditySensor); accessory.addService(Service.LightSensor); accessory .getService(Service.AccessoryInformation)! .setCharacteristic(Characteristic.Manufacturer, 'Electrolux') .setCharacteristic(Characteristic.Model, modelName) .setCharacteristic(Characteristic.SerialNumber, pncId) .setCharacteristic(Characteristic.FirmwareRevision, firmwareVersion); this.configureAccessory(accessory); this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [ accessory, ]); } else { this.log.info( 'Accessory name %s already added, loading from cache ', name, ); } } removeAccessories() { this.log.info('Removing all accessories'); this.api.unregisterPlatformAccessories( PLUGIN_NAME, PLATFORM_NAME, this.accessories, ); this.accessories.splice(0, this.accessories.length); } isAccessoryRegistered(name: string, uuid: string) { return !!_.find(this.accessories, { UUID: uuid }); } getAirQualityLevel(pm25: number): number { switch (true) { case pm25 < 6: return Characteristic.AirQuality.EXCELLENT; case pm25 < 12: return Characteristic.AirQuality.GOOD; case pm25 < 36: return Characteristic.AirQuality.FAIR; case pm25 < 50: return Characteristic.AirQuality.INFERIOR; case pm25 >= 50: return Characteristic.AirQuality.POOR; } return Characteristic.AirQuality.UNKNOWN; } getAirPurifierState(workMode: WorkModes): number { if (workMode !== WorkModes.Off) { return Characteristic.CurrentAirPurifierState.PURIFYING_AIR; } return Characteristic.CurrentAirPurifierState.INACTIVE; } getAirPurifierStateTarget(workMode: WorkModes): number { if (workMode === WorkModes.Auto) { return Characteristic.TargetAirPurifierState.AUTO; } return Characteristic.TargetAirPurifierState.MANUAL; } // Best effort attempt to convert Wellbeing TVOC ppb reading to μg/m3, but we lack insight into their algorithms // or TVOC densities. We assume 1 ppb = 3.243 μg/m3 (see benzene @ 20C [1]) as this produces results (μg/m3) that fit // quite well within the defined ranges in [2]. // // Wellbeing defines 1500 ppb as possibly having an effect on health when exposed to these levels for a month, [2] // lists 400-500 μg/m3 as _marginal_ which sounds like a close approximation. Here's an example where 1500 ppb falls // within the _marginal_ range. // // 1500 * 3.243 / 10 = 486.45 // // Note: It's uncertain why we have to divide the result by 10 for the values to make sense, perhaps this is a // Wellbeing quirk, but at least the values look good. // // The maximum value shown by Wellbeing is 4000 ppb and the maximum value accepted by HomeKit is 1000 μg/m3, our // assumed molecular density may put the value outside of the HomeKit range, but not by much, which seems acceptable: // // 4000 * 3.243 / 10 = 1297.2 // // [1] https://uk-air.defra.gov.uk/assets/documents/reports/cat06/0502160851_Conversion_Factors_Between_ppb_and.pdf // [2] https://myhealthyhome.info/assets/pdfs/TB531rev2TVOCInterpretation.pdf convertTVOCToDensity(tvocppb: number): number { const ugm3 = (tvocppb * 3.243) / 10; return Math.min(ugm3, 1000); } }