Skip to content

Commit

Permalink
add: tp-link-tapo-connect *interim measures*
Browse files Browse the repository at this point in the history
  • Loading branch information
sanlike0911 committed Aug 14, 2022
1 parent 8eef4f7 commit 0b6f751
Show file tree
Hide file tree
Showing 10 changed files with 1,077 additions and 474 deletions.
21 changes: 13 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "node-red-contrib-tplink-tapo-connect-api",
"version": "0.3.3",
"version": "0.3.4",
"description": "Unofficial node-RED node for connecting to TP-Link Tapo devices. Currently limited to the P100 & P105 & P110 smart plugs and L510E smart bulbs.",
"author": "sanlike",
"license": "Apache",
Expand Down Expand Up @@ -30,7 +30,7 @@
"start:debug": "node --inspect-brk=0.0.0.0:9229 ./node_modules/node-red/red.js --userDir ./data"
},
"node-red": {
"version": ">=1.0.0",
"version": ">=2.0.0",
"nodes": {
"tplink_tapo_connect_api": "nodes/tplink_tapo_connect_api.js",
"tplink_command": "nodes/tplink_command.js",
Expand All @@ -46,18 +46,23 @@
"node": ">=12.0.0"
},
"dependencies": {
"tp-link-tapo-connect": "^1.0.8"
"ansi-styles": "^6.1.0",
"arpping": "^4.0.0",
"local-devices": "^3.2.0",
"axios": "^0.27.2",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/node-red": "^1.2.1",
"cpx": "^1.5.0",
"node-red": "^2.2.2",
"node-red-node-test-helper": "^0.2.7",
"node-red": "^3.0.2",
"node-red-node-test-helper": "^0.3.0",
"npm-run-all": "^4.1.5",
"rimraf": "^3.0.2",
"ts-node": "^10.8.1",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^4.7.3"
"typescript": "^4.7.4",
"mocha": "^10.0.0"
},
"repository": {
"type": "git",
Expand All @@ -67,4 +72,4 @@
"url": "https://github.com/sanlike0911/node-red-contrib-tplink-tapo-connect-api/issues"
},
"homepage": "https://github.com/sanlike0911/node-red-contrib-tplink-tapo-connect-api#readme"
}
}
294 changes: 294 additions & 0 deletions src/nodes/tplink_tapo_connect_wrapper/tp-link-tapo-connect/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import axios, { AxiosInstance } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { encrypt, decrypt, generateKeyPair, readDeviceKey, base64Encode, base64Decode, shaDigest } from "./tplinkCipher";
import { TapoDevice, TapoDeviceKey, TapoDeviceInfo, TapoVideoImage, TapoVideoPageItem, TapoVideoList, TapoVideo } from "./types";
import { resolveMacToIp } from './network-tools';
import { getColour } from './colour-helper';
import tplinkCaCert from "./tplink-ca-cert";
import * as https from "https";

// another variant is https://n-euw1-wap-gw.tplinkcloud.com
const baseUrl = 'https://eu-wap.tplinkcloud.com/'

/**
* also url may be one of that:
* "http://use1-relay-dcipc.i.tplinknbu.com"
* "http://aps1-relay-dcipc-beta.i.tplinknbu.com"
* "http://euw1-relay-dcipc.i.tplinknbu.com"
* "http://aps1-relay-dcipc-beta.i.tplinknbu.com"
* "http://aps1-relay-dcipc.i.tplinknbu.com"
* "http://aps1-relay-dcipc-beta.i.tplinknbu.com"
*/
const baseTapoCareUrl = 'https://euw1-app-tapo-care.i.tplinknbu.com'

export {
TapoDevice,
TapoDeviceKey,
TapoDeviceInfo,
TapoVideoImage,
TapoVideoPageItem,
TapoVideoList,
TapoVideo,
};

export const cloudLogin = async (email: string = process.env.TAPO_USERNAME || "", password: string = process.env.TAPO_PASSWORD || ""): Promise<string> => {
const loginRequest = {
"method": "login",
"params": {
"appType": "Tapo_Android",
"cloudPassword": password,
"cloudUserName": email,
"terminalUUID": uuidv4()
}
}
const response = await axios({
method: 'post',
url: baseUrl,
data: loginRequest
})

checkError(response.data);

return response.data.result.token;
}

export const listDevices = async (cloudToken: string): Promise<Array<TapoDevice>> => {
const getDeviceRequest = {
"method": "getDeviceList",
}
const response = await axios({
method: 'post',
url: `${baseUrl}?token=${cloudToken}`,
data: getDeviceRequest
})

checkError(response.data);

return Promise.all(response.data.result.deviceList.map(async (deviceInfo: TapoDevice) => augmentTapoDevice(deviceInfo)));
}

export const listDevicesByType = async (cloudToken: string, deviceType: string): Promise<Array<TapoDevice>> => {
const devices = await listDevices(cloudToken);
return devices.filter(d => d.deviceType === deviceType);
}

export const handshake = async (deviceIp: string):Promise<TapoDeviceKey> => {
const keyPair = await generateKeyPair();

const handshakeRequest =
{
method: "handshake",
params: {
"key": keyPair.publicKey
}
}
const response = await axios({
method: 'post',
url: `http://${deviceIp}/app`,
data: handshakeRequest
})

checkError(response.data);

const setCookieHeader = response.headers['set-cookie'][0];
const sessionCookie = setCookieHeader.substring(0,setCookieHeader.indexOf(';'))

const deviceKey = readDeviceKey(response.data.result.key, keyPair.privateKey)

return {
key: deviceKey.subarray(0,16),
iv: deviceKey.subarray(16,32),
deviceIp,
sessionCookie
}
}

export const loginDevice = async (email: string = process.env.TAPO_USERNAME || "", password: string = process.env.TAPO_PASSWORD || "", device: TapoDevice) =>
loginDeviceByIp(email, password, await resolveMacToIp(device.deviceMac));

export const loginDeviceByIp = async (email: string = process.env.TAPO_USERNAME || "", password: string = process.env.TAPO_PASSWORD || "", deviceIp: string):Promise<TapoDeviceKey> => {
const deviceKey = await handshake(deviceIp);
const loginDeviceRequest =
{
"method": "login_device",
"params": {
"username": base64Encode(shaDigest(email)),
"password": base64Encode(password)
}
}

const loginDeviceResponse = await securePassthrough(loginDeviceRequest, deviceKey);
deviceKey.token = loginDeviceResponse.token;
return deviceKey;
}

export const turnOn = async (deviceKey: TapoDeviceKey, deviceOn: boolean = true) => {
const turnDeviceOnRequest = {
"method": "set_device_info",
"params":{
"device_on": deviceOn,
},
"requestTimeMils": (new Date()).getTime(),
"terminalUUID": "00-00-00-00-00-00"
}
await securePassthrough(turnDeviceOnRequest, deviceKey)
}

export const turnOff = async (deviceKey: TapoDeviceKey) => {
return turnOn(deviceKey, false);
}

export const setBrightness = async (deviceKey: TapoDeviceKey, brightnessLevel: number = 100) => {
const setBrightnessRequest = {
"method": "set_device_info",
"params":{
"brightness": brightnessLevel,
},
"requestTimeMils": (new Date()).getTime(),
"terminalUUID": "00-00-00-00-00-00"
}
await securePassthrough(setBrightnessRequest, deviceKey)
}

export const setColour = async (deviceKey: TapoDeviceKey, colour: string = 'white') => {
const params = await getColour(colour);

const setColourRequest = {
"method": "set_device_info",
params,
"requestTimeMils": (new Date()).getTime(),
"terminalUUID": "00-00-00-00-00-00"
}
await securePassthrough(setColourRequest, deviceKey)
}

export const getDeviceInfo = async (deviceKey: TapoDeviceKey): Promise<TapoDeviceInfo> => {
const statusRequest = {
"method": "get_device_info",
"requestTimeMils": (new Date()).getTime(),
"terminalUUID": "00-00-00-00-00-00"
}
return augmentTapoDeviceInfo(await securePassthrough(statusRequest, deviceKey))
}

export const getEnergyUsage = async (deviceKey: TapoDeviceKey): Promise<TapoDeviceInfo> => {
const statusRequest = {
"method": "get_energy_usage"
}
return securePassthrough(statusRequest, deviceKey)
}

export const securePassthrough = async (deviceRequest: any, deviceKey: TapoDeviceKey):Promise<any> => {
const encryptedRequest = encrypt(deviceRequest, deviceKey)
const securePassthroughRequest = {
"method": "securePassthrough",
"params": {
"request": encryptedRequest,
}
}

const response = await axios({
method: 'post',
url: `http://${deviceKey.deviceIp}/app?token=${deviceKey.token}`,
data: securePassthroughRequest,
headers: {
"Cookie": deviceKey.sessionCookie
}
})

checkError(response.data);

const decryptedResponse = decrypt(response.data.result.response, deviceKey);
checkError(decryptedResponse);

return decryptedResponse.result;
}

const augmentTapoDevice = async (deviceInfo: TapoDevice): Promise<TapoDevice> => {
if (isTapoDevice(deviceInfo.deviceType)) {
return {
...deviceInfo,
alias: base64Decode(deviceInfo.alias)
}
} else {
return deviceInfo
}
}

export const tapoCareCloudVideos = async (cloudToken: string, deviceId: string, order: string = 'desc', page: number = 0, pageSize: number = 20, startTime: string | null = null, endTime: string | null = null): Promise<TapoVideoList> => {
const response = await tplinkCaAxios()({
method: 'get',
url: `${baseTapoCareUrl}/v1/videos`,
params: {
deviceId,
page,
pageSize,
order,
startTime,
endTime,
},
headers: tapoCareAuthHeaders(cloudToken),
})

checkTapoCareError(response)

return <TapoVideoList> response.data
}

const tapoCareAuthHeaders = (cloudToken: string): { authorization: string } => {
return {
'authorization': `ut|${cloudToken}`,
};
}

const tplinkCaAxios = (): AxiosInstance => {
const httpsAgent = new https.Agent({
rejectUnauthorized: true,
ca: tplinkCaCert,
})

return axios.create({ httpsAgent })
}

const augmentTapoDeviceInfo = (deviceInfo: TapoDeviceInfo): TapoDeviceInfo => {
return {
...deviceInfo,
ssid: base64Decode(deviceInfo.ssid),
nickname: base64Decode(deviceInfo.nickname),
}
}

export const isTapoDevice = (deviceType: string) => {
switch (deviceType) {
case 'SMART.TAPOPLUG':
case 'SMART.TAPOBULB':
case 'SMART.IPCAMERA':
return true
default: return false
}
}

export const checkError = (responseData: any) => {
const errorCode = responseData["error_code"];
if (errorCode) {
switch (errorCode) {
case 0: return;
case -1010: throw new Error("Invalid public key length");
case -1501: throw new Error("Invalid request or credentials");
case -1002: throw new Error("Incorrect request");
case -1003: throw new Error("JSON format error");
case -20601: throw new Error("Incorrect email or password");
case -20675: throw new Error("Cloud token expired or invalid");
case 9999: throw new Error("Device token expired or invalid");
default: throw new Error(`Unexpected Error Code: ${errorCode} (${responseData["msg"]})`);
}

}
}

export const checkTapoCareError = (responseData: any) => {
const errorCode = responseData?.code;
if (errorCode) {
throw new Error(`Unexpected Error Code: ${errorCode} (${responseData["message"]})`);
}
}
Loading

0 comments on commit 0b6f751

Please sign in to comment.