Skip to content

Instantly share code, notes, and snippets.

@joshuatz
Last active June 16, 2023 16:33
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joshuatz/5266d8cc85ef3e0e67561de3573a1ff5 to your computer and use it in GitHub Desktop.
Save joshuatz/5266d8cc85ef3e0e67561de3573a1ff5 to your computer and use it in GitHub Desktop.
Google Apps Script Wrapper around Kasa API
/**
* @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