UNPKG

node-red-contrib-dulonode

Version:

Alexa integration for Node-RED. Voice-control lights, blinds, locks, thermostats, TVs, and more using Node-RED.

471 lines (412 loc) 17.6 kB
const { settings } = require('../settings.js') const axios = require('axios'); const mqtt = require('mqtt'); const { CognitoIdentityProviderClient, InitiateAuthCommand } = require('@aws-sdk/client-cognito-identity-provider'); const identityProvider = new CognitoIdentityProviderClient({ region: settings.region }); module.exports = function(RED) { /** * DuloNodeHub constructor function. * @param {object} config - The configuration for the node. */ function DuloNodeHub(config) { RED.nodes.createNode(this, config); const node = this; let mqttClient; if (node.credentials && node.credentials.hasOwnProperty("email")) { node.email = node.credentials.email; } if (node.credentials && node.credentials.hasOwnProperty("password")) { node.password = node.credentials.password; } /** * Normalize an error object into a safe string for status messages or logging. * Optionally prints detailed information to the console if DULONODE_DEBUG is set. * * @param {Error|Object|string} err - The error object, string, or other value to process. * @param {string} [label="Error"] - A short label prefix for the message. * @returns {string} - A clean error message suitable for user-facing status updates. */ function errorMessage(err, label = 'Error') { let message; if (err instanceof Error) { message = `${label}: ${err.message || err.toString()}`; } else { try { message = `${label}: ${JSON.stringify(err, null, 2)}`; } catch { message = `${label}: [unserializable error object]`; } } if (process.env.DULONODE_DEBUG) { RED.log.error(message); if (err instanceof Error && err.stack) { RED.log.error(`${label} stack:\n${err.stack}`); } else { RED.log.error(`${label} raw object:`); RED.log.error(err); } } return message; } /** * Set the node status based on the type and optionally send a message. * @param {string} statusType - The type of status ('success', 'error', 'loading'). * @param {string} text - The generic text to display in the node status. * @param {string} detailedMessage - A detailed message for the output payload. */ function setStatus(statusType, text, detailedMessage) { let fill, shape; // Determine fill color and shape based on status type switch (statusType) { case 'success': fill = 'green'; shape = 'dot'; break; case 'error': fill = 'red'; shape = 'ring'; break; case 'loading': fill = 'yellow'; shape = 'dot'; break; default: fill = 'grey'; shape = 'ring'; break; } if (text !== '') { // Update the node's status node.status({ fill, shape, text }); } if (detailedMessage !== '') { // Send a formatted message payload node.send({ payload: { status: statusType, message: detailedMessage } }); } } /** * Decode the provided token. * @param {string} token - The JWT token. * @returns {Object} - JWT decoded object. */ function decodeToken(token) { return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); } /** * Checks if the provided token is valid by decoding it and checking expiration. * @param {string} token - The JWT token to validate. * @returns {boolean} - True if the token is valid, false otherwise. */ function isTokenValid(token) { try { const decoded = decodeToken(token); const expiration = decoded.exp * 1000; return expiration > Date.now(); } catch (e) { return false; } } /** * Retrieves a valid token from cache or triggers a fresh authentication. * Always returns a Promise. * @returns {Promise<string>} - A promise resolving to a valid token. */ function getToken() { return new Promise((resolve, reject) => { let auth = node.context().get('apiAuth'); if (auth?.token && isTokenValid(auth.token)) { resolve(auth.token); } else { authenticate() .then(resolve) .catch(reject); } }); } /** * Initiates authentication with Cognito using provided email and password. * @returns {Promise<string>} - A promise resolving to the fresh authentication token. */ function authenticate() { return new Promise((resolve, reject) => { const params = { AuthFlow: 'USER_PASSWORD_AUTH', ClientId: settings.clientId, AuthParameters: { USERNAME: node.email, PASSWORD: node.password } }; identityProvider.send(new InitiateAuthCommand(params)) .then((data) => { const token = data.AuthenticationResult.IdToken; const decodedToken = decodeToken(token); node.context().set('apiAuth', { token, user: decodedToken.sub }); resolve(token); }) .catch((err) => { setStatus('error', 'Authentication error', errorMessage(err, 'Authentication error')); reject(err); }); }); } /** * Check API and Client compatability * @param {string} minVersion - The minimum compatible version. */ function compatibilityCheck(minVersion) { const currentVersion = settings.version.split('.').map(Number); const minimumVersion = minVersion.split('.').map(Number); for (let i = 0; i < Math.max(currentVersion.length, minimumVersion.length); i++) { const currentVersionNum = currentVersion[i] || 0; const minimumVersionNum = minimumVersion[i] || 0; if (currentVersionNum < minimumVersionNum) { setStatus('error', 'Upgrade required', `Upgrade required to version ${minVersion}`); return false; } } return true; } /** * Function to initiate MQTT connection. */ function initMQTT() { const apiAuth = node.context().get('apiAuth'); const mqttAuth = node.context().get('mqttAuth'); if (!mqttAuth) { setStatus('error', 'Setup error', 'MQTT data not found in context storage'); return; } const clientId = `${apiAuth.user}`; const topic = `hub/${apiAuth.user}/device/update`; const options = { clientId: clientId, keepalive: 120, cert: mqttAuth.certificate, key: mqttAuth.private, rejectUnauthorized: true }; const url = `mqtts://${mqttAuth.endpoint}:8883`; // Disconnect the existing client if (mqttClient) { try { mqttClient.end(); } catch (error) { setStatus('error', 'MQTT error', `Error stopping MQTT client: ${error.message}`); } } // Initialize a new MQTT client mqttClient = mqtt.connect(url, options); mqttClient.on('connect', () => { mqttClient.subscribe(topic, { qos: 1 }, (err) => { if (err) { setStatus('error', 'error', errorMessage(err, 'Error subscribing to topic')); } else { setStatus('success', 'connected', ''); } }); }); // Event listener for incoming messages mqttClient.on('message', (topic, message) => { const data = message.toString('utf8'); if (data) { try { const payload = JSON.parse(data); node.send({ payload }); } catch (jsonError) { setStatus('error', '', `Error parsing JSON payload: ${jsonError.message}`); } } else { setStatus('error', '', 'Empty payload received from MQTT'); } }); // Event listener for errors mqttClient.on('error', (error) => { setStatus('error', 'MQTT error', `MQTT Client Error: ${error.message}`); }); } function deploy() { const nodeWires = node.wires || []; const connectedNodes = {}; RED.nodes.eachNode((currentNode) => { nodeWires.forEach(output => { output.forEach(connectedNodeID => { if (currentNode.id === connectedNodeID && currentNode.type === "DuloNodeDevice") { connectedNodes[connectedNodeID] = { name: currentNode.name || "Unnamed", type: currentNode.deviceType || "light" }; } }); }); }); getToken() .then((token) => { setStatus('loading', 'deploying', ''); axios.post(`${settings.apiURL}/hub/deploy`, connectedNodes, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) .then((response) => { const { data } = response.data; if ( data?.minVersion && compatibilityCheck(data.minVersion) ){ if (data?.auth) { const { certificate, private, endpoint } = data.auth; node.context().set('mqttAuth', { certificate, private, endpoint }); initMQTT(); } if (data?.devices) { // Notify devices node.send({ payload: { status: 'deployed', devices: data.devices } }); } } }) .catch((err) => { setStatus('error', 'Deployment error', errorMessage(err, 'Deployment error')); }); }) .catch((err) => { setStatus('error', 'Token error', errorMessage(err, 'Token retrieval error')); }); } // Handle node input node.on('input', function (msg) { getToken() .then((token) => { axios.post(`${settings.apiURL}/device/set`, msg.payload, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) .catch((err) => { setStatus('error', 'Request failed', errorMessage(err, 'Request to set device state failed')); }); }) .catch((err) => { setStatus('error', 'Token error', errorMessage(err, 'Token retrieval error')); }); }); // Handle node shutdown or redeployment node.on('close', function (done) { if (mqttClient) { try { mqttClient.end(); done(); } catch (error) { setStatus('error', 'MQTT stopping error', `Error stopping MQTT client: ${error.message}`); done(error); } } else { done(); } }); /** * Backend methods to handle requests from the frontend */ RED.httpNode.get('/dulonode/subscription/upgrade', async (req, res) => { getToken() .then((token) => { axios.get(`${settings.apiURL}/subscription/upgrade`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) .then((response) => { const { data } = response.data; if (data?.url) { res.redirect(data.url); } else { res.status(500).send({ error: 'No URL returned' }); } }) .catch((err) => { setStatus('error', 'Upgrade subscription error', errorMessage(err, 'Upgrade subscription error')); res.status(500).send({ error: error.message }); }); }) .catch((err) => { setStatus('error', 'Token error', errorMessage(err, 'Token retrieval error')); }); }); RED.httpNode.get('/dulonode/subscription/manage', async (req, res) => { try { const token = await getToken(); const response = await axios.get(`${settings.apiURL}/subscription/manage`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); const { data } = response.data; if (data?.url) { // Redirect the client to the URL res.redirect(data.url); } else { // Handle case where no URL is returned res.status(500).send({ error: 'No URL returned' }); } } catch (err) { // Log and handle errors setStatus('error', 'Manage subscription error', errorMessage(err, 'Manage subscription error')); res.status(500).send({ error: err.message }); } }); RED.httpNode.get('/dulonode/subscription/details', async (req, res) => { if (!node.email || !node.password) { res.json({}); } else { try { const token = await getToken(); const response = await axios.get(`${settings.apiURL}/subscription/details`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); const { data } = response.data; if (data) { res.json(data); } else { res.status(500).send({ error: 'No subscription details returned' }); } } catch (err) { // Log and handle errors setStatus('error', 'Subscription details error', errorMessage(err, 'Subscription details error')); res.status(500).send({ error: err.message }); } } }); RED.httpNode.get('/dulonode/installation', async (req, res) => { res.json({ installation: process.env.HASSIO_TOKEN ? 'homeassistant' : 'standalone' }); }); /** * On deploy, collect connected devices and send them to the API */ if (!node.email || !node.password) { setStatus('error', 'Account configuration', 'The account email or password is not configured.'); return; } else { deploy(); } } RED.nodes.registerType('DuloNodeHub', DuloNodeHub, { credentials: { email: { type: 'text' }, password: { type: 'password' } } }); };