diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/@types/miio.d.ts | 26 | ||||
| -rw-r--r-- | src/accessory.ts | 170 | ||||
| -rw-r--r-- | src/device.ts | 162 | ||||
| -rw-r--r-- | src/index.ts | 8 | ||||
| -rw-r--r-- | src/platform.ts | 64 | ||||
| -rw-r--r-- | src/settings.ts | 3 |
6 files changed, 433 insertions, 0 deletions
diff --git a/src/@types/miio.d.ts b/src/@types/miio.d.ts new file mode 100644 index 0000000..0e277af --- /dev/null +++ b/src/@types/miio.d.ts @@ -0,0 +1,26 @@ +declare module 'miio' { + type PrimitiveTypes = string | number | boolean + + interface DeviceOptions { + address: string + port?: number + token?: string + } + + interface DeviceInfo { + id: number + token: string + model: string + } + + interface DeviceHandle { + api: DeviceInfo + } + + interface Device { + handle: DeviceHandle + call(method: string, arguments: PrimitiveTypes[]): Promise<PrimitiveTypes[]> + } + + function device(options: DeviceOptions): Promise<Device> +} diff --git a/src/accessory.ts b/src/accessory.ts new file mode 100644 index 0000000..a3bd051 --- /dev/null +++ b/src/accessory.ts @@ -0,0 +1,170 @@ +import type { Service, PlatformAccessory, CharacteristicValue } from 'homebridge'; +import type { Device } from './device.js'; + +import { Platform } from './platform.js'; +import { WindLevel } from './device.js'; + +export class Accessory { + private service: Service; + + constructor( + private readonly platform: Platform, + private readonly accessory: PlatformAccessory, + private readonly device: Device, + ) { + this.accessory.getService(this.platform.Service.AccessoryInformation)! + .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Xiaomi') + .setCharacteristic(this.platform.Characteristic.Model, 'xiaomi.aircondition.ma2') + .setCharacteristic(this.platform.Characteristic.SerialNumber, this.device.deviceId().toString()); + + this.service = this.accessory.getService(this.platform.Service.HeaterCooler) + || this.accessory.addService(this.platform.Service.HeaterCooler); + + this.service.setCharacteristic(this.platform.Characteristic.Name, this.accessory.context.config.name); + + this.service.getCharacteristic(this.platform.Characteristic.Active) + .onSet(this.setActive.bind(this)) + .onGet(this.getActive.bind(this)); + + this.service.getCharacteristic(this.platform.Characteristic.CurrentHeaterCoolerState) + .onGet(this.getCurrentHeaterCoolerState.bind(this)); + + this.service.getCharacteristic(this.platform.Characteristic.TargetHeaterCoolerState) + .onSet(this.setTargetHeaterCoolerState.bind(this)) + .onGet(this.getTargetHeaterCoolerState.bind(this)); + + this.service.getCharacteristic(this.platform.Characteristic.CurrentTemperature) + .onGet(this.getCurrentTemperature.bind(this)); + + this.service.getCharacteristic(this.platform.Characteristic.CoolingThresholdTemperature) + .onSet(this.setTargetTemperature.bind(this)) + .onGet(this.getTargetTemperature.bind(this)) + .setProps({ + minValue: 16, + maxValue: 31, + minStep: 0.5, + }); + + this.service.getCharacteristic(this.platform.Characteristic.HeatingThresholdTemperature) + .onSet(this.setTargetTemperature.bind(this)) + .onGet(this.getTargetTemperature.bind(this)) + .setProps({ + minValue: 16, + maxValue: 31, + minStep: 0.5, + }); + + this.service.getCharacteristic(this.platform.Characteristic.RotationSpeed) + .onSet(this.setRotationSpeed.bind(this)) + .onGet(this.getRotationSpeed.bind(this)); + + this.service.getCharacteristic(this.platform.Characteristic.SwingMode) + .onSet(this.setSwingMode.bind(this)) + .onGet(this.getSwingMode.bind(this)); + + this.polling(); + } + + async polling() { + await this.device.refresh(); + this.platform.log.debug('Polling state from device: ', this.device.getRawState()); + setTimeout(this.polling.bind(this), 60000); + } + + async setActive(value: CharacteristicValue) { + if (value === this.platform.Characteristic.Active.ACTIVE) { + await this.device.turnOn(); + + return; + } + + await this.device.turnOff(); + } + + async getActive(): Promise<CharacteristicValue> { + return this.device.isOn() + ? this.platform.Characteristic.Active.ACTIVE + : this.platform.Characteristic.Active.INACTIVE; + } + + async getCurrentHeaterCoolerState(): Promise<CharacteristicValue> { + if (this.device.isOff()) { + return this.platform.Characteristic.CurrentHeaterCoolerState.INACTIVE; + } + + if (this.device.isCooling()) { + return this.device.temperature() > this.device.targetTemperature() + ? this.platform.Characteristic.CurrentHeaterCoolerState.COOLING + : this.platform.Characteristic.CurrentHeaterCoolerState.IDLE; + } + + if (this.device.isHeating()) { + return this.device.temperature() < this.device.targetTemperature() + ? this.platform.Characteristic.CurrentHeaterCoolerState.HEATING + : this.platform.Characteristic.CurrentHeaterCoolerState.IDLE; + } + + return this.platform.Characteristic.CurrentHeaterCoolerState.COOLING; + } + + async setTargetHeaterCoolerState(value: CharacteristicValue) { + switch (value) { + case this.platform.Characteristic.TargetHeaterCoolerState.HEAT: + await this.device.heating(); + break; + case this.platform.Characteristic.TargetHeaterCoolerState.COOL: + await this.device.cooling(); + break; + default: + await this.device.auto(); + break; + } + } + + async getTargetHeaterCoolerState(): Promise<CharacteristicValue> { + if (this.device.isCooling()) { + return this.platform.Characteristic.TargetHeaterCoolerState.COOL; + } + + if (this.device.isHeating()) { + return this.platform.Characteristic.TargetHeaterCoolerState.HEAT; + } + + return this.platform.Characteristic.TargetHeaterCoolerState.AUTO; + } + + getCurrentTemperature(): CharacteristicValue { + return this.device.temperature(); + } + + async setTargetTemperature(value: CharacteristicValue) { + await this.device.temperatureSetTo(value as number); + } + + getTargetTemperature(): CharacteristicValue { + return this.device.targetTemperature(); + } + + async setRotationSpeed(value: CharacteristicValue) { + const level = Math.ceil(value as number / 100 * WindLevel.Level7); + await this.device.windLevelSetTo(level); + } + + getRotationSpeed(): CharacteristicValue { + const level = this.device.windLevel(); + const speed = level * Math.ceil(100 / WindLevel.Level7); + return speed > 100 ? 100 : speed; + } + + async setSwingMode(value: CharacteristicValue) { + if (value === this.platform.Characteristic.SwingMode.SWING_ENABLED) { + await this.device.startSwinging(); + } else { + await this.device.stopSwinging(); + } + } + + getSwingMode() { + return this.device.swinging(); + } +} diff --git a/src/device.ts b/src/device.ts new file mode 100644 index 0000000..bd9211e --- /dev/null +++ b/src/device.ts @@ -0,0 +1,162 @@ +import type { Device as Miio } from 'miio'; + +const enum Power { + Off, + On, +} + +const enum OperationMode { + Auto = 1, + Cool, + Dry, + Fan, + Heat, +} + +export const enum WindLevel { + Auto, + Level1, + Level2, + Level3, + Level4, + Level5, + Level6, + Level7, +} + +export const enum Swing { + Off, + On, +} + +type DeviceState = { + power: Power; + mode: OperationMode; + settemp: number; + temperature: number; + wind_level: WindLevel; + swing: Swing; +}; + +export class Device { + private state: DeviceState = { + power: Power.Off, + mode: OperationMode.Cool, + settemp: 26, + temperature: 26, + wind_level: WindLevel.Auto, + swing: Swing.Off, + }; + + constructor( + private readonly miio: Miio, + ) { + // + } + + deviceId() { + return this.miio.handle.api.id; + } + + isOn() { + return this.state.power === Power.On; + } + + isOff() { + return ! this.isOn(); + } + + async turnOn() { + this.state.power = Power.On; + await this.miio.call('set_power', [this.state.power]); + await this.refresh(); + } + + async turnOff() { + this.state.power = Power.Off; + await this.miio.call('set_power', [this.state.power]); + await this.refresh(); + } + + isCooling() { + return this.state.mode === OperationMode.Cool; + } + + isHeating() { + return this.state.mode === OperationMode.Heat; + } + + async auto() { + this.state.mode = OperationMode.Auto; + await this.miio.call('set_mode', [this.state.mode]); + await this.refresh(); + } + + async cooling() { + this.state.mode = OperationMode.Cool; + await this.miio.call('set_mode', [this.state.mode]); + await this.refresh(); + } + + async heating() { + this.state.mode = OperationMode.Heat; + await this.miio.call('set_mode', [this.state.mode]); + await this.refresh(); + } + + async temperatureSetTo(temperature: number) { + this.state.settemp = temperature; + await this.miio.call('set_temp', [this.state.settemp]); + await this.refresh(); + } + + temperature() { + return this.state.temperature; + } + + targetTemperature() { + return this.state.settemp; + } + + async windLevelSetTo(level: WindLevel) { + this.state.wind_level = level; + await this.miio.call('set_wind_level', [level]); + await this.refresh(); + } + + windLevel() { + return this.state.wind_level; + } + + async startSwinging() { + this.state.swing = Swing.On; + await this.miio.call('set_swing', [this.state.swing]); + await this.refresh(); + } + + async stopSwinging() { + this.state.swing = Swing.Off; + await this.miio.call('set_swing', [this.state.swing]); + await this.refresh(); + } + + swinging() { + return this.state.swing; + } + + async refresh() { + const attributes = Object.keys(this.state); + const values = await this.miio.call('get_prop', attributes); + + this.state = attributes.reduce( + (carry, attr, i) => ({ ...carry, [attr]: values[i] }), + { ...this.state }, + ); + + return this; + } + + getRawState() { + return this.state; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a18f86f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +import type { API } from 'homebridge'; + +import { PLATFORM_NAME } from './settings.js'; +import { Platform } from './platform.js'; + +export default (api: API) => { + api.registerPlatform(PLATFORM_NAME, Platform); +}; diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..1ea16fb --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,64 @@ +import type { API, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge'; + +import miio from 'miio'; + +import { Accessory } from './accessory.js'; +import { Device } from './device.js'; +import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'; + +export class Platform implements DynamicPlatformPlugin { + public readonly Service: typeof Service; + public readonly Characteristic: typeof Characteristic; + + public readonly accessories: Map<string, PlatformAccessory> = new Map(); + public readonly discoveredCacheUUIDs: string[] = []; + + constructor( + public readonly log: Logging, + public readonly config: PlatformConfig, + public readonly api: API, + ) { + this.Service = api.hap.Service; + this.Characteristic = api.hap.Characteristic; + + this.api.on('didFinishLaunching', () => { + this.discoverDevices(); + }); + } + + configureAccessory(accessory: PlatformAccessory) { + this.log.info('Loading accessory from cache:', accessory.displayName); + this.accessories.set(accessory.UUID, accessory); + } + + async discoverDevices() { + for (const config of this.config.devices) { + const connection = await miio.device({ address: config.address, token: config.token }); + const device = new Device(connection); + const uuid = this.api.hap.uuid.generate(device.deviceId().toString(16)); + const existingAccessory = this.accessories.get(uuid); + + if (existingAccessory) { + this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName); + existingAccessory.context.config = config; + this.api.updatePlatformAccessories([existingAccessory]); + new Accessory(this, existingAccessory, device); + } else { + this.log.info('Adding new accessory:', config.name); + const accessory = new this.api.platformAccessory(config.name, uuid); + accessory.context.config = config; + new Accessory(this, accessory, device); + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); + } + + this.discoveredCacheUUIDs.push(uuid); + } + + for (const [uuid, accessory] of this.accessories) { + if (! this.discoveredCacheUUIDs.includes(uuid)) { + this.log.info('Removing existing accessory from cache:', accessory.displayName); + this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); + } + } + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..3a32b4b --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,3 @@ +export const PLUGIN_NAME = 'homebridge-xiaomi-aircondition-ma2'; + +export const PLATFORM_NAME = 'XiaomiAirConditionMa2'; |
