UNPKG

homebridge-myplace

Version:

Exec Plugin bringing Advanatge Air MyPlace system to Homekit

949 lines (821 loc) 31 kB
const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils'); const { RequestError } = require('@homebridge/plugin-ui-utils'); const fs = require('fs') const chalk = require('chalk') const which = require('which'); const path = require( "path" ); const commandExistsSync = require( "command-exists" ).sync; class UiServer extends HomebridgePluginUiServer { constructor () { super(); this.MYPLACE_SH = "/homebridge-myplace/MyPlace.sh"; this.CONFIGCREATOR_SH = "/homebridge-myplace/ConfigCreator.sh"; this.listOfConstants = { }; // To enable debug, add the following to your config.json AT ANY TIME. // No restart required. // { // "platform": "MyPlace", // "debug": true // }, // // Note: remove the above or you will get the message: // No plugin was found for the platform "MyPlace" in your config.json. Please make // sure the corresponding plugin is installed correctly. // // This error is not harmful, just annoying. this.debug = false; this.config = { }; this.updateConfigFirstTime( true ); // handle request this.onRequest('/configcreator', this.ConfigCreator.bind(this)); this.onRequest('/checkInstallationButtonPressed', this.checkInstallationButtonPressed.bind(this)); this.onRequest('/consoleLog', this.consoleLog.bind(this)); // console.log("HomebridgePluginUIServer ready"); this.ready(); } async ConfigCreator(payload) { if (payload.ip1 !== "") { payload.ip1 += ':'; payload.ip1 += payload.port1; console.log('Processing AA system:', payload.name1,payload.ip1,payload.debug1); console.log('extra Timers:', payload.extraTimers1); } if (payload.ip2 !== "") { payload.ip2 += ':'; payload.ip2 += payload.port2; console.log('Processing AA system:', payload.name2,payload.ip2,payload.debug2); console.log('extra Timers:', payload.extraTimers2); } if (payload.ip3 !== "") { payload.ip3 += ':'; payload.ip3 += payload.port3; console.log('Processing AA system:', payload.name3,payload.ip3,payload.debug3); console.log('Extra Timers:', payload.extraTimers3); } try { const MyPlace_shPath = this.getGlobalNodeModulesPathForFile( this.MYPLACE_SH ); const ConfigCreator_shPath = this.getGlobalNodeModulesPathForFile( this.CONFIGCREATOR_SH ); //This spawns a child process which runs a bash script const spawnSync = require('child_process').spawnSync; let FeedBack = spawnSync(ConfigCreator_shPath, [payload.ip1,payload.name1,payload.extraTimers1,payload.debug1,payload.ip2,payload.name2,payload.extraTimers2,payload.debug2,payload.ip3,payload.name3,payload.extraTimers3,payload.debug3,MyPlace_shPath], {encoding: 'utf8'}); let feedback = `${ FeedBack.stdout.replace(/\n*$/, "")}` // return data to the ui return { feedback: feedback } } catch (e) { throw new RequestError('Failed to run ConfigCreator.sh', { message: e.message }); } } async consoleLog( msg ) { if ( this.debug ) console.log( msg ); } // Have the server send an error to the listening HTML page. // We could return the same structure, but this would be synchronously. // which is okay in most instances. The other reason is that toast // error messages close within a few seconds. Not giving time for // complicated messages like the proper state_cmd to use. advError( data ) { this.pushEvent('advErrorEvent', data ); } checkQueueTypesForQueue( queueTypes, queue ) { for ( let queueTypesIndex = 0; queueTypesIndex < queueTypes.length; queueTypesIndex++ ) { let entry = queueTypes[ queueTypesIndex ]; if ( entry.queue == queue ) { if ( entry.queueType == "WoRm2" ) { return( { rc: true, message: `Passed` }); } return( { rc: false, message: `queue ${ queue } queueType is not WoRm2. Please change to Worm2.` }); } } return( { rc: false, message: `No matching queue: "${ queue }" in queueTypes` }); } // Cmd5 has the ability to allow constants which could be used for the IP // processConstants( constantsArgArray ) { // // Check #8A // Constants must be an Array // if ( ! Array.isArray ( constantsArgArray ) ) { this.advError( { "rc": false, "message": `Constants must be an array of { "key": "\${SomeKey}", "value": "some replacement string" }` }); return false; } // Iterate over the groups of key/value constants in the array. // Note: DO NOT USE: forEach as javascript continues after a return! for ( let argIndex = 0; argIndex < constantsArgArray.length; argIndex++ ) { let argEntry = constantsArgArray[ argIndex ]; if ( argEntry.key == undefined ) { // // Check #8B // key must be defined // this.advError( { "rc": false, "message": `Constant definition at index: "${ argIndex }" has no "key":` }); return false; } if ( argEntry.value == undefined ) { // // Check #8c // value must be defined // this.advError( { "rc": false, "message": `Constant definition at index: "${ argIndex }" has no "value":` }); return false; } let keyToAdd = argEntry.key; let valueToAdd = argEntry.value; if ( ! keyToAdd.startsWith( "${" ) ) { if ( this.debug ) console.log( `Constant definition for: "${ keyToAdd }" must start with "\${" for clarity.` ); // // Check #8D // key must start with ${ // this.advError( { "rc": false, "message": `Constant definition for: "${ keyToAdd }" must start with "\${" for clarity.` }); return false; } if ( ! keyToAdd.endsWith( "}" ) ) { // // Check #8E // key must end with } // if ( this.debug ) console.log( `Constant definition for: "${ keyToAdd }" must end with "}" for clarity.` ); this.advError( { "rc": false, "message": `Constant definition for: "${ keyToAdd }" must end with "}" for clarity.` }); return false; } // remove any leading and trailing single quotes // so that using it for replacement will be easier. valueToAdd.replace(/^'/, "") valueToAdd.replace(/'$/, "") if ( this.debug ) console.log( "Server.js keyToAa=%s valueToAdd:%s", keyToAdd, valueToAdd ); this.listOfConstants[ keyToAdd ] = valueToAdd; } return true; } replaceConstantsInString( orig ) { let finalAns = orig; for ( let key in this.listOfConstants ) { let replacementConstant = this.listOfConstants[ key ]; if ( this.debug ) console.log(`replacing key: ${ key } with: ${ replacementConstant }` ); finalAns = finalAns.replace( key, replacementConstant ); } return finalAns; } updateConfigFirstTime( firstTime ) { // // Check #1 // See if the config.json file exists // let configFile = this.homebridgeConfigPath; if ( configFile == undefined ) { if ( this.debug ) console.log( `Server.js returning false configFile is undefined` ); this.advError( { "rc": false, "message": `No config.json yet` }); return false; } if ( ! fs.existsSync( configFile ) ) { if ( ! firstTime ) { if ( this.debug ) console.log( `Server.js returning false configFile ${ configFile }` ); this.advError( { "rc": false, "message": `No ${ configFile } yet` }); } return false; } // Open the config.json file for reading let config_in = fs.readFileSync( configFile, 'utf8' ); // // Check #2 // Convert the config.json into a json type // This can throw an Error so catch it. try { this.config = JSON.parse( config_in ); } catch ( e ) { if ( ! firstTime ) { if ( this.debug ) console.log( `Server.js returning false parse failed ${ e }` ); this.advError( { "rc": false, "message": `Parse config.json failed: ${ e }` }); } return false; } let cmd5AdvantageAirConfig = this.config.platforms.find( platform => platform[ "MyPlace" ] !== null ); if ( cmd5AdvantageAirConfig && cmd5AdvantageAirConfig.debug ) { console.log( `Setting debug for MyPlace` ); this.debug = cmd5AdvantageAirConfig.debug; } if ( this.debug ) console.log( `main.js After JSONPARSE` ); return true; } // There is nothing really to differentiate a regular Cmd5 Accessory for that of // an Advantage Air // isAccessoryAnMyPlace( accessory ) { if ( accessory.manufacturer && accessory.manufacturer.match( /Advantage Air/ ) ) return true; // Trigger off of the state_cmd, if it exists if ( accessory.state_cmd != undefined ) { // The new MyPlace if ( accessory.state_cmd.match( /MyPlace.sh/ ) ) return true; } return false; } // This method is called by the html page (main.js) to check the users config.json // for a valid configuration of the Advantage Air accessory. checkInstallationButtonPressed( ) { // The read in config.json in JSON format let fileToFind = ""; if ( this.debug ) console.log( "Server.js in checkInstallationButtonPressed(" ); // Update the config, this is not the first time // return if it fails. As this is not the First time, it will // error if need be. if ( this.updateConfigFirstTime( false ) == false ) return; // // Check #3 // Check that jq is installed. if ( ! commandExistsSync( "jq" ) ) { if ( this.debug ) console.log( `Server.js returning false jq not installed` ); this.advError( { "rc": false, "message": `jq is required globally and not installed.` }); return; } // // Check #4 // Check that jq is installed. if ( ! commandExistsSync( "curl" ) ) { if ( this.debug ) console.log( `Server.js returning false curl not installed` ); this.advError( { "rc": false, "message": `curl is required globally and not installed.` }); return; } // // Check #5A // Find Node modules // let node_modules = this.getGlobalNodeModulesPathForFile( "/homebridge-myplace/MyPlace.sh" ); node_modules = node_modules.replace("/homebridge-myplace/MyPlace.sh", ""); if ( node_modules == null ) { if ( this.debug ) console.log( `Server.js Could not determine where node_modules is.` ); this.advError( { "rc": false, "message": `Could not determine where node_modules is installed globally.` }); return; } // // Check #5B // See if Cmd5 is installed from node_modules // fileToFind = "/homebridge-myplace/index.js"; let cmd5Index = this.getGlobalNodeModulesPathForFile( fileToFind ) if ( cmd5Index == null ) { if ( this.debug ) console.log( `Server.js returning false cmd5Index <Your Global node_modules Path>${ fileToFind }` ); this.advError( { "rc": false, "message": `Homebridge-MyPlace Plugin not installed` }); return; } // // Check #6 // See if our MyPlace.sh script is present // // Create the path to the cmd5MyAir.sh from node_modules let ourScript = this.getGlobalNodeModulesPathForFile( this.MYPLACE_SH ) if ( ourScript == null ) { if ( this.debug ) console.log( `Server.js returning false. No MyPlace.sh present. Looking for: <Your Global node_modules Path>${ this.MYPLACE_SH }` ); this.advError( { "rc": false, "message": `No MyPlace.sh script present. Looking for: <Your Global node_modules Path>${ this.MYPLACE_SH }` }); return; } let cmd5AccessoriesFound = false; let advantageAirAccessoriesFound = []; let cmd5QueueTypesFound = []; let retVal = { }; // Iterate over the elements in the array. // Note: DO NOT USE: forEach as javascript continues after a return! for ( let entryIndex = 0; entryIndex < this.config.platforms.length; entryIndex++ ) { let entry = this.config.platforms[ entryIndex]; if ( this.debug ) console.log( `Server.js Checking Platform entry ${ entry.platform }` ); // // Check #7 // See if any Cmd5 accessories are defined in config.json // if ( entry.platform != "MyPlace" ) continue; cmd5AccessoriesFound = true; // // Check #18 // See if there are any accessory queues defined // if ( entry.queueTypes != undefined ) { // // Check #19 // queueTypes must be an array // if ( ! Array.isArray( entry.queueTypes ) ) { if ( this.debug ) console.log( `Server.js returning false queueTypes is not an Array` ); this.advError( { "rc": false, "message": `queueTypes is not an Array` }); return; } // Iterate over the elements in the array. // Note: DO NOT USE: forEach as javascript continues after a return! for ( let queueTypesIndex = 0; queueTypesIndex < entry.queueTypes.length; queueTypesIndex++ ) { let queueTypeEntry = entry.queueTypes[ queueTypesIndex ]; // Need to append each one retVal = this.checkQueueTypesForQueue( cmd5QueueTypesFound, queueTypeEntry.queue ); if ( retVal.rc == true ) // if ( cmd5QueueTypesFound.find( queueTypeEntry ) ) { // // Check #20 // Duplicate queue // this.advError( { "rc": false, "message": `Duplicate queue found: ${ queueTypeEntry.queue }` }); return; } cmd5QueueTypesFound.push( queueTypeEntry ); } } // // Check #8 // Process Constants // if ( entry.constants != undefined ) if ( this.processConstants( entry.constants ) == false ) return; // Iterate over the elements in the array. // Note: DO NOT USE: forEach as javascript continues after a return! for ( let accessoryIndex = 0; accessoryIndex < entry.accessories.length; accessoryIndex++ ) { let accessory = entry.accessories[ accessoryIndex ]; if ( this.debug ) console.log( `Server.js Checking accessory ${ accessory.name }` ); // // Check #9 // See if any Advantage Air accessories are defined in config.json // if ( ! this.isAccessoryAnMyPlace( accessory ) ) continue; // // Check #10 // See if any Advantage Air accessory has a defined name // if ( this.debug ) console.log( `Server.js Checking accessory ${ accessory.name }` ); if ( accessory.name == undefined ) { this.advError( { "rc": false, "message": `Accessory at index: ${ entryIndex } accessory.name is undefined` }); return; } // // Check #11 // See if any Advantage Air accessory has a defined displayName // if ( this.debug ) console.log( `Server.js Checking accessory ${ accessory.name } for displayName` ); if ( accessory.displayName == undefined ) { this.advError( { "rc": false, "message": `Accessory at index: ${ entryIndex } "${ accessory.name }" has no displayName` }); return; } // // Check #12 // Polling is done by displayName, It cannot already exist. // if ( this.debug ) console.log( `Server.js Checking accessory ${ accessory.displayName } for duplicate displayName` ); if ( advantageAirAccessoriesFound.find( ( displayName ) => displayName == accessory.displayName ) ) { this.advError( { "rc": false, "message": `Accessory: "${ accessory.displayName }"'s displayName is defined twice` }); return; } // Add it to the Array advantageAirAccessoriesFound.push( accessory.displayName ); if ( this.debug ) console.log( `Server.js Checking Advantage Air accessory ${ accessory.displayName }` ); // // Check #13 // The state_cmd must be defined for the Air accessory // if ( accessory.state_cmd == undefined ) { this.advError( { "rc": false, "message": `No state_cmd for: "${ accessory.displayName }"` }); return; } // // Check #14 // See if the state_cmd does not match the MyPlace.sh // if ( ! accessory.state_cmd.match( ourScript ) ) { if ( this.debug ) console.log( `Server.js returning false accessory.displayName ${ accessory.displayName } invalid state_cmd` ); this.advError( { "rc": false, "message": `Invalid state_cmd for: "${ accessory.displayName }". It should be:\n${ ourScript }` }); return; } // // Check #15 // See if the state_cmd_suffix is defined for the Air accessory // It must have at least an IP if ( accessory.state_cmd_suffix == undefined ) { this.advError( { "rc": false, "message": `No state_cmd_suffix for: "${ accessory.displayName }". It must at least contain an IP.` }); return; } if ( this.debug ) console.log(`Calling replaceConstantsInString`); let state_cmd_suffix = this.replaceConstantsInString( accessory.state_cmd_suffix ); if ( this.debug ) console.log(`after replaceConstantsInString state_cmd_suffix=${ state_cmd_suffix }`); // // Check #16 // The state_cmd_suffix must have an IP for the Air accessory // if ( ! state_cmd_suffix.match( /[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/ ) ) { this.advError( { "rc": false, "message": `state_cmd_suffix has no IP for: "${ accessory.displayName }" state_cmd_suffix: ${ state_cmd_suffix }` }); return; } // // Check #17 // Checking for required linkedTypes accessory and required keywords in state_cmd_sufffix if ( accessory.type.match( /Switch/ ) || accessory.type.match( /Thermostat/ ) ) { // Check #17A // A Switch or a Thermostat must have a linkedTypes 'Fan' accessory for fanSpeed control if ( accessory.linkedTypes == undefined ) { this.advError( { "rc": false, "message": `"${ accessory.displayName }" requires a linkedTypes 'Fan' accessory for fan speed control.` }) return; // Check #17B // The state_cmd_suffix must have 'fanSpeed' linkedTypes Fan accessory associated with a Switch or a Thermostat } else if ( ! accessory.linkedTypes[0].state_cmd_suffix.match( /fanSpeed/ ) ) { this.advError( { "rc": false, "message": `The state_cmd_suffix for "${ accessory.linkedTypes[0].displayName }" requires the keyword 'fanSpeed' (without quotes).` }) return; } // Check #17C // The state_cmd_suffix must have 'timer' or 'Timer' or 'ligID' for a Lightbulb accessory } else if ( accessory.type.match( /Lightbulb/ ) ) { if ( ! ( state_cmd_suffix.match( /timer/ ) || state_cmd_suffix.match( /fanTimer/ ) || state_cmd_suffix.match( /coolTimer/ ) || state_cmd_suffix.match( /heatTimer/ ) || state_cmd_suffix.match( /ligID:/ ) ) ) { this.advError( { "rc": false, "message": `The state_cmd_suffix for "${ accessory.displayName }" requires 'timer' or 'fanTimer' or 'coolTimer' or 'heatTimer' (without quotes) if being used as timers or requires ligID:<light ID> if being used as a MyPlace Light.` }) return; } // Check #17D // The state_cmd_suffix must have 'thiID' for an Garage or WindowCovering accessory } else if ( accessory.type.match( /^Window/ ) || accessory.type.match( /^Garage/ ) ) { if ( ! state_cmd_suffix.match( /thiID:/ ) ) { this.advError( { "rc": false, "message": `The state_cmd_suffix has no 'thiID' for: "${ accessory.displayName }"` }) return; } // Check #17E // The state_cmd_suffix must have a zone for a Fan accessory with displayName ending with ' Zone' (without quotes) } else if ( accessory.type.match( /^Fan/ ) && accessory.displayName.match( / Zone$/ ) ) { if ( ! state_cmd_suffix.match( /z[0-9][0-9]/ ) ) { this.advError( { "rc": false, "message": `The state_cmd_suffix has no zone for: "${ accessory.displayName }"` }) return; } } // // Check #21 // See if there is a queue defined // if ( accessory.queue == undefined ) { if ( this.debug ) console.log( `Server.js returning false accessory.displayName ${ accessory.displayName } queue is not a string` ); this.advError( { "rc": false, "message": `No queue defined for: "${ accessory.displayName }"` }); return; } // // Check #22 // queue name must be an string // if ( typeof accessory.queue != "string" ) { if ( this.debug ) console.log( `Server.js returning false accessory.displayName ${ accessory.displayName } queue is not a string` ); this.advError( { "rc": false, "message": `queue for: "${ accessory.displayName }" is not a string` }); return; } retVal = this.checkQueueTypesForQueue( cmd5QueueTypesFound, accessory.queue ); // Check #23 // queue must be defined in queueTypes if ( retVal.rc == false ) { if ( this.debug ) console.log( `Server.js returning false accessory.displayName ${ accessory.displayName } no queue defined in queueTypes` ); this.advError( { "rc": false, "message": `For: "${ accessory.displayName }" ${ retVal.message }` }); return; } // Check #24 Polling must be defined for MyPlace accessories if ( ! accessory.polling || ( typeof accessory.polling == "boolean" && accessory.polling != true && ! Array.isArray( accessory.polling) ) ) { if ( this.debug ) console.log( `Server.js returning false accessory.displayName ${ accessory.displayName } polling not defined correctly` ); this.advError( { "rc": false, "message": `Polling for: "${ accessory.displayName }" is not an Array or Boolean` }); return; } } } // // Check #32 // See if any Cmd5 accessories are defined in config.json // if ( cmd5AccessoriesFound == false ) { if ( this.debug ) console.log( `Server.js returning false noCmd5Accessories` ); this.advError( { "rc": false, "message": `No MyPlace Accessories found` }); return; } // // Check #33 // See if any Advantage Air accessories are defined in config.json // if ( advantageAirAccessoriesFound.length == 0 ) { if ( this.debug ) console.log( `Server.js returning false noAIRAccessories` ); this.advError( { "rc": false, "message": `No Advantage Air Accessories found` }); return; } // // Check #34 // See if any queueTypes were defined // ( Most likely an earlier failure will succeed this one ) // if ( cmd5QueueTypesFound == null ) { if ( this.debug ) console.log( `Server.js returning false no Cmd5 Queue types defined` ); this.advError( { "rc": false, "message": `No Cmd5 Queue Types were defined for Advantage Air Accessories` }); return; } if ( this.debug ) { console.log( chalk.red( `Remember to remove the "MyPlace" debug entry from your config.json when done.` ) ); } // PASS ! this.advError( { "rc": true, "message": `Passed` }); } getGlobalNodeModulesPathForFile( file ) { let fullPath = null; for ( let tryIndex = 1; tryIndex <= 6; tryIndex ++ ) { switch ( tryIndex ) { case 1: { if ( commandExistsSync( "npm" ) ) { // Use spawnSync as execSync does not allow capture of // stdio, even when using try/catch const spawnSync = require('child_process').spawnSync; let foundPath = spawnSync("npm", ["root", "-g"], {encoding: 'utf8'}); if ( foundPath.stderr ) { console.log( "Error: %s", foundPath.stderr ); console.log( "This error is a Debian packaging issue. See: https://github.com/nodejs/node-v0.x-archive/issues/3911#issuecomment-8956154" ); break; } if ( ! foundPath.stdout ) break; // Remove any trailing carriage returns and combine // with file. let fullPath = `${ foundPath.stdout.replace(/\n*$/, "")}${ file }`; if ( fs.existsSync( fullPath ) ) return fullPath; } break; } case 2: { if ( commandExistsSync( "homebridge" ) ) { const homebridgePath = which.sync( 'homebridge', { nothrow: true } ) if ( homebridgePath ) { let dirname = path.dirname( homebridgePath ); dirname = dirname.replace(/\/[^/]+$/, ''); fullPath = `${dirname}${ file }`; if ( fs.existsSync( fullPath ) ) return fullPath; } } break; } case 3: { fullPath = `/usr/local/lib/node_modules${ file }`; if ( fs.existsSync( fullPath ) ) return fullPath; break; } case 4: { fullPath = `/usr/lib/node_modules${ file }`; if ( fs.existsSync( fullPath ) ) return fullPath; break; } case 5: { fullPath = `/var/lib/homebridge/node_modules${ file }`; if ( fs.existsSync( fullPath ) ) return fullPath; break; } case 6: { fullPath = `/opt/homebrew/lib/node_modules${ file }`; if ( fs.existsSync( fullPath ) ) return fullPath; break; } } } return null; } } module.exports = UiServer; //(() => { // return new UiServer(); //})(); (function() { return new UiServer; })();