├── .gitignore ├── src ├── fan │ ├── fanDetails.ts │ ├── vesyncFan.ts │ └── fanController.ts ├── api │ └── client.ts └── index.ts ├── tsconfig.json ├── config.schema.json ├── .github └── workflows │ └── build.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | config.js 4 | /src/test.ts -------------------------------------------------------------------------------- /src/fan/fanDetails.ts: -------------------------------------------------------------------------------- 1 | export type OnOrOff = 'on' | 'off'; 2 | export type FanSpeed = 1 | 2 | 3; 3 | 4 | export interface FanDetails { 5 | mode: 'auto' | 'manual' | 'sleep'; 6 | deviceStatus: OnOrOff; 7 | screenStatus: OnOrOff; 8 | level: FanSpeed; // fan speed level 9 | airQuality: string; // TODO refine type 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "sourceMap": false, 6 | "target": "ES2018", 7 | "resolveJsonModule": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "skipLibCheck": true 11 | }, 12 | "include": [ 13 | "src" 14 | ] 15 | } -------------------------------------------------------------------------------- /src/fan/vesyncFan.ts: -------------------------------------------------------------------------------- 1 | export class VesyncFan { 2 | name: string; 3 | mode: string; 4 | speed: number; 5 | uuid: string; 6 | status: string; 7 | 8 | constructor(deviceData) { 9 | this.name = deviceData.deviceName; 10 | this.mode = deviceData.mode; 11 | this.speed = deviceData.speed; 12 | this.uuid = deviceData.uuid; 13 | this.status = deviceData.deviceStatus; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "VesyncPlatform", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "headerDisplay": "Enter your Vesync username (email) and password. This information is required to connect to Vesync's online services.", 6 | "schema": { 7 | "type": "object", 8 | "properties": { 9 | "username": { 10 | "title": "Username or email", 11 | "type": "string", 12 | "required": true 13 | }, 14 | "password": { 15 | "title": "Password", 16 | "type": "string", 17 | "required": true 18 | } 19 | } 20 | }, 21 | "form": null, 22 | "display": null 23 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [10.x, 12.x, 14.x, 15.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build the project 30 | run: npm run build 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-vesync-client", 3 | "version": "0.0.3", 4 | "description": "A Homebridge plugin to control Levoit Air Purifiers with via the Vesync Platform.", 5 | "keywords": [ 6 | "homebridge-plugin", 7 | "levoit", 8 | "vesync" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/npm/cli.git" 13 | }, 14 | "homepage": "https://github.com/Kwintenvdb/homebridge-vesync-client", 15 | "main": "dist/index.js", 16 | "scripts": { 17 | "build": "tsc", 18 | "watch": "tsc --watch" 19 | }, 20 | "author": "Kwinten Van den Berghe", 21 | "license": "ISC", 22 | "engines": { 23 | "homebridge": ">=0.4.53" 24 | }, 25 | "dependencies": { 26 | "crypto": "^1.0.1", 27 | "got": "^11.0.2" 28 | }, 29 | "devDependencies": { 30 | "homebridge": "^1.3.4", 31 | "typescript": "^4.2.4" 32 | } 33 | } -------------------------------------------------------------------------------- /src/fan/fanController.ts: -------------------------------------------------------------------------------- 1 | import { VesyncFan } from './vesyncFan'; 2 | import { createAuthBody, createBaseBody, VesyncClient } from '../api/client'; 3 | import { FanDetails, FanSpeed } from './fanDetails'; 4 | 5 | function createDetailsBody() { 6 | return { 7 | 'appVersion': 'V2.9.35 build3', 8 | 'phoneBrand': 'HomeBridge-Vesync', 9 | 'phoneOS': 'HomeBridge-Vesync', 10 | 'traceId': Date.now() 11 | }; 12 | } 13 | 14 | export class FanController { 15 | private details: FanDetails | any = {}; 16 | 17 | constructor( 18 | private readonly fan: VesyncFan, 19 | private readonly client: VesyncClient 20 | ) { 21 | this.initialize(); 22 | } 23 | 24 | private async initialize() { 25 | this.details = await this.getDetails(); 26 | } 27 | 28 | setPower(on: boolean) { 29 | // this.details = on; 30 | const power = on ? 'on' : 'off'; 31 | this.details.deviceStatus = power; 32 | this.client.put('131airPurifier/v1/device/deviceStatus', { 33 | 'uuid': this.fan.uuid, 34 | 'status': power 35 | }); 36 | } 37 | 38 | isOn(): boolean { 39 | return this.details.deviceStatus === 'on'; 40 | } 41 | 42 | getDetails(): Promise { 43 | return this.client.post('131airPurifier/v1/device/deviceDetail', { 44 | headers: { 45 | ...this.client.createHeaders() 46 | }, 47 | json: { 48 | ...createBaseBody(), 49 | ...createAuthBody(this.client), 50 | ...createDetailsBody(), 51 | 'uuid': this.fan.uuid 52 | } 53 | }).json(); 54 | } 55 | 56 | setFanSpeed(speed: FanSpeed) { 57 | this.details.level = speed; 58 | return this.client.put('131airPurifier/v1/device/updateSpeed', { 59 | 'uuid': this.fan.uuid, 60 | 'level': speed 61 | }); 62 | } 63 | 64 | getFanSpeed(): FanSpeed { 65 | return this.details.level; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-vesync-client 2 | 3 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/Kwintenvdb/homebridge-vesync-client/Build) 4 | 5 | A [Homebridge](https://github.com/homebridge/homebridge) plugin to control Levoit Air Purifiers with via the Vesync Platform. 6 | 7 | **NOTE: This plugin is still heavily work in progress. Therefore it has limited functionality and may introduce breaking changes at any time.** 8 | 9 | ## Installation 10 | 11 | See the [Homebridge](https://github.com/homebridge/homebridge) documentation for how to install and run Homebridge. 12 | 13 | To install the plugin, run the following on the command line on the machine where Homebridge is installed: 14 | 15 | ``` 16 | npm install -g homebridge-vesync-client 17 | ``` 18 | 19 | ## Configuration 20 | 21 | * Via the Homebridge UI, enter the **Homebridge Vesync Client** plugin settings. 22 | * Enter your [Vesync app](https://www.vesync.com/app) credentials. 23 | * Save and restart Homebridge. 24 | 25 | This plugin requires your Vesync credentials as it communicates with the Vesync devices via Vesync's own API. Your credentials are only stored in the Homebridge config and not sent to any server except Vesync's. 26 | 27 | You can also do this directly via the homebridge config by adding your credentials to the config file under `platforms`. Replace the values of `username` and `password` by your credentials. 28 | 29 | ```json 30 | "platforms": [ 31 | { 32 | "platform": "VesyncPlatform", 33 | "username": "email@example.com", 34 | "password": "enter_your_password" 35 | } 36 | ] 37 | ``` 38 | 39 | ## Features 40 | 41 | This plugin currently supports the following features. 42 | 43 | ### Levoit Air Purifier 44 | 45 | * Turning the Air Purifier on and off 46 | 47 | On the roadmap: 48 | 49 | * Changing fan speed 50 | * Displaying filter life level 51 | * Toggling night and auto mode 52 | * Extract TypeScript Vesync API client into separate library (port of [pyvesync](https://github.com/webdjoe/pyvesync)) 53 | 54 | ## Local Development 55 | 56 | If you want to develop and run the plugin locally, you can do the following: 57 | 58 | 1. Clone the repository. 59 | 1. Run the following scripts on the command line: 60 | 61 | ``` 62 | cd homebridge-vesync-client 63 | npm install 64 | npm run watch 65 | npm link 66 | ``` 67 | 68 | Afterwards, restart Homebridge. Restart Homebridge whenever you have made changes to the code. 69 | -------------------------------------------------------------------------------- /src/api/client.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import * as crypto from 'crypto'; 3 | import { VesyncFan } from '../fan/vesyncFan'; 4 | 5 | const request = got.extend({ 6 | prefixUrl: 'https://smartapi.vesync.com', 7 | responseType: 'json' 8 | }); 9 | 10 | export function createBaseBody() { 11 | return { 12 | 'acceptLanguage': 'en', 13 | 'timeZone': 'America/Chicago' 14 | }; 15 | } 16 | 17 | export function createAuthBody(client) { 18 | return { 19 | 'accountID': client.accountId, 20 | 'token': client.token 21 | }; 22 | } 23 | 24 | export class VesyncClient { 25 | private accountId: string = null; 26 | private token: string = null; 27 | 28 | post(url, options) { 29 | return request.post(url, options); 30 | } 31 | 32 | put(url, body) { 33 | const headers = this.createHeaders(); 34 | const options = { 35 | headers, 36 | json: { 37 | ...createBaseBody(), 38 | ...createAuthBody(this), 39 | ...body 40 | } 41 | }; 42 | return request.put(url, options); 43 | } 44 | 45 | createHeaders() { 46 | return { 47 | 'accept-language': 'en', 48 | 'accountid': this.accountId, 49 | 'appversion': '2.5.1', 50 | 'content-type': 'application/json', 51 | 'tk': this.token, 52 | 'tz': 'America/New_York', 53 | 'user-agent': 'HomeBridge-Vesync' 54 | } 55 | } 56 | 57 | async login(username, password) { 58 | const pwdHashed = crypto.createHash('md5').update(password).digest('hex'); 59 | const response: any = await this.post('cloud/v1/user/login', { 60 | json: { 61 | 'acceptLanguage': 'en', 62 | 'appVersion': '2.5.1', 63 | 'phoneBrand': 'SM N9005', 64 | 'phoneOS': 'Android', 65 | 'email': username, 66 | 'password': pwdHashed, 67 | 'devToken': '', 68 | 'userType': 1, 69 | 'method': 'login', 70 | 'timeZone': 'America/New_York', 71 | 'token': '', 72 | 'traceId': Date.now() 73 | }, 74 | responseType: 'json' 75 | }).json(); 76 | 77 | if (!response || !response.result) { 78 | throw new Error('Invalid login response from Vesync API.'); 79 | } 80 | 81 | const result = response.result; 82 | this.accountId = result.accountID; 83 | this.token = result.token; 84 | } 85 | 86 | async getDevices(): Promise { 87 | const req = this.post('cloud/v2/deviceManaged/devices', { 88 | headers: this.createHeaders(), 89 | json: { 90 | 'acceptLanguage': 'en', 91 | 'accountID': this.accountId, 92 | 'appVersion': '1.1', 93 | 'method': 'devices', 94 | 'pageNo': 1, 95 | 'pageSize': 1000, 96 | 'phoneBrand': 'HomeBridge-Vesync', 97 | 'phoneOS': 'HomeBridge-Vesync', 98 | 'timeZone': 'America/Chicago', 99 | 'token': this.token, 100 | 'traceId': Date.now(), 101 | } 102 | }); 103 | const response: any = await req.json(); 104 | const list = response.result.list; 105 | const fans = list.map(it => new VesyncFan(it)); 106 | return fans; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | API, 3 | Logging, 4 | Service, 5 | PlatformConfig, 6 | DynamicPlatformPlugin, 7 | PlatformAccessory, 8 | UnknownContext, 9 | Categories, 10 | WithUUID, 11 | CharacteristicValue 12 | } from 'homebridge'; 13 | 14 | import { FanController } from './fan/fanController'; 15 | import { VesyncClient } from './api/client'; 16 | import { VesyncFan } from './fan/vesyncFan'; 17 | 18 | const client = new VesyncClient(); 19 | 20 | interface Config extends PlatformConfig { 21 | username: string; 22 | password: string; 23 | } 24 | 25 | class LevoitAirPurifier { 26 | private readonly airPurifierService: Service; 27 | private readonly airQualityService: Service; 28 | 29 | constructor( 30 | private readonly fan: VesyncFan, 31 | private readonly log: Logging, 32 | private readonly accessory: PlatformAccessory, 33 | private readonly api: API 34 | ) { 35 | const fanController = new FanController(fan, client); 36 | const hap = api.hap; 37 | this.airPurifierService = this.getOrAddService(hap.Service.AirPurifier); 38 | 39 | // this.airPurifierService.getCharacteristic(hap.Characteristic.FilterLifeLevel) 40 | // .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => { 41 | // log.info('getting filter life level...'); 42 | // callback(null, 50); 43 | // }); 44 | 45 | this.airPurifierService.getCharacteristic(hap.Characteristic.Active) 46 | .onGet(() => { 47 | const isOn = fanController.isOn(); 48 | return isOn ? hap.Characteristic.Active.ACTIVE : hap.Characteristic.Active.INACTIVE; 49 | }) 50 | .onSet((value: CharacteristicValue) => { 51 | const power = value === hap.Characteristic.Active.ACTIVE; 52 | fanController.setPower(power); 53 | return value; 54 | }); 55 | 56 | this.airPurifierService.getCharacteristic(hap.Characteristic.CurrentAirPurifierState) 57 | .onGet(() => { 58 | return hap.Characteristic.CurrentAirPurifierState.PURIFYING_AIR; 59 | }); 60 | 61 | this.airPurifierService.getCharacteristic(hap.Characteristic.TargetAirPurifierState) 62 | .onGet(() => { 63 | return hap.Characteristic.TargetAirPurifierState.AUTO; 64 | }); 65 | 66 | this.airPurifierService.getCharacteristic(hap.Characteristic.RotationSpeed) 67 | .setProps({ minStep: 33, maxValue: 99 }) 68 | .onGet(() => { 69 | const level = fanController.getFanSpeed(); 70 | return level * 33; 71 | }); 72 | 73 | this.airQualityService = this.getOrAddService(hap.Service.AirQualitySensor); 74 | this.airQualityService.getCharacteristic(hap.Characteristic.AirQuality) 75 | .onGet(() => { 76 | return hap.Characteristic.AirQuality.POOR; 77 | }); 78 | } 79 | 80 | private getOrAddService>(service: T): Service { 81 | return this.accessory.getService(service) ?? this.accessory.addService(service); 82 | } 83 | } 84 | 85 | class VesyncPlatform implements DynamicPlatformPlugin { 86 | private readonly cachedAccessories: PlatformAccessory[] = []; 87 | 88 | constructor( 89 | private readonly log: Logging, 90 | config: Config, 91 | private readonly api: API 92 | ) { 93 | this.api.on('didFinishLaunching', async () => { 94 | await client.login(config.username, config.password); 95 | await this.findDevices(); 96 | }); 97 | } 98 | 99 | private async findDevices() { 100 | const fans = await client.getDevices(); 101 | fans.forEach(fan => { 102 | const cached = this.cachedAccessories.find(a => a.UUID === fan.uuid); 103 | if (cached) { 104 | this.log.debug('Restoring cached accessory: ' + cached.displayName); 105 | new LevoitAirPurifier(fan, this.log, cached, this.api); 106 | } else { 107 | this.log.debug('Creating new fan accessory...') 108 | const platformAccessory = new this.api.platformAccessory(fan.name, fan.uuid, Categories.AIR_PURIFIER); 109 | new LevoitAirPurifier(fan, this.log, platformAccessory, this.api); 110 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [platformAccessory]); 111 | } 112 | }); 113 | } 114 | 115 | /** 116 | * REQUIRED - Homebridge will call the "configureAccessory" method once for every cached 117 | * accessory restored 118 | */ 119 | configureAccessory(accessory: PlatformAccessory): void { 120 | this.cachedAccessories.push(accessory); 121 | } 122 | 123 | } 124 | 125 | const PLUGIN_NAME = 'homebridge-vesync-client'; 126 | const PLATFORM_NAME = 'VesyncPlatform'; 127 | 128 | export = (homebridge: API) => { 129 | homebridge.registerPlatform(PLATFORM_NAME, VesyncPlatform); 130 | }; 131 | --------------------------------------------------------------------------------