Last active
June 16, 2023 16:33
-
-
Save joshuatz/5266d8cc85ef3e0e67561de3573a1ff5 to your computer and use it in GitHub Desktop.
Google Apps Script Wrapper around Kasa API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @file Google Apps Script Wrapper around Kasa API | |
* @license MIT | |
* @see https://gist.github.com/joshuatz/5266d8cc85ef3e0e67561de3573a1ff5 | |
* @author Joshua Tzucker | |
* @see | |
* - https://joshuatz.com/posts/2020/scripting-my-morning-wake-up-alarm-and-lights-with-android-and-kasa/ | |
* - https://cheatsheets.joshuatz.com/random/tp-link-kasa/ | |
*/ | |
// @ts-check | |
// Change below reference path to types="google-apps-script" if using package.json setup. | |
/// <reference path="C:/laragon/bin/nodejs/node-v12/node_modules/@types/google-apps-script/index.d.ts" /> | |
/** | |
* @typedef {object} GenKasaResponse | |
* @property {number} GenKasaResponse.error_code | |
* @property {string} [GenKasaResponse.msg] // On Error | |
* @property {object} [GenKasaResponse.result] // On success | |
*/ | |
/** | |
* @typedef {Omit<Required<GenKasaResponse>, 'result'>} ErrorKasaResponse | |
*/ | |
/** | |
* @typedef {Omit<Required<GenKasaResponse>, 'msg'>} SuccessKasaResponse | |
*/ | |
/** @type {Readonly<['on', 'off', 'smartBulbOn', 'smartBulbOff']>} */ | |
const ValidActions = ['on', 'off', 'smartBulbOn', 'smartBulbOff']; | |
/** | |
* @typedef {object} ValidActionReq | |
* @property {string} ValidActionReq.deviceId | |
* @property {typeof ValidActions[number]} ValidActionReq.action | |
*/ | |
// Globals | |
const authEndpoint = 'https://wap.tplinkcloud.com'; | |
// const actionEndpoint = 'https://use1-wap.tplinkcloud.com'; | |
const actionEndpoint = 'https://wap.tplinkcloud.com'; | |
const scriptProps = PropertiesService.getScriptProperties(); | |
// Fetch from storage | |
const KASA_User = scriptProps.getProperty('KASA_User'); | |
const KASA_Pass = scriptProps.getProperty('KASA_Pass'); | |
const Script_Pass = scriptProps.getProperty('Script_Pass'); | |
let KASA_Token = scriptProps.getProperty('KASA_Token'); | |
function guardedInit(pass) { | |
// Auth check | |
if (!Script_Pass) { | |
throw new Error('Script_Pass is not set!'); | |
} | |
if (pass !== Script_Pass) { | |
throw new Error('INVALID CREDENTIALS'); | |
} | |
// Check if token was never created and saved to storage | |
if (!KASA_Token) { | |
getFreshToken(); | |
} | |
return true; | |
} | |
/** | |
* Get a fresh auth token | |
* @returns {string} token | |
*/ | |
function getFreshToken() { | |
if (!KASA_User || !KASA_Pass) { | |
throw new Error('Did not find Kasa credentials in storage!'); | |
} | |
const uuid = Utilities.getUuid(); | |
const res = UrlFetchApp.fetch(authEndpoint, { | |
method: 'post', | |
contentType: 'application/json', | |
payload: JSON.stringify({ | |
method: 'login', | |
params: { | |
appType: 'Kasa_Android', | |
cloudUserName: KASA_User, | |
cloudPassword: KASA_Pass, | |
terminalUUID: uuid | |
} | |
}) | |
}); | |
const jsonRes = JSON.parse(res.getContentText()); | |
const token = jsonRes['result']['token']; | |
// Set scriptProp and global | |
scriptProps.setProperty('KASA_Token', token); | |
KASA_Token = token; | |
console.log(`Fresh token retrieved at ${new Date().toString()}`); | |
return token; | |
} | |
/** | |
* Make a POST request to API | |
* @param {string} apiMethod | |
* @param {object} [params] | |
* @param {string} [token] | |
* @param {boolean} [retry] | |
* @returns {GenKasaResponse} JSON parsed response | |
*/ | |
function doApiPost(apiMethod, params, token, retry = true) { | |
token = token || KASA_Token; | |
if (params && typeof params['requestData'] === 'object') { | |
params['requestData'] = JSON.stringify(params['requestData']); | |
} | |
const payload = { | |
method: apiMethod, | |
params: { | |
token | |
} | |
}; | |
if (params) { | |
payload.params = { | |
...payload.params, | |
...params | |
}; | |
} | |
const res = UrlFetchApp.fetch(authEndpoint, { | |
method: 'post', | |
contentType: 'application/json', | |
payload: JSON.stringify(payload) | |
}); | |
/** @type {GenKasaResponse} */ | |
const jsonRes = JSON.parse(res.getContentText()); | |
if (!isSuccessKasaResponse(jsonRes) && retry) { | |
// Get fresh token and try again! | |
getFreshToken(); | |
return doApiPost(apiMethod, params, null, false); | |
} | |
return jsonRes; | |
} | |
/** | |
* Check if a token is valid by making an API request | |
* @param {string} token | |
* @returns {boolean} if token is valid | |
*/ | |
function checkToken(token) { | |
const res = doApiPost('getDeviceList', null, token); | |
if (!res.error_code) { | |
return true; | |
} | |
return false; | |
} | |
/** | |
* @param {object | string | number} thing | |
*/ | |
function createHttpResponse(thing) { | |
let isError = false; | |
if ('stack' in thing && 'message' in thing) { | |
isError = true; | |
} | |
if (isError || typeof thing === 'string') { | |
return ContentService.createTextOutput(thing.toString()).setMimeType(ContentService.MimeType.TEXT); | |
} else { | |
return ContentService.createTextOutput(JSON.stringify(thing)).setMimeType(ContentService.MimeType.JSON); | |
} | |
} | |
/** | |
* =========================== | |
* == Listen for Requests == | |
* =========================== | |
*/ | |
/** | |
* | |
* @param {GoogleAppsScript.Events.DoPost} e | |
*/ | |
function doPost(e) { | |
try { | |
const body = JSON.parse(e.postData.contents); | |
guardedInit(body.pass); | |
// Actions | |
if (isValidActionReq(body)) { | |
/** @type {object} */ | |
let requestData = { | |
system: { | |
set_relay_state: { | |
state: body.action === 'on' ? 1 : 0 | |
} | |
} | |
} | |
if (body.action === 'smartBulbOn' || body.action === 'smartBulbOff') { | |
requestData = { | |
"smartlife.iot.smartbulb.lightingservice": { | |
transition_light_state: { | |
on_off: body.action === 'smartBulbOn' ? 1 : 0 | |
} | |
} | |
} | |
} | |
const params = { | |
deviceId: body.deviceId, | |
requestData | |
}; | |
const res = doApiPost('passthrough', params); | |
return createHttpResponse(res); | |
} | |
return createHttpResponse({ | |
error_code: -1, | |
msg: 'Could not understand request.' | |
}); | |
} catch (err) { | |
return createHttpResponse(err); | |
} | |
} | |
/** | |
* =========================== | |
* == Type Guards == | |
* =========================== | |
*/ | |
/** | |
* @param {any} value | |
* @returns {value is ValidActionReq} | |
*/ | |
function isValidActionReq(value) { | |
if (typeof value.deviceId !== 'string') { | |
return false; | |
} | |
if (!ValidActions.includes(value.action)) { | |
return false; | |
} | |
return true; | |
} | |
/** | |
* @param {GenKasaResponse} response | |
* @returns {response is SuccessKasaResponse} | |
*/ | |
function isSuccessKasaResponse(response) { | |
return response.error_code > 0; | |
} | |
/** | |
* @example - dummy doPost Request - Simulate incoming POST hook | |
function actionTest() { | |
const response = doPost({ | |
contentLength: 0, | |
contextPath: '', | |
parameter: {}, | |
parameters: {}, | |
postData: { | |
contents: JSON.stringify({ | |
pass: 'REDACTED', | |
deviceId: 'REDACTED', | |
action: 'off' | |
}), | |
length: 0, | |
name: 'postData', | |
type: 'application/json' | |
}, | |
queryString: '' | |
}); | |
} | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment