diff --git a/.eslintrc b/.eslintrc index 2bb198a..ee8c0a9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,13 +33,17 @@ "ctype", "damperposition", "deadband", + "dehum", "Fanv", "filtrlvl", "featureset", "Homebridge", "htsp", "mutex", + "rclg", + "rclgovercool", "rh", + "rhtg", "setpoint", "setpoints", "sig", diff --git a/src/accessory_thermostat.ts b/src/accessory_thermostat.ts index 6ff9a19..3f56e9d 100644 --- a/src/accessory_thermostat.ts +++ b/src/accessory_thermostat.ts @@ -11,7 +11,7 @@ import { FilterService } from './characteristics_filter'; import { convertSystemTemp2CharTemp, } from './helpers'; -import { ThermostatRHService } from './characteristics_humidity'; +import { ThermostatRHService, HumidifierService } from './characteristics_humidity'; import { FanService } from './characteristics_fan'; import { ACService } from './characteristics_ac'; import { BaseAccessory } from './accessory_base'; @@ -19,6 +19,7 @@ import { BaseAccessory } from './accessory_base'; export class ThermostatAccessory extends BaseAccessory { private service: Service; private fan_service?: Service; + private hum_service?: Service; private system_status: InfinityEvolutionSystemStatus; private system_config: InfinityEvolutionSystemConfig; private system_profile: InfinityEvolutionSystemProfile; @@ -85,11 +86,14 @@ export class ThermostatAccessory extends BaseAccessory { this.accessory.context, ).wrap(this.service); - // Humidity Control + // Humidity Sensor new ThermostatRHService( this.platform, this.accessory.context, ).wrap(this.service); + // Humidifier/Dehumidifier Control + this.hum_service = this.accessory.getService(this.platform.Service.HumidifierDehumidifier); + this.setupHumidifierService(); } setupFanService(): void { @@ -107,4 +111,20 @@ export class ThermostatAccessory extends BaseAccessory { this.accessory.context, ).wrap(this.fan_service); } + + setupHumidifierService(): void { + this.hum_service = this.hum_service || this.accessory.addService(this.platform.Service.HumidifierDehumidifier); + + this.system_config.fetch().then(async () => { + this.hum_service?.setCharacteristic( + this.platform.Characteristic.Name, + await this.system_config.getZoneName(this.accessory.context.zone) + ' Humidifier', + ); + }); + + new HumidifierService( + this.platform, + this.accessory.context, + ).wrap(this.hum_service); + } } diff --git a/src/characteristics_humidity.ts b/src/characteristics_humidity.ts index 4df35c2..1edb4e9 100644 --- a/src/characteristics_humidity.ts +++ b/src/characteristics_humidity.ts @@ -1,4 +1,6 @@ -import { CharacteristicWrapper, MultiWrapper } from './base'; +import { CharacteristicWrapper, MultiWrapper, ThermostatCharacteristicWrapper } from './base'; +import { convertSystemDehum2CharDehum, convertSystemHum2CharHum } from './helpers'; +import { STATUS } from './infinityApi'; class CurrentRH extends CharacteristicWrapper { ctype = this.Characteristic.CurrentRelativeHumidity; @@ -7,12 +9,91 @@ class CurrentRH extends CharacteristicWrapper { }; } -class TargetDehumidify extends CharacteristicWrapper { +class HumidifierActive extends ThermostatCharacteristicWrapper { + ctype = this.Characteristic.Active; + get = async () => { + // I'd rather use 'status' here instead of 'config', but if the accessory + // is inactive HomeKit always shows the target state as off. + const activity_name = await this.getActivity(); + const [c_humidifier, c_dehumidifier] = await Promise.all([ + this.system.config.getActivityHumidifierState(activity_name), + this.system.config.getActivityDehumidifierState(activity_name), + ]); + if (c_humidifier === STATUS.OFF && c_dehumidifier === STATUS.OFF) { + return this.Characteristic.Active.INACTIVE; + } else { + return this.Characteristic.Active.ACTIVE; + } + }; +} + +class HumidifierCurrentState extends ThermostatCharacteristicWrapper { + ctype = this.Characteristic.CurrentHumidifierDehumidifierState; + get = async () => { + const activity_name = await this.getActivity(); + const [s_humidifier, s_dehumidifier, c_humidifier, c_dehumidifier] = await Promise.all([ + this.system.status.getHumidifier(), + this.system.status.getDehumidifier(), + this.system.config.getActivityHumidifierState(activity_name), + this.system.config.getActivityDehumidifierState(activity_name), + ]); + if (s_humidifier === STATUS.ON) { + return this.Characteristic.CurrentHumidifierDehumidifierState.HUMIDIFYING; + } else if (s_dehumidifier === STATUS.ON) { + return this.Characteristic.CurrentHumidifierDehumidifierState.DEHUMIDIFYING; + } else if (c_humidifier === STATUS.ON || c_dehumidifier === STATUS.ON) { + return this.Characteristic.CurrentHumidifierDehumidifierState.IDLE; + } else { + return this.Characteristic.CurrentHumidifierDehumidifierState.INACTIVE; + } + }; +} + +class HumidifierTargetState extends ThermostatCharacteristicWrapper { + ctype = this.Characteristic.TargetHumidifierDehumidifierState; + get = async () => { + const activity_name = await this.getActivity(); + const [humidifier, dehumidifier] = await Promise.all([ + this.system.config.getActivityHumidifierState(activity_name), + this.system.config.getActivityDehumidifierState(activity_name), + ]); + if (humidifier === STATUS.ON && dehumidifier === STATUS.OFF) { + return this.Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER; + } else if (humidifier === STATUS.OFF && dehumidifier === STATUS.ON) { + return this.Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER; + } else { + // both off also returns here, but HumidifierActive handles that. + return this.Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER_OR_DEHUMIDIFIER; + } + }; +} + +class TargetDehumidifyPoint extends ThermostatCharacteristicWrapper { ctype = this.Characteristic.RelativeHumidityDehumidifierThreshold; + props = {minValue: 46, maxValue: 58, minStep: 2}; + default_value = 58; + + get = async () => { + return convertSystemDehum2CharDehum( + await this.system.config.getActivityDehumidifierTarget( + await this.getActivity(), + ), + ); + }; } -class TargetHumidify extends CharacteristicWrapper { +class TargetHumidifyPoint extends ThermostatCharacteristicWrapper { ctype = this.Characteristic.RelativeHumidityHumidifierThreshold; + props = {minValue: 5, maxValue: 45, minStep: 5}; + default_value = 5; + + get = async () => { + return convertSystemHum2CharHum( + await this.system.config.getActivityHumidifierTarget( + await this.getActivity(), + ), + ); + }; } @@ -25,7 +106,10 @@ export class ThermostatRHService extends MultiWrapper { export class HumidifierService extends MultiWrapper { WRAPPERS = [ CurrentRH, - TargetDehumidify, - TargetHumidify, + HumidifierActive, + HumidifierCurrentState, + HumidifierTargetState, + TargetHumidifyPoint, + TargetDehumidifyPoint, ]; } \ No newline at end of file diff --git a/src/helpers.spec.ts b/src/helpers.spec.ts index 91cd950..7cde8c3 100644 --- a/src/helpers.spec.ts +++ b/src/helpers.spec.ts @@ -1,5 +1,9 @@ import { + convertCharDehum2SystemDehum, + convertCharHum2SystemHum, convertCharTemp2SystemTemp, + convertSystemDehum2CharDehum, + convertSystemHum2CharHum, processSetpointDeadband, } from './helpers'; @@ -45,3 +49,43 @@ describe('processSetpointDeadband', () => { expect(processSetpointDeadband(72, 70, 'F', false)).toEqual([72, 74]); }); }); + +describe('humidity helpers', () => { + test('convertSystemHum2CharHum', () => { + expect(convertSystemHum2CharHum(1)).toEqual(5); + expect(convertSystemHum2CharHum(5)).toEqual(25); + expect(convertSystemHum2CharHum(8)).toEqual(40); + }); + + test('convertCharHum2SystemHum', () => { + expect(convertCharHum2SystemHum(40)).toEqual(8); + expect(convertCharHum2SystemHum(10)).toEqual(2); + }); + + test('there and back again', () => { + expect(convertCharHum2SystemHum(convertSystemHum2CharHum(1))).toEqual(1); + expect(convertCharHum2SystemHum(convertSystemHum2CharHum(8))).toEqual(8); + expect(convertSystemHum2CharHum(convertCharHum2SystemHum(50))).toEqual(50); + expect(convertSystemHum2CharHum(convertCharHum2SystemHum(60))).toEqual(60); + }); +}); + +describe('dehumidity helpers', () => { + test('convertSystemDehum2CharDehum', () => { + expect(convertSystemDehum2CharDehum(1)).toEqual(46); + expect(convertSystemDehum2CharDehum(5)).toEqual(54); + expect(convertSystemDehum2CharDehum(8)).toEqual(60); + }); + + test('convertCharHum2SystemHum', () => { + expect(convertCharDehum2SystemDehum(60)).toEqual(8); + expect(convertCharDehum2SystemDehum(80)).toEqual(18); + }); + + test('there and back again', () => { + expect(convertCharDehum2SystemDehum(convertSystemDehum2CharDehum(1))).toEqual(1); + expect(convertCharDehum2SystemDehum(convertSystemDehum2CharDehum(8))).toEqual(8); + expect(convertSystemDehum2CharDehum(convertCharDehum2SystemDehum(50))).toEqual(50); + expect(convertSystemDehum2CharDehum(convertCharDehum2SystemDehum(60))).toEqual(60); + }); +}); diff --git a/src/helpers.ts b/src/helpers.ts index 1458a09..6d7bcd9 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -70,4 +70,20 @@ export function processSetpointDeadband( } } return [htsp, clsp]; +} + +export function convertSystemHum2CharHum(level: number): CharacteristicValue { + return level * 5; +} + +export function convertCharHum2SystemHum(level: CharacteristicValue): number { + return Math.round(Number(level) / 5); +} + +export function convertSystemDehum2CharDehum(level: number): CharacteristicValue { + return level * 2 + 44; +} + +export function convertCharDehum2SystemDehum(level: CharacteristicValue): number { + return Math.round((Number(level) - 44) / 2); } \ No newline at end of file diff --git a/src/infinityApi.ts b/src/infinityApi.ts index 67e8cce..a02b693 100644 --- a/src/infinityApi.ts +++ b/src/infinityApi.ts @@ -42,6 +42,13 @@ interface BaseElement { '$': {id: string}; } +interface HumidityActivity extends BaseElement { + rhtg: string[]; + rclg: string[]; + rclgovercool: string[]; + humidifier: string[]; +} + interface ZoneActivity extends BaseElement { clsp: string[]; htsp: string[]; @@ -351,6 +358,17 @@ export class InfinityEvolutionSystemStatus extends BaseInfinityEvolutionSystemAp } } + async getHumidifier(): Promise { + await this.fetch(); + return this.data_object.status.humid[0]; + } + + async getDehumidifier(): Promise { + await this.fetch(); + // TODO: include cool also in dehumidifier being on? + return this.data_object.status.mode[0] === 'dehumidify' ? STATUS.ON : STATUS.OFF; + } + private async getZone(zone: string): Promise { await this.fetch(); return this.data_object.status.zones[0].zone.find( @@ -469,6 +487,46 @@ export class InfinityEvolutionSystemConfig extends BaseInfinityEvolutionSystemAp } } + private async getActivityHumidityConfig(activity_name: string): Promise { + await this.fetch(); + switch(activity_name) { + case ACTIVITY.VACATION: + return this.data_object.config.humidityVacation[0]; + case ACTIVITY.AWAY: + return this.data_object.config.humidityAway[0]; + case ACTIVITY.HOME: + case ACTIVITY.SLEEP: + case ACTIVITY.WAKE: + case ACTIVITY.MANUAL: + return this.data_object.config.humidityHome[0]; + default: + this.api_connection.log.error( + `Unknown activity '${activity_name}'. Report bug: https://bit.ly/3igbU7D`, + ); + return this.data_object.config.humidityHome[0]; + } + } + + async getActivityHumidifierState(activity_name: string): Promise { + const activity_obj = await this.getActivityHumidityConfig(activity_name); + return activity_obj.humidifier[0]; + } + + async getActivityHumidifierTarget(activity_name: string): Promise { + const activity_obj = await this.getActivityHumidityConfig(activity_name); + return Number(activity_obj.rhtg[0]); + } + + async getActivityDehumidifierState(activity_name: string): Promise { + const activity_obj = await this.getActivityHumidityConfig(activity_name); + return activity_obj.rclgovercool[0]; + } + + async getActivityDehumidifierTarget(activity_name: string): Promise { + const activity_obj = await this.getActivityHumidityConfig(activity_name); + return Number(activity_obj.rclg[0]); + } + private async getZoneActivityConfig(zone: string, activity_name: string): Promise { await this.fetch(); // Vacation is stored somewhere else...