summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/@types/miio.d.ts26
-rw-r--r--src/accessory.ts170
-rw-r--r--src/device.ts162
-rw-r--r--src/index.ts8
-rw-r--r--src/platform.ts64
-rw-r--r--src/settings.ts3
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';