// =========================================================== // SERVER.JS // =========================================================== // // Canadian Locomotive Logistics // // This Node.js server controls lighting on different layouts. // // It can control TWO different types of smart lights: // // 1. TUYA // 2. LIFX // // The actual lights are defined in: // // devices.json // // This means we can add/change lights in devices.json // without having to rewrite server.js. // // Example API calls: // // /layout1/light1/on // /layout1/light1/off // // /layout2/light1/on // /layout2/light1/off // // The server looks in devices.json to determine whether // the requested light is TUYA or LIFX. // // =========================================================== // =========================================================== // LOAD THE NODE.JS MODULES WE NEED // =========================================================== // Express creates our web/API server. // // require() tells Node.js to load another module. // // const means we are creating a variable whose reference // will not later be reassigned. const express = require("express"); // Axios is used to send HTTP requests. // // We use Axios to communicate with both: // // Tuya Cloud // LIFX Cloud const axios = require("axios"); // Crypto provides encryption/hashing functions. // // Tuya requires us to digitally sign API requests. const crypto = require("crypto"); // fs = File System. // // This lets Node.js read our devices.json file. const fs = require("fs"); // path helps Node.js build proper file paths. const path = require("path"); // =========================================================== // CREATE THE EXPRESS SERVER // =========================================================== // Create our Express application. const app = express(); // Tell Express that we may use JSON data. app.use(express.json()); // Our Node.js server listens on port 3000. const PORT = 3000; // =========================================================== // TUYA SETTINGS // =========================================================== // // These are the credentials for your Tuya Cloud project. // // IMPORTANT: // // Copy the REAL values from your CURRENT working server.js. // // Do NOT put the placeholders below into production. // const TUYA_ACCESS_ID = "INPUT TUYA ACCESS ID"; const TUYA_ACCESS_SECRET = "INPUT TUYA ACCCESS SECRECT"; // This is the Tuya API server for the America region. const TUYA_BASE_URL = "https://openapi.tuyaus.com"; // =========================================================== // LIFX SETTINGS // =========================================================== // // LIFX uses an API token. // // Later we will put your actual LIFX token here. // const LIFX_TOKEN = "INPUT LIFX TOKEN"; // This is the base address for the LIFX HTTP API. const LIFX_BASE_URL = "https://api.lifx.com/v1"; // =========================================================== // DEVICES.JSON // =========================================================== // // __dirname means: // // "the folder where server.js is located" // // So if server.js and devices.json are in the same folder, // this creates the correct path automatically. // const DEVICES_FILE = path.join( __dirname, "devices.json" ); // =========================================================== // LOAD DEVICES.JSON // =========================================================== // // function creates a reusable piece of JavaScript. // // Whenever we call: // // loadDevices() // // JavaScript reads devices.json and returns its contents. // function loadDevices() { try { // Read devices.json as text. // // "utf8" tells Node.js that this is a normal // text file. const data = fs.readFileSync( DEVICES_FILE, "utf8" ); // JSON.parse() converts the JSON text into // a JavaScript object that we can work with. return JSON.parse(data); } catch (error) { // If something goes wrong, display the error // in the Node.js console. console.error( "Unable to read devices.json:", error.message ); // Return an empty object so the server // does not immediately crash. return {}; } } // =========================================================== // TUYA ACCESS TOKEN STORAGE // =========================================================== // // Tuya gives us a temporary access token. // // Instead of requesting a new token every time somebody // presses a light button, we remember the token until // it is close to expiring. // let accessToken = ""; let tokenExpireTime = 0; // =========================================================== // SHA256 FUNCTION // =========================================================== // // Tuya requires SHA256 hashing when signing requests. // // "data" is whatever information we give the function. // // The function returns the SHA256 hash. // function sha256(data) { return crypto .createHash("sha256") .update(data) .digest("hex"); } // =========================================================== // HMAC SHA256 FUNCTION // =========================================================== // // Tuya also requires an HMAC-SHA256 signature. // // This proves that the API request came from someone // who knows the Tuya secret. // function hmacSHA256( data, secret ) { return crypto .createHmac( "sha256", secret ) .update(data) .digest("hex") .toUpperCase(); } // =========================================================== // GET TUYA ACCESS TOKEN // =========================================================== // // async means this function performs work that may take time, // such as communicating with another server. // // await means: // // "wait here until this operation finishes" // async function getAccessToken() { // Date.now() gives us the current time // in milliseconds. const now = Date.now(); // ------------------------------------------------------- // CHECK FOR AN EXISTING TOKEN // ------------------------------------------------------- // // && means AND. // // If we already have a token AND it has not expired, // simply return the existing token. // if ( accessToken && now < tokenExpireTime ) { return accessToken; } // ------------------------------------------------------- // BUILD THE TUYA TOKEN REQUEST // ------------------------------------------------------- const method = "GET"; const url = "/v1.0/token?grant_type=1"; const timestamp = Date.now().toString(); // There is no request body for this GET request, // so we hash an empty string. const contentHash = sha256(""); // Build the exact string Tuya expects us to sign. const stringToSign = method + "\n" + contentHash + "\n" + "\n" + url; // Add our Tuya Access ID and timestamp. const signString = TUYA_ACCESS_ID + timestamp + stringToSign; // Generate the security signature. const signature = hmacSHA256( signString, TUYA_ACCESS_SECRET ); // ------------------------------------------------------- // CONTACT TUYA // ------------------------------------------------------- // // axios.get() sends an HTTP GET request. // // await tells JavaScript to wait for Tuya's answer. // const response = await axios.get( TUYA_BASE_URL + url, { headers: { client_id: TUYA_ACCESS_ID, sign: signature, t: timestamp, sign_method: "HMAC-SHA256" } } ); // ------------------------------------------------------- // CHECK TUYA'S RESPONSE // ------------------------------------------------------- if (!response.data.success) { // ! means NOT. // // So this means: // // If success is NOT true... throw new Error( "Unable to get Tuya token: " + JSON.stringify( response.data ) ); } // Save the token. accessToken = response.data.result.access_token; // Tuya normally tells us how long the token lasts. // // || means OR / fallback. // // If expire_time is unavailable, use 7200 seconds. const expireSeconds = response.data.result.expire_time || 7200; // Renew the token one minute before it actually expires. tokenExpireTime = Date.now() + (expireSeconds - 60) * 1000; console.log( "Tuya access token updated" ); // Give the token back to whichever function requested it. return accessToken; } // =========================================================== // SEND ON/OFF COMMAND TO TUYA // =========================================================== // // deviceId = Tuya's ID for the physical device. // // state determines ON or OFF: // // true = ON // false = OFF // async function setTuyaSwitch( deviceId, state ) { // Get a valid Tuya access token. const token = await getAccessToken(); const method = "POST"; // Build the Tuya API address for this particular device. // The backticks ` ` allow us to insert variables // directly into a string using ${variable}. const url = `/v1.0/iot-03/devices/${deviceId}/commands`; // Build the command we will send to Tuya. const body = JSON.stringify({ commands: [ { code: "switch_1", value: state } ] }); const timestamp = Date.now().toString(); const contentHash = sha256(body); const stringToSign = method + "\n" + contentHash + "\n" + "\n" + url; const signString = TUYA_ACCESS_ID + token + timestamp + stringToSign; const signature = hmacSHA256( signString, TUYA_ACCESS_SECRET ); // ------------------------------------------------------- // SEND COMMAND TO TUYA // ------------------------------------------------------- const response = await axios.post( TUYA_BASE_URL + url, JSON.parse(body), { headers: { client_id: TUYA_ACCESS_ID, access_token: token, sign: signature, t: timestamp, sign_method: "HMAC-SHA256", "Content-Type": "application/json" } } ); // Check whether Tuya accepted the command. if (!response.data.success) { throw new Error( "Tuya command failed: " + JSON.stringify( response.data ) ); } return response.data; } // =========================================================== // SEND ON/OFF COMMAND TO LIFX // =========================================================== // // This performs the same basic job as setTuyaSwitch(), // except this function talks to LIFX. // // selector tells LIFX WHICH light we want. // // state: // // true = ON // false = OFF // async function setLifxSwitch( selector, state ) { // ------------------------------------------------------- // MAKE SURE A TOKEN HAS BEEN ENTERED // ------------------------------------------------------- if ( !LIFX_TOKEN || LIFX_TOKEN === "PUT_YOUR_LIFX_TOKEN_HERE" ) { throw new Error( "LIFX API token has not been configured" ); } // ------------------------------------------------------- // CONVERT TRUE/FALSE INTO ON/OFF // ------------------------------------------------------- // // This is called a ternary operator. // // It is a short way of saying: // // if state is true // power = "on" // otherwise // power = "off" // const power = state ? "on" : "off"; // encodeURIComponent() makes the selector safe // to put inside a web address. const encodedSelector = encodeURIComponent(selector); // Build the LIFX API address. const url = `${LIFX_BASE_URL}/lights/${encodedSelector}/state`; // ------------------------------------------------------- // SEND COMMAND TO LIFX // ------------------------------------------------------- const response = await axios.put( url, { power: power }, { headers: { // LIFX authenticates using a Bearer token. Authorization: `Bearer ${LIFX_TOKEN}`, "Content-Type": "application/json" } } ); return response.data; } // =========================================================== // UNIVERSAL DEVICE CONTROL // =========================================================== // // THIS IS THE IMPORTANT NEW PART. // // The rest of the program does NOT need to know how Tuya // or LIFX works. // // It simply calls: // // setDeviceState(device, true) // // or: // // setDeviceState(device, false) // // This function looks at: // // device.type // // inside devices.json. // // For example: // // "type": "tuya" // // or: // // "type": "lifx" // async function setDeviceState( device, state ) { // Read the device type. // // If "type" is missing, assume Tuya. // // This helps maintain compatibility with your // existing devices.json. const type = String( device.type || "tuya" ).toLowerCase(); // ------------------------------------------------------- // TUYA DEVICE // ------------------------------------------------------- if (type === "tuya") { // Make sure we have a Tuya device ID. if (!device.deviceId) { throw new Error( "Tuya deviceId missing" ); } // Send the command to Tuya. return await setTuyaSwitch( device.deviceId, state ); } // ------------------------------------------------------- // LIFX DEVICE // ------------------------------------------------------- if (type === "lifx") { // Make sure we have a LIFX selector. if (!device.selector) { throw new Error( "LIFX selector missing" ); } // Send the command to LIFX. return await setLifxSwitch( device.selector, state ); } // ------------------------------------------------------- // UNKNOWN DEVICE TYPE // ------------------------------------------------------- throw new Error( `Unknown device type: ${type}` ); } // =========================================================== // AUTOMATIC OFF TIMERS // =========================================================== // // This object keeps track of active timers. // // Example: // // timers["layout1/light1"] // // timers["layout2/light1"] // // Each light therefore gets its OWN timer. // const timers = {}; // =========================================================== // GET DEVICE INFORMATION // =========================================================== // // This looks up a device inside devices.json. // // For example: // // getDevice("layout2", "light1") // // would find: // // layout2 // light1 // function getDevice( layout, deviceName ) { // Read the latest devices.json. const devices = loadDevices(); // Does this layout exist? if (!devices[layout]) { return null; } // Does this light exist inside the layout? if ( !devices[layout][deviceName] ) { return null; } // Return the information for that light. return devices[layout][deviceName]; } // =========================================================== // API ROUTE — TURN LIGHT ON // =========================================================== // // Express watches for an address such as: // // /layout2/light1/on // // The parts beginning with : are variables: // // :layout // :device // // Therefore: // // /layout2/light1/on // // gives us: // // layout = "layout2" // device = "light1" // app.get( "/:layout/:device/on", async (req, res) => { // Get the layout name from the URL. const layout = req.params.layout; // Get the device name from the URL. const deviceName = req.params.device; // Look up this device in devices.json. const device = getDevice( layout, deviceName ); // --------------------------------------------------- // DEVICE DOES NOT EXIST // --------------------------------------------------- if (!device) { return res .status(404) .json({ success: false, error: "Device not found" }); } // --------------------------------------------------- // READ THE TIMER // --------------------------------------------------- // // Number() converts the value to a number. // // || 30 means: // // If timerMinutes is missing or invalid, // use 30 minutes. // // Example: // // "timerMinutes": 15 // // means the light stays on for 15 minutes. // const timerMinutes = Number( device.timerMinutes ) || 30; // Create a unique name for this timer. // // Example: // // layout2/light1 const timerKey = `${layout}/${deviceName}`; try { // ================================================= // TURN THE LIGHT ON // ================================================= // // setDeviceState() determines whether this is // a Tuya or LIFX light. // // true means ON. // await setDeviceState( device, true ); console.log( `${timerKey} turned ON` ); // ================================================= // CHECK FOR AN EXISTING TIMER // ================================================= // // Suppose the timer is 15 minutes. // // Someone presses ON. // // 10 minutes later they press ON again. // // We cancel the old timer and start another // full 15-minute timer. // if (timers[timerKey]) { clearTimeout( timers[timerKey] ); console.log( `${timerKey} timer restarted` ); } // ================================================= // START AUTOMATIC OFF TIMER // ================================================= // // setTimeout() tells JavaScript: // // "Run this code later." // // JavaScript timers use MILLISECONDS. // // Therefore: // // minutes // × 60 seconds // × 1000 milliseconds // // Example: // // 15 × 60 × 1000 // // = 900,000 milliseconds // // = 15 minutes // timers[timerKey] = setTimeout( async () => { try { // false means OFF. await setDeviceState( device, false ); console.log( `${timerKey} automatically turned OFF after ${timerMinutes} minutes` ); } catch (error) { console.error( `Automatic OFF failed for ${timerKey}:`, error.message ); } // The timer has finished, // so remove it from our timer list. delete timers[ timerKey ]; }, timerMinutes * 60 * 1000 ); // ================================================= // TELL THE WEB BROWSER IT WORKED // ================================================= res.json({ success: true, layout: layout, device: deviceName, type: device.type || "tuya", state: "on", timerMinutes: timerMinutes }); } catch (error) { // If Tuya or LIFX reports an error, // display it in the Node console. console.error( "ON error:", error.message ); // Also send the error back to the browser. res .status(500) .json({ success: false, error: error.message }); } } ); // =========================================================== // API ROUTE — TURN LIGHT OFF // =========================================================== // // Example: // // /layout2/light1/off // // or: // // /layout1/light1/off // app.get( "/:layout/:device/off", async (req, res) => { // Read layout and device from the URL. const layout = req.params.layout; const deviceName = req.params.device; // Look up the device. const device = getDevice( layout, deviceName ); // Make sure it exists. if (!device) { return res .status(404) .json({ success: false, error: "Device not found" }); } // Build this light's timer name. const timerKey = `${layout}/${deviceName}`; try { // ================================================= // TURN LIGHT OFF // ================================================= // // false means OFF. // await setDeviceState( device, false ); // ================================================= // CANCEL AUTOMATIC TIMER // ================================================= // // If somebody manually turns the light OFF, // we don't need the automatic OFF timer anymore. // if (timers[timerKey]) { clearTimeout( timers[timerKey] ); delete timers[ timerKey ]; } console.log( `${timerKey} turned OFF` ); // Tell the browser that the command worked. res.json({ success: true, layout: layout, device: deviceName, type: device.type || "tuya", state: "off" }); } catch (error) { console.error( "OFF error:", error.message ); res .status(500) .json({ success: false, error: error.message }); } } ); // =========================================================== // SERVER TEST PAGE // =========================================================== // // If we visit the server without specifying a light: // // http://server-address:3000/ // // this message will appear. // app.get( "/", (req, res) => { res.send( "Tuya + LIFX control server is running" ); } ); // =========================================================== // START THE SERVER // =========================================================== // // app.listen() starts Express. // // PORT is 3000. // // The function after it runs once the server // has successfully started. // app.listen( PORT, () => { console.log( `Tuya + LIFX server running on port ${PORT}` ); } );