node-red-contrib-google-smarthome
Version:
Lets you control Node-Red via Google Assistant or the Google Home App
773 lines (654 loc) • 27.2 kB
JavaScript
/**
* node-red-contrib-google-smarthome
* Copyright (C) 2024 Michael Jacobsen and others.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
'use strict';
const path = require('path');
const UidGenerator = require('uid-generator');
const { google } = require('googleapis');
const fs = require('fs');
const ipRangeCheck = require('ip-range-check');
const userId = '0';
const LOOPBACK_NETWORKS = [
"127.0.0.0/8",
"::1/128",
"::ffff:127.0.0.0/104"
];
const PRIVATE_NETWORKS = [
"fd00::/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16"
];
const isLocalIP = function (ip) {
return ipRangeCheck(ip, LOOPBACK_NETWORKS) || ipRangeCheck(ip, PRIVATE_NETWORKS)
};
/******************************************************************************************************************
* HttpActions
*
*/
class HttpActions {
/**
* Constructor
*
* @param {GoogleSmartHome} smarthome - Instance of the GoogleSmartHome class
*/
constructor(smarthome) {
this._smarthome = smarthome;
this._reqGen = new UidGenerator(128, UidGenerator.BASE62);
}
//
//
//
httpActionsRegister(httpRoot, appHttp) {
let me = this;
if (typeof appHttp === 'undefined') {
appHttp = this._smarthome.app;
}
/**
*
* action: {
* initialTrigger: {
* intent: [
* "action.devices.SYNC",
* "action.devices.QUERY",
* "action.devices.EXECUTE"
* "action.devices.IDENTIFY",
* "action.devices.REACHABLE_DEVICES",
* ]
* },
* httpExecution: "https://example.org/device/agent",
* accountLinking: {
* authenticationUrl: "https://example.org/device/auth"
* }
* }
*/
appHttp.post(me._smarthome.Path_join(httpRoot, 'smarthome'), function (request, response) {
me._post(request, response, 'smarthome');
});
/**
* Endpoint to check HTTP(S) reachability.
*/
appHttp.get(me._smarthome.Path_join(httpRoot, 'check'), function (request, response) {
me._smarthome.debug('HttpActions:httpActionsRegister(/check)');
if (me._smarthome._debug) {
fs.readFile(path.join(__dirname, 'frontend/check.html'), 'utf8', function (err, data) {
if (err) {
response.end();
throw (err);
}
response
.set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' code.jquery.com")
.send(data);
});
} else {
response.send('SUCCESS');
}
});
/**
* Enables preflight (OPTIONS) requests made cross-domain.
*/
appHttp.options(me._smarthome.Path_join(httpRoot, 'smarthome'), function (request, response) {
me._options(request, response);
});
}
httpLocalActionsRegister(httpLocalRoot, appHttp) {
let me = this;
/**
*
* action: {
* initialTrigger: {
* intent: [
* "action.devices.IDENTIFY",
* "action.devices.REACHABLE_DEVICES",
* "action.devices.QUERY",
* "action.devices.EXECUTE"
* ]
* },
* }
*/
appHttp.post(me._smarthome.Path_join(httpLocalRoot, 'smarthome'), function (request, response) {
me._smarthome.debug('local smarthome: request.headers = ' + JSON.stringify(request.headers));
if (!isLocalIP(request.ip)) {
response.status(200).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({});
}
me._post(request, response, 'smarthome');
});
/**
* Enables preflight (OPTIONS) requests made cross-domain.
*/
appHttp.options(me._smarthome.Path_join(httpLocalRoot, 'smarthome'), function (request, response) {
me._options(request, response);
});
/**
* Endpoint to check HTTP reachability.
*/
appHttp.get(me._smarthome.Path_join(httpLocalRoot, 'check'), function (request, response) {
me._smarthome.debug('HttpActions:httpLocalActionsRegister(/check)');
response.send('SUCCESS');
});
}
/**
* @private
*/
_options(request, response) {
response.status(200).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).send('null');
}
/**
* @private
*/
_post(request, response, url) {
let me = this;
let reqdata = request.body;
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): request.headers = ' + JSON.stringify(request.headers));
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): reqdata = ' + JSON.stringify(reqdata));
let res = request.headers.authorization;
if (!res) {
me._smarthome.error('HttpActions:httpActionsRegister(/' + url + '): missing authorization header; res = ' + JSON.stringify(res));
response.status(401).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'missing inputs' });
return;
}
res = res.split(" ");
if (res.length != 2 || res[0] !== 'Bearer') {
me._smarthome.error('HttpActions:httpActionsRegister(/' + url + '): invalid authorization data; res = ' + JSON.stringify(res));
response.status(401).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'missing inputs' });
return;
}
let accessToken = res[1];
const is_local_execution = me._smarthome.auth.isValidLocalAccessToken(accessToken);
const user = me._smarthome.auth.getuserForAccessToken(accessToken);
if (user === null) {
me._smarthome.error('HttpActions:httpActionsRegister(/' + url + '): no user found for access token "' + accessToken + '"');
response.status(401).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'missing inputs' });
return;
}
if (is_local_execution) {
if (!isLocalIP(request.ip)) {
response.status(200).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({});
}
}
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): user: ' + user);
if (!reqdata.inputs) {
me._smarthome.error('HttpActions:httpActionsRegister(/' + url + '): missing reqdata.inputs');
response.status(401).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'missing inputs' });
return;
}
for (let i = 0; i < reqdata.inputs.length; i++) {
let input = reqdata.inputs[i];
let intent = input.intent;
if (!intent) {
me._smarthome.error('HttpActions:httpActionsRegister(/' + url + '): missing intent');
response.status(401).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'missing inputs' });
continue;
}
switch (intent) {
case 'action.devices.SYNC':
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): SYNC');
/**
* request:
* {
* "requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
* "inputs": [{
* "intent": "action.devices.SYNC",
* }]
* }
*/
me._sync(reqdata.requestId, response);
break;
case 'action.devices.QUERY':
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): QUERY');
/**
* request:
* {
* "requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
* "inputs": [{
* "intent": "action.devices.QUERY",
* "payload": {
* "devices": [{
* "id": "123",
* "customData": {
* "fooValue": 12,
* "barValue": true,
* "bazValue": "alpaca sauce"
* }
* }, {
* "id": "234",
* "customData": {
* "fooValue": 74,
* "barValue": false,
* "bazValue": "sheep dip"
* }
* }]
* }
* }]
* }
*/
me._query(reqdata.requestId, reqdata.inputs[i].payload.devices, response);
break;
case 'action.devices.EXECUTE':
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): EXECUTE');
/**
* request:
* {
* "requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
* "inputs": [{
* "intent": "action.devices.EXECUTE",
* "payload": {
* "commands": [{
* "devices": [{
* "id": "123",
* "customData": {
* "fooValue": 12,
* "barValue": true,
* "bazValue": "alpaca sauce"
* }
* }, {
* "id": "234",
* "customData": {
* "fooValue": 74,
* "barValue": false,
* "bazValue": "sheep dip"
* }
* }],
* "execution": [{
* "command": "action.devices.commands.OnOff",
* "params": {
* "on": true
* }
* }]
* }]
* }
* }]
* }
*/
me._exec(reqdata.requestId, reqdata.inputs[i].payload.commands, response, is_local_execution);
break;
case 'action.devices.DISCONNECT':
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): DISCONNECT');
me._smarthome.auth.removeAllTokensForUser(user);
response.status(200).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({});
break;
case 'action.devices.IDENTIFY':
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): IDENTIFY');
me._smarthome.checkAppJsVersion(reqdata.appJsVersion);
response.status(200).json({
requestId: reqdata.requestId,
payload: {
device: {
id: me._smarthome._nodeId,
isLocalOnly: true,
isProxy: true,
deviceInfo: {
manufacturer: 'Node-RED',
model: 'Node-Red',
swVersion: '1.0',
hwVersion: '1.0'
}
}
}
});
break;
case 'action.devices.REACHABLE_DEVICES':
me._smarthome.debug('HttpActions:httpActionsRegister(/' + url + '): REACHABLE_DEVICES');
me._reachable_devices(reqdata.requestId, reqdata.inputs[i].payload.commands, response);
break;
default:
me._smarthome.error('HttpActions:httpActionsRegister(/' + url + '): invalid intent');
response.status(401).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'missing intent' });
break;
}
}
}
/**
* @private
*/
_sync(requestId, response) {
this._smarthome.debug('HttpActions:_sync()');
let devices = this._smarthome.devices.getProperties();
if (!devices) {
response.status(500).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'failed' });
return;
}
let me = this;
let deviceList = [];
Object.keys(devices).forEach(function (key) {
if (Object.prototype.hasOwnProperty.call(devices, key) && devices[key]) {
let device = devices[key];
device.id = key;
deviceList.push(device);
}
});
if (deviceList.length === 0) {
deviceList.push({
type: "action.devices.types.SCENE",
traits: [
"action.devices.traits.Scene"
],
name: {
defaultNames: [
"Node-RED Scene"
],
name: "Dummy"
},
willReportState: true,
attributes: {
sceneReversible: false
},
deviceInfo: {
manufacturer: "Node-RED",
model: "nr-device-scene-v1",
swVersion: "1.0",
hwVersion: "1.0"
},
id: "dummy.node"
});
}
let deviceProps = {
requestId: requestId,
payload: {
agentUserId: userId,
devices: deviceList,
},
};
me._smarthome.debug('HttpActions:_sync(): response = ' + JSON.stringify(deviceProps));
response.status(200).json(deviceProps);
return deviceProps;
}
/**
* @private
*/
_query(requestId, devices, response) {
this._smarthome.debug('HttpActions:_query()');
let deviceIds = this._smarthome.devices.getDeviceIds(devices);
let states = this._smarthome.devices.getStates(deviceIds);
if (!states) {
response.status(500).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'failed' });
return;
}
let deviceStates = {
requestId: requestId,
payload: {
devices: states,
},
};
this._smarthome.debug('HttpActions:_query(): deviceStates = ' + JSON.stringify(deviceStates));
response.status(200).json(deviceStates);
return deviceStates;
}
/**
* @private
*/
_exec(requestId, commands, response, is_local) {
this._smarthome.debug('HttpActions:_exec()');
// Prevent loop bound injection (https://codeql.github.com/codeql-query-help/javascript/js-loop-bound-injection/)
if (!(commands instanceof Array)) {
throw new Error('Parameter "commands" must be of type Array');
}
let respCommands = [];
for (let i = 0; i < commands.length; i++) {
let curCommand = commands[i];
for (let j = 0; j < curCommand.execution.length; j++) {
let curExec = curCommand.execution[j];
let devices = curCommand.devices;
for (let k = 0; k < devices.length; k++) {
let executionResponse = this._execDevice(curExec, devices[k], is_local);
this._smarthome.debug('HttpActions:_exec(): executionResponse = ' + JSON.stringify(executionResponse));
const execState = {};
if (executionResponse.executionStates) {
executionResponse.executionStates.map((key) => {
execState[key] = executionResponse.states[key];
});
} else {
this._smarthome.debug('HttpActions:_exec(): no execution states were found for this device');
}
respCommands.push({
ids: [devices[k].id],
status: executionResponse.status,
errorCode: executionResponse.errorCode ? executionResponse.errorCode : undefined,
states: execState,
challengeNeeded: executionResponse.challengeNeeded || undefined
});
}
}
}
let resBody = {
requestId: requestId,
payload: {
commands: respCommands,
},
};
this._smarthome.debug('HttpActions:_exec(): resBody = ' + JSON.stringify(resBody));
response.status(200).json(resBody);
return resBody;
}
/**
* @private
*/
_execDevice(command, device, is_local) {
let me = this;
me._smarthome.debug('HttpActions:_execDevice(): command = ' + JSON.stringify(command));
const deviceId = device.id;
const cur_device = me._smarthome.devices.getDevice(deviceId);
// check whether the device exists or whether it exists and it is disconnected.
if (!cur_device || !cur_device.states.online) {
me._smarthome._mgmtNode.warn('HttpActions:_execDevice(): the device you want to control is offline');
return { status: 'ERROR', errorCode: 'deviceOffline' };
}
let curDevice = {
id: deviceId,
states: {},
};
curDevice.command = command.command;
const result = cur_device.execCommand(command);
if (Object.prototype.hasOwnProperty.call(result, 'status')) {
return result;
}
cur_device.updated(command, result, is_local);
let reportState = true;
let allParams = false;
if (Object.prototype.hasOwnProperty.call(result, 'reportState')) {
reportState = result.reportState;
}
if (Object.prototype.hasOwnProperty.call(result, "params") && Object.keys(result.params).length > 0) {
command.params = result.params;
allParams = true;
}
if (Object.prototype.hasOwnProperty.call(command, 'params')) {
Object.keys(command.params).forEach(function (key) {
if (allParams || Object.prototype.hasOwnProperty.call(cur_device.states, key)) {
curDevice.states[key] = command.params[key];
}
});
}
if (reportState) {
// update states in HomeGraph
process.nextTick(() => {
let s = me._smarthome.devices.getStates([deviceId]);
me.reportState(deviceId, s[deviceId]);
});
}
let states = cur_device.states;
if (Object.prototype.hasOwnProperty.call(result, "states") && Object.keys(result.states).length > 0) {
states = result.states;
}
return {
status: 'SUCCESS',
states: states,
executionStates: result.executionStates,
};
}
/**
* @private
*/
_reachable_devices(requestId, devices, response) {
this._smarthome.debug('HttpActions:_reachable_devices()');
let reachableDevices = this._smarthome.devices.getReachableDeviceIds();
if (!reachableDevices) {
response.status(500).set({
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}).json({ error: 'failed' });
return;
}
let resBody = {
requestId: requestId,
payload: {
devices: reachableDevices,
},
};
this._smarthome.debug('HttpActions:_reachable_devices(): reachableDevices = ' + JSON.stringify(resBody));
response.status(200).json(resBody);
return reachableDevices;
}
//
//
//
reportState(deviceId, states, notifications) {
let me = this;
if (deviceId == undefined && Object.keys(states).length === 0 && notifications == undefined) {
me._smarthome.error('HttpActions:reportState(): skipped reporting states, no devices registered');
return;
}
if (!this._smarthome.IsHttpServerRunning()) {
me._smarthome.error('HttpActions:reportState(): skipped reporting states, http server is not running');
return;
}
if (!this._smarthome.auth.isAccountLinked()) {
me._smarthome.error('HttpActions:reportState(): skipped reporting states, account is not linked. (Re-)link your account in the app.');
return;
}
if (!this._smarthome.auth._jwtkey || !this._smarthome.auth._jwtkey.private_key) {
me._smarthome.error('HttpActions:reportState(): skipped reporting states, JWT key is not loaded');
return;
}
const postData = {
requestId: this._reqGen.generateSync(),
agentUserId: userId,
payload: {
devices: {
}
}
};
if (states) {
postData.payload.devices.states = {};
}
if (notifications) {
postData.eventId = this._reqGen.generateSync();
postData.payload.devices.notifications = {};
postData.payload.devices.notifications[deviceId] = notifications; // single device
}
if (states) {
if (!deviceId) {
postData.payload.devices.states = states; // all devices
} else {
delete states["command"];
postData.payload.devices.states[deviceId] = states; // single device
}
}
const auth = new google.auth.GoogleAuth({
keyFile: this._smarthome._jwtKeyFile.startsWith(path.sep) ? this._smarthome._jwtKeyFile : path.join(this._smarthome._userDir, this._smarthome._jwtKeyFile),
scopes: ['https://www.googleapis.com/auth/homegraph']
});
const homegraph = google.homegraph({
version: 'v1',
auth: auth,
});
homegraph.devices.reportStateAndNotification({ 'requestBody': postData })
.then(() => {
me._smarthome.debug('HttpActions:reportState(): successfully reported state to Google: ' + JSON.stringify(postData));
})
.catch(error => {
let myError = {
"msg": "Error in HttpActions:reportState()",
"org_msg": (error.response !== undefined ? error.response.data.error : error),
"data": {
"postData": postData,
},
};
me._smarthome.error(myError);
});
}
/**
* Sends a request to Google to sync devices.
*/
requestSync() {
let me = this;
this._smarthome.debug('HttpActions:requestSync()');
if (!this._smarthome.IsHttpServerRunning()) {
me._smarthome.error('HttpActions:requestSync(): skipped requested sync, http server is not running');
return;
}
if (!this._smarthome.auth.isAccountLinked()) {
me._smarthome.error('HttpActions:requestSync(): skipped requested sync, account is not linked. (Re-)link your account in the app.');
return;
}
if (!this._smarthome.auth._jwtkey || !this._smarthome.auth._jwtkey.private_key) {
me._smarthome.error('HttpActions:requestSync(): skipped requested sync, JWT is not loaded');
return;
}
const postData = {
agentUserId: userId,
async: true,
};
this._smarthome.debug('HttpActions:requestSync(): postData = ' + JSON.stringify(postData));
const auth = new google.auth.GoogleAuth({
keyFile: this._smarthome._jwtKeyFile.startsWith(path.sep) ? this._smarthome._jwtKeyFile : path.join(this._smarthome._userDir, this._smarthome._jwtKeyFile),
scopes: ['https://www.googleapis.com/auth/homegraph']
});
const homegraph = google.homegraph({
version: 'v1',
auth: auth,
});
homegraph.devices.requestSync({ 'requestBody': postData })
.then(() => {
me._smarthome.debug('HttpActions:requestSync(): success');
})
.catch(error => {
let myError = {
"msg": "Error in HttpActions:requestSync()",
"org_msg": (error.response !== undefined ? error.response.data.error : error),
"data": {
"postData": postData,
},
};
me._smarthome.error(myError);
});
}
}
module.exports = HttpActions;