mirror of
https://github.com/koush/scrypted.git
synced 2026-03-06 11:21:58 +00:00
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import axios from 'axios';
|
|
import { wrapper } from 'axios-cookiejar-support';
|
|
import { CookieJar } from 'tough-cookie';
|
|
import { Battery, DeviceDiscovery, DeviceProvider, Dock, OnOff, Pause, Refresh, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue, StartStop } from '@scrypted/sdk';
|
|
import sdk from '@scrypted/sdk';
|
|
import { StorageSettings } from "@scrypted/common/src/settings";
|
|
import qs from 'query-string';
|
|
import crypto from 'crypto';
|
|
import throttle from 'lodash/throttle';
|
|
|
|
const rc4 = require('arc4');
|
|
|
|
|
|
const { log, deviceManager } = sdk;
|
|
|
|
function generateAgent() {
|
|
// 13 characters between A and E.
|
|
let agent = '';
|
|
for (let i = 0; i < 13; i++) {
|
|
const r = Math.floor(Math.random() * 5);
|
|
const c = String.fromCharCode('A'.charCodeAt(0) + r);
|
|
agent += c;
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
function generateDeviceId() {
|
|
// 13 characters between A and E.
|
|
let agent = '';
|
|
for (let i = 0; i < 13; i++) {
|
|
const r = Math.floor(Math.random() * 26);
|
|
const c = String.fromCharCode('a'.charCodeAt(0) + r);
|
|
agent += c;
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
function apiToJson(data: string) {
|
|
return JSON.parse(data.replace('&&&START&&&', ''));
|
|
}
|
|
|
|
function generateEncSignature(url: string, method: string, signedNonce: string, parans: any) {
|
|
const signatureParams = [
|
|
method.toUpperCase(),
|
|
url.split("com")[1].replace("/app/", "/")
|
|
];
|
|
for (const [k, v] of Object.entries(parans)) {
|
|
signatureParams.push(`${k}=${v}`);
|
|
}
|
|
signatureParams.push(signedNonce);
|
|
const signature = signatureParams.join('&');
|
|
return crypto.createHash('sha1').update(signature).digest().toString('base64');
|
|
}
|
|
|
|
function encryptRc4(password: string, payload: string) {
|
|
const r = rc4('arc4', Buffer.from(password, 'base64'));
|
|
const e: Buffer = r.encodeBuffer(Buffer.concat([Buffer.alloc(1024), Buffer.from(payload)]));
|
|
const s = e.slice(1024);
|
|
return s.toString('base64');
|
|
}
|
|
|
|
function decryptRc4(password: string, payload: string) {
|
|
const r = rc4('arc4', Buffer.from(password, 'base64'));
|
|
const b: Buffer = r.encodeBuffer(Buffer.concat([Buffer.alloc(1024), Buffer.from(payload, 'base64')]));
|
|
return b.slice(1024).toString();
|
|
}
|
|
|
|
function generateEncParams(url: string, method: string, signedNonce: string, nonce: string, params: any, ssecurity: string) {
|
|
params['rc4_hash__'] = generateEncSignature(url, method, signedNonce, params)
|
|
for (const [k, v] of Object.entries(params)) {
|
|
params[k] = encryptRc4(signedNonce, v as string);
|
|
}
|
|
params.signature = generateEncSignature(url, method, signedNonce, params);
|
|
params.ssecurity = ssecurity;
|
|
params._nonce = nonce;
|
|
return params
|
|
}
|
|
|
|
function createSignedNonce(ssecurity: string, nonce: string) {
|
|
return crypto.createHash('sha256')
|
|
.update(Buffer.from(ssecurity, 'base64'))
|
|
.update(Buffer.from(nonce, 'base64'))
|
|
.digest().toString('base64');
|
|
}
|
|
|
|
const miio = require('../homebridge-xiaomi-roborock-vacuum/miio');
|
|
const agent = `Android-7.1.1-1.0.0-ONEPLUS A3010-136-${generateAgent()} APP/xiaomi.smarthome APPV/62830`;
|
|
const deviceId = generateDeviceId();
|
|
|
|
const jar = new CookieJar();
|
|
|
|
|
|
jar.setCookie("sdkVersion=accountsdk-18.8.15", 'http://mi.com');
|
|
jar.setCookie("sdkVersion=accountsdk-18.8.15", 'http://xiaomi.com');
|
|
jar.setCookie(`deviceId=${deviceId}`, 'http://mi.com');
|
|
jar.setCookie(`deviceId=${deviceId}`, 'http://xiaomi.com');
|
|
|
|
class RoborockVacuum extends ScryptedDeviceBase implements StartStop, Pause, Dock, Refresh, Battery {
|
|
storageSettings = new StorageSettings(this, {
|
|
ip: {
|
|
title: 'IP Address',
|
|
description: 'The address of the device.',
|
|
},
|
|
token: {
|
|
title: 'Token',
|
|
description: 'The token used to authenticate with the device.',
|
|
},
|
|
});
|
|
|
|
refreshThrottled = throttle(() => {
|
|
this.refresh(undefined, undefined);
|
|
}, 10000);
|
|
|
|
constructor(nativeId: string) {
|
|
super(nativeId);
|
|
}
|
|
|
|
async getRefreshFrequency() {
|
|
return 30;
|
|
}
|
|
|
|
async pollChanges() {
|
|
for (let i = 0; i < 20; i++) {
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
this.refreshThrottled();
|
|
}
|
|
}
|
|
|
|
async refresh(refreshInterface: string, userInitiated: boolean) {
|
|
const device = await this.findDevice();
|
|
const state = await device.state();
|
|
this.running = !state.charging || state.cleaning;
|
|
this.docked = state.charging;
|
|
this.paused = !state.charging && !state.cleaning;
|
|
this.batteryLevel = state.batteryLevel;
|
|
}
|
|
|
|
async findDevice() {
|
|
return await miio.device({
|
|
address: this.storageSettings.values.ip,
|
|
token: this.storageSettings.values.token,
|
|
});
|
|
}
|
|
|
|
async start() {
|
|
const device = await this.findDevice();
|
|
await device.activateCleaning();
|
|
this.pollChanges();
|
|
}
|
|
async stop() {
|
|
const device = await this.findDevice();
|
|
await device.deactivateCleaning();
|
|
this.pollChanges();
|
|
}
|
|
async pause() {
|
|
const device = await this.findDevice();
|
|
await device.pause();
|
|
this.pollChanges();
|
|
}
|
|
async resume() {
|
|
const device = await this.findDevice();
|
|
await device.activateCleaning();
|
|
this.pollChanges();
|
|
}
|
|
async dock() {
|
|
const device = await this.findDevice();
|
|
await device.activateCharging();
|
|
this.pollChanges();
|
|
}
|
|
}
|
|
|
|
class RoborockPlugin extends ScryptedDeviceBase implements DeviceDiscovery, DeviceProvider, Settings {
|
|
storageSettings = new StorageSettings(this, {
|
|
userId: {
|
|
title: 'User ID or Email',
|
|
description: 'The numberical User ID or email for your Mi Home account.',
|
|
onPut: () => this.discoverDevices(),
|
|
},
|
|
password: {
|
|
title: 'Password',
|
|
type: 'password',
|
|
onPut: () => this.discoverDevices(),
|
|
}
|
|
});
|
|
step2Json: any;
|
|
step3Json: any;
|
|
devices = new Map<string, RoborockVacuum>();
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.discoverDevices();
|
|
}
|
|
|
|
getSettings(): Promise<Setting[]> {
|
|
return this.storageSettings.getSettings();
|
|
}
|
|
putSetting(key: string, value: SettingValue) {
|
|
return this.storageSettings.putSetting(key, value);
|
|
}
|
|
|
|
async discoverDevices(duration?: number) {
|
|
if (!this.storageSettings.values.userId || !this.storageSettings.values.password) {
|
|
this.log.a('Enter your User ID and Password to discover your Roborock Vacuums.');
|
|
return;
|
|
}
|
|
const client = wrapper(axios.create({ jar }));
|
|
|
|
const step1 = await client('https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true', {
|
|
headers: {
|
|
'User-Agent': agent,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Cookie': `userId=${this.storageSettings.values.userId}`,
|
|
},
|
|
})
|
|
|
|
const step1Json = apiToJson(step1.data);
|
|
this.console.log('login step 1', step1Json);
|
|
|
|
|
|
const step2Data = qs.stringify({
|
|
"sid": "xiaomiio",
|
|
"hash": crypto.createHash('md5').update(this.storageSettings.values.password).digest().toString('hex').toUpperCase(),
|
|
"callback": "https://sts.api.io.mi.com/sts",
|
|
"qs": "%3Fsid%3Dxiaomiio%26_json%3Dtrue",
|
|
"user": this.storageSettings.values.userId,
|
|
"_sign": step1Json._sign,
|
|
"_json": "true"
|
|
})
|
|
|
|
const step2 = await client.post('https://account.xiaomi.com/pass/serviceLoginAuth2', step2Data, {
|
|
headers: {
|
|
'User-Agent': agent,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}
|
|
})
|
|
|
|
this.step2Json = apiToJson(step2.data);
|
|
this.console.log('login step 2', this.step2Json);
|
|
|
|
if (!this.step2Json.ssecurity)
|
|
throw new Error('login missing: ssecurity');
|
|
if (this.step2Json.ssecurity.length < 4) {
|
|
const { notificationUrl } = this.step2Json;
|
|
if (notificationUrl) {
|
|
this.log.a('Two factor authentication is required. Open the following url and reload the plugin: ' + notificationUrl)
|
|
this.console.error('Two factor authentication is required. Open the following url and reload the plugin: ' + notificationUrl)
|
|
this.console.error(notificationUrl);
|
|
}
|
|
throw new Error('login failure');
|
|
}
|
|
|
|
const step3 = await client(this.step2Json.location, {
|
|
headers: {
|
|
'User-Agent': agent,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}
|
|
});
|
|
|
|
this.console.log('login step 3', step3.data);
|
|
this.step3Json = step3.data;
|
|
const serviceToken = (await jar.getCookies(this.step2Json.location)).find(cookie => cookie.key === 'serviceToken').value;
|
|
this.console.log('service token', serviceToken);
|
|
|
|
const servers = ["cn", "de", "us", "ru", "tw", "sg", "in", "i2"];
|
|
|
|
for (const server of servers) {
|
|
try {
|
|
const subdomain = server === 'cn' ? '' : server + '.';
|
|
const url = `https://${subdomain}api.io.mi.com/app/home/device_list`;
|
|
|
|
const minutes = Math.floor(Date.now() / 60000);
|
|
const minutesBuffer = Buffer.alloc(4);
|
|
minutesBuffer.writeInt32BE(minutes, 0);
|
|
const nonce = Buffer.concat([crypto.randomBytes(8), minutesBuffer]).toString('base64');
|
|
const signedNonce = createSignedNonce(this.step2Json.ssecurity, nonce);
|
|
|
|
const params = {
|
|
"data": '{"getVirtualModel":true,"getHuamiDevices":1,"get_split_device":false,"support_smart_home":true}'
|
|
}
|
|
|
|
const fields = generateEncParams(url, 'POST', signedNonce, nonce, params, this.step2Json.ssecurity);
|
|
|
|
jar.setCookie(`userId=${this.storageSettings.values.userId}`, url);
|
|
jar.setCookie(`yetAnotherServiceToken=${serviceToken}`, url);
|
|
jar.setCookie(`serviceToken=${serviceToken}`, url);
|
|
jar.setCookie(`locale=en_GB`, url);
|
|
jar.setCookie(`timezone=GMT+02:00`, url);
|
|
jar.setCookie(`is_daylight=1`, url);
|
|
jar.setCookie(`dst_offset=3600000`, url);
|
|
jar.setCookie(`channel=MI_APP_STORE`, url);
|
|
|
|
|
|
|
|
jar.setCookie("sdkVersion=accountsdk-18.8.15", url);
|
|
jar.setCookie(`deviceId=${deviceId}`, url);
|
|
|
|
const client = wrapper(axios.create({ jar }));
|
|
|
|
const devicesResponse = await client.post(url, qs.stringify(fields), {
|
|
headers: {
|
|
"Accept-Encoding": "identity",
|
|
"User-Agent": agent,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"x-xiaomi-protocal-flag-cli": "PROTOCAL-HTTP2",
|
|
"MIOT-ENCRYPT-ALGORITHM": "ENCRYPT-RC4",
|
|
}
|
|
});
|
|
|
|
this.console.log(devicesResponse.data);
|
|
const decoded = decryptRc4(signedNonce, devicesResponse.data);
|
|
const devices = JSON.parse(decoded);
|
|
this.console.log(devices);
|
|
|
|
for (const device of devices.result.list) {
|
|
if (!device.model?.includes('vacuum'))
|
|
continue;
|
|
const id = await deviceManager.onDeviceDiscovered({
|
|
name: device.name,
|
|
type: ScryptedDeviceType.Vacuum,
|
|
nativeId: device.mac,
|
|
interfaces: [
|
|
ScryptedInterface.Refresh,
|
|
ScryptedInterface.Battery,
|
|
ScryptedInterface.StartStop,
|
|
ScryptedInterface.Pause,
|
|
ScryptedInterface.Dock,
|
|
],
|
|
info: {
|
|
model: device.model,
|
|
manufacturer: 'Roborock',
|
|
serialNumber: device.mac,
|
|
},
|
|
});
|
|
const vacuum = this.getDevice(device.mac);
|
|
vacuum.storageSettings.values.token = device.token;
|
|
vacuum.storageSettings.values.ip = device.localip;
|
|
}
|
|
}
|
|
catch (e) {
|
|
this.console.error('error retrieving devices from server', server);
|
|
}
|
|
}
|
|
}
|
|
|
|
getDevice(nativeId: string) {
|
|
let vacuum = this.devices.get(nativeId);
|
|
if (!vacuum) {
|
|
vacuum = new RoborockVacuum(nativeId);
|
|
this.devices.set(vacuum.nativeId, vacuum);
|
|
}
|
|
return vacuum;
|
|
}
|
|
}
|
|
|
|
export default new RoborockPlugin();
|