HomeKit

Do you know Apples HomeKit? It is an easy solution to make your Home "smart". But it is also very expensive because every HomeKit-Accessory needs to be licensed.

This hurdle can be passed with HomeBridge. The project aims to make incompatible devices available in the HomeKit ecosystem.

To configure the HomeBridge and to add new devices, you have to manually adapt a JSON-file. After implementing the HomeBridge at my own and my parents home, I discovered the need for an easier solution to configure everything.

I came up with a HomeBridge plugin that serves a NodeJS-webpage. This plugin is started by HomeBridge itself, reads the config.json-file, parses all the sections and presents them on a simply website. The project is now online at GitHub under HomeBridge-Server and can be installed via npm:

npm install homebridge-server@latest -g  

Please see the Wiki for further information on how to setup and how to use the plugin.


What can it do?

The website uses the Twitter Bootstrap framework and provides a menubar to make a backup of the config.json, show the log-file of HomeBridge and to reboot the system. As well it provides a visual way to adapt the config.json with different input-fields. You can easily add or remove platforms and plugins.

All available platforms and accessories are listed in a table containing the type, the given name and all the additional fields in the info-column. The info-column was in fact the hardest part, because the additional fields a plugin needs depends on the individual plugin developer. I thought the easiest way, to present the user these information, was to just properly print the JSON.

But how does it work?

This is my first NodeJS-project so I tried to keep it as small as possible and maybe a lot of things are not perfect - I am open and willing for every improvement!

              .------------.
              | HomeBridge |
              '-----.------'
                    |                       .------------------------.
.-------------------'--------------------.  |'/'                     |
|           HomeBridge-Server            |  |'/saveBridgeSettings'   |
|========================================|  |'/addPlatform'          |
|handleRequest(req, res)....................|'/addAccessory'         |
|stripEscapeCodes(chunk)                 |  |'/savePlatformSettings' |
|saveConfig(res, backup)                 |  |'/saveAccessorySettings'|
|reloadConfig(res)                       |  |'/createBackup'         |
|prepareConfig()                         |  |'/showLog'              |
|printAddPage(res, type, additionalInput)|  |'/reboot'               |
|printMainPage(res)                      |  |default                 |
'----------------------------------------'  '------------------------'  

The plugin consists of a request-handler that reacts on the address and calls the corresponding functions. All the others are helper-functions and will be explained in further detail in the next sections.


The implementation

To make my development process clear, I will go through the whole file step by step.

Prepare the meal

The first part makes the plugin accessible to HomeBridge and sets the basic parameters:

var Service, Characteristic, LastUpdate;

module.exports = function(homebridge) {  
    Service = homebridge.hap.Service;
    Characteristic = homebridge.hap.Characteristic;
    homebridge.registerPlatform("homebridge-server", "Server", Server);
}

Next I provide the main function that gets called by the HomeBridge-plugin-manager, require the filesystem-access to read and write the config.json as well as the needed node http-server to serve the page:

function Server(log, config) {  
    var self = this;
    self.config = config;
    self.log = log;
    var fs = require('fs');
    var http = require('http');

Next I read and parse the actual config.json:

    var configJSON = require(process.argv[process.argv.indexOf('-U') + 1] + '/config.json');

Then I prepare two variables for the platforms and two for the accessories. One of each holds the JSON-object whereas the other holds the corresponding String so that I can replace and concatenate them later in the webpage.

    var platformsJSON = {};
    var platforms = "";
    var accessoriesJSON = {};
    var accessories = "";

Next I prepare the header and footer variables. These contain the resources for JQuery and Bootstrap as well as visual adaptions that come close to Apples font and color schemes.

    var bootstrap = "<link rel='stylesheet' href='//maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap.min.css'>"
        //+ "<link href='//cdnjs.cloudflare.com/ajax/libs/x-editable/1.5.0/bootstrap3-editable/css/bootstrap-editable.css' rel='stylesheet'/>"
        ;
    var font = "<link href='https://fonts.googleapis.com/css?family=Open+Sans:300' rel='stylesheet' type='text/css'>";
    var style = "<style>h1, h2, h3, h4, h5, h6 {font-family: 'Open Sans', sans-serif;}p, div {font-family: 'Open Sans', sans-serif;} input[type='radio'], input[type='checkbox'] {line-height: normal; margin: 0;}</style>"
    var header = "<html><meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'><head><title>Homebridge - Configuration</title>" + bootstrap + font + style + "</head><body style='padding-top: 70px;'>";
    var footer = "</body>"
        //+ "<script src='//cdnjs.cloudflare.com/ajax/libs/x-editable/1.5.0/bootstrap3-editable/js/bootstrap-editable.min.js'></script>"
        //+ "<script> $(document).ready(function() { $.fn.editable.defaults.mode = 'popup';  $('#username').editable(); }); </script>"
        //+ "<script defer='defer' src='//code.jquery.com/jquery-ui-latest.min.js'></script>"
        //+ "<script defer='defer' src='//code.jquery.com/jquery-latest.min.js'></script>"
        + "<script defer='defer' src='//maxcdn.bootstrapcdn.com/bootstrap/latest/js/bootstrap.min.js'></script>"
        + "</html>";

I've commented out some of the resources because they seem broken at the moment so that I can either have a fully responsive navbar or functioning input-field. I think that the input-fields are most important for that use. ;)
To create a navbar I implemented the following string:

    var navBar = (function() {/*
        <nav class="navbar navbar-default navbar-fixed-top">
            <div class="navbar-header">
                <a class="navbar-brand" href="/">Homebridge - Configuration</a>
            </div>
            <div class="container-fluid">      
              <ul class="nav navbar-nav navbar-right">
                  <li><a href="/createBackup">Backup</a></li>
                  <li><a href="/showLog">Log</a></li>
                  <li><a href="/reboot">Reboot</a></li>
                </ul>
            </div>
        </nav>
    */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1];

Then I provide the opening table1 and the closing table2 of the html-table syntax. These are used to surround the on-the-fly created table afterwards. I've found a hack online that allows a line break in the code so that the table-code is easily readable. The hack consists of an inline function that replaces the line breaks and /* as well as */ in the string which was given as a comment.

    var table1 = (function() {/* 
            <div class="table-responsive"> 
              <table class="table table-hover">
                <thead>
                  <tr>
                    <th width='15%'>Type</th>
                    <th width='35%'>Name</th>
                    <th width='40%'>Info</th>
                    <th width='10%'></th>
                  </tr>
                </thead>
                <tbody>
    */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1];
    var table2 = (function() {/*  
                </tbody>
              </table>
            </div>
    */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1];

Then I create the last important variables that will hold the name, the username and the pin of the HomeBridge instance.

    var bridgeName;
    var bridgeUsername;
    var bridgePin;

After having setup all the variables, I start by implementing all the needed inline functions.

Strip strange characters

The first function is stripEscapeCodes(chunk). This function takes a string with escape codes, replaces them and returns the cleaned string.

    function stripEscapeCodes(chunk) {
        var receivedData = chunk.toString()
         .replace(/\%40/g,'@')
         .replace(/\%23/g,'#')
         .replace(/\%7B/g,'{')
         .replace(/\%0D/g,'')
         .replace(/\%0A/g,'')
         .replace(/\%2C/g,',')
         .replace(/\%7D/g,'}')
         .replace(/\%3A/g,':')
         .replace(/\%22/g,'"')
         .replace(/\+/g,' ')
         .replace(/\+\+/g,'')
         .replace(/\%2F/g,'/')
         .replace(/\%3C/g,'<')
         .replace(/\%3E/g,'>')
         .replace(/\%5B/g,'[')
         .replace(/\%5D/g,']');
        return receivedData;
    }

Parse the JSON and create objects

A very important step for the server is to correctly transfer the JSON content into proper html-code. This is provided by the prepareConfig()-function. Mainly it iterates over the JSON-content, adds every item to the platform- or accessory-string and pads it with the html-markup to separate the table columns and rows.

function prepareConfig() {  
        bridgeName = "<div class='form-group'><label for='homebridgename'>Name:</label><input type='text' class='form-control' name='bridgeName' value='" + configJSON.bridge.name + "'></div>";
        bridgeUsername = "<div class='form-group'><label for='username'>Username:</label><input type='text' class='form-control' name='bridgeUsername' value='" + configJSON.bridge.username + "'></div>";
        bridgePin = "<div class='form-group'><label for='pin'>Pin:</label><input type='text' class='form-control' name='bridgePin' value='" + configJSON.bridge.pin + "'></div>";

        platformsJSON = configJSON.platforms;
        platforms = "";
        accessoriesJSON = configJSON.accessories;
        accessories = "";

        const wastebasket = "&#128465";
        const pen = "&#9997";
        var symbolToPresent = wastebasket;

        for (var id_platform in platformsJSON) {
            var platformNoTypeNoName = JSON.parse(JSON.stringify(platformsJSON[id_platform]));
            delete platformNoTypeNoName.platform;
            delete platformNoTypeNoName.name;
            var platform = platformsJSON[id_platform];
            platforms = platforms + "<tr>"
             + "<td style='vertical-align:middle;'>" + platform.platform + "</td>"
              + "<td style='vertical-align:middle;'>" + platform.name + "</td>"
               + "<td style='vertical-align:middle;'>" + (JSON.stringify(platformNoTypeNoName, null, ' ')).replace(/,/g,',<br>') + "</td>"
                + "<td style='vertical-align:middle;'><a href='/removePlatform" + id_platform + "' class='btn btn-default center-block' style='height: 34px; line-height: 16px; vertical-align:middle;outline:none !important;'><span style='font-size:25px;''>" + symbolToPresent + ";</span></a>"
                 + "</td></tr>";
        }

        for (var id_accessory in accessoriesJSON) {
            var accessoryNoTypeNoName = JSON.parse(JSON.stringify(accessoriesJSON[id_accessory]));
            delete accessoryNoTypeNoName.accessory;
            delete accessoryNoTypeNoName.name;
            var accessory = accessoriesJSON[id_accessory];
            accessories = accessories + "<tr>"
             + "<td style='vertical-align:middle;'>" + accessory.accessory + "</td>"
              + "<td style='vertical-align:middle;'>" + accessory.name + "</td>"
               + "<td style='vertical-align:middle;'>" + (JSON.stringify(accessoryNoTypeNoName, null, ' ')).replace(/,/g,',<br>') + "</td>"
                + "<td style='vertical-align:middle;'><a href='/removeAccessory" + id_accessory + "' class='btn btn-default center-block' style='height: 34px; line-height: 16px; vertical-align:middle;outline:none !important;'><span style='font-size:25px;''>" + symbolToPresent + ";</span></a>"
                 + "</td></tr>";
        }
    }

Add platforms and accessories

To present the user the ability to add platforms or accessories, the server provides an add-page that is created by the printAddPage(res, type, additionalInput)-function. This function simply shows a texture-field where the user can add the copied JSON-code for the wanted platform or accessory. If the page is shown for adding a platform or an accessory depends where the user came from. The function accepts three parameters. The response-variable res for the html code, the type variable type, a string that is simply added to the html markup and the POST-link to separate between platforms and accessories, as well as an additional input string additionalInput to provide feedback, if the user entered incorrect JSON code.

    function printAddPage(res, type, additionalInput) {
        res.write(header + navBar);
        res.write("<div class='container'>");

        if(additionalInput != null) {
            res.write(additionalInput);
        }

        res.write("<h2>Add " + type + "</h2>");

        res.write("<form enctype='application/x-www-form-urlencoded' action='/save" + type + "Settings' method='post'>")
        res.write("<textarea class='form-control' type='text' name='" + type + "ToAdd' rows='10' placeholder='{ \"" + type  + "\": \"test\" }' required></textarea>");
        res.write("<br>");
        res.write("<div class='row'>");
        res.write("<div class='col-xs-offset-1 col-sm-offset-1 col-md-offset-2 col-xs-10 col-sm-9 col-md-8 text-center'>");
        res.write("<div class='btn-group' data-toggle='buttons'>");
        res.write("<input type='submit' class='btn btn-default center-block' value='Save' style='width:135px' />");
        res.write("<a href='/' class='btn btn-default center-block' style='width:135px'>Cancel</a>");
        res.write("</div>");
        res.write("</div>");
        res.write("</form>");

        res.write("<br>");
        res.write("</div>");
        res.end(footer);
    }

Show me the page

The second print function is printMainPage(res) that concatenates the precompiled variables to serve the main webpage. The user can interact with the navbar, save changed settings which calls the /saveBridgeSettings-handler (see below for further info), add platforms and accessories by calling the /addPlatform- or /addAccessory-handler and remove each platform or device by clicking on the wastebasket (this was added earlier, when each item was added to the table).

    function printMainPage(res) {
        res.write(header + navBar);
        res.write("<div class='container'>");

        //res.write("<h1>Homebridge</h1>");
        //res.write("<h2>Configuration</h2>");

        res.write("<form enctype='application/x-www-form-urlencoded' action='/saveBridgeSettings' method='post'>")
        res.write(bridgeName + bridgeUsername + bridgePin);
        res.write("<input type='submit' class='btn btn-default center-block' style='width:135px' value='Save' />");
        res.write("</form>");

        res.write("<h2>Platforms</h2>");
        if (0 < Object.keys(platformsJSON).length) {
            res.write(table1 + platforms + table2);
        } else {
            res.write("No platforms installed or configured!");
        }
        res.write("<a href='/addPlatform' name='AddPlatform' class='btn btn-default center-block' style='width:135px'>Add</a><br>");

        res.write("<h2>Accessories</h2>");
        if (0 < Object.keys(accessoriesJSON).length) {
            res.write(table1 + accessories + table2);
        } else {
            res.write("No accessories installed or configured!");
        }
        res.write("<a href='/addAccessory' name='AddAccessory' class='btn btn-default center-block' style='width:135px'>Add</a><br>");

        res.write("</div>");
        res.end(footer);
    }

Overview

Save and reload the configuration

The reloadConfig(res) function rereads and parses the config.json, resets the variables and prints the main page.

    function reloadConfig(res) {
        configJSON = require(process.argv[process.argv.indexOf('-U') + 1] + '/config.json');
        prepareConfig();
        printMainPage(res);
    }

To make the changes permanent, the server provides the saveConfig(res, backup)-function. This function takes a response variable res to print a banner on the webpage and a bool variable backup to determine how the configuration should be saved. It converts the stored configJSON into a valid string, replacing all empty items and finally writes the file.

    function saveConfig(res, backup) {
        var newConfig = JSON.stringify(configJSON)
         .replace(/\[,/g, '[')
         .replace(/,null/g, '')
         .replace(/null,/g, '')
         .replace(/null/g, '')
         .replace(/,,/g, ',')
         .replace(/,\]/g, ']');
        newConfig = JSON.stringify(JSON.parse(newConfig), null, 4);
        if(backup != null) {
            fs.writeFile(process.argv[process.argv.indexOf('-U') + 1] + '/config.json.bak', newConfig, "utf8", function (err, data) {
                if (err) {
                  return console.log(err);
                }
                res.write(header + navBar);
                res.write("<div class='alert alert-success alert-dismissible fade in out'><a href='/' class='close' data-dismiss='success'>&times;</a><strong>Succes!</strong> Configuration saved!</div>");
                res.end(footer);
            });    
        } else {
            res.write(header + navBar);
            res.write("<div class='alert alert-danger alert-dismissible fade in out'><a href='/' class='close' data-dismiss='alert'>&times;</a><strong>Note!</strong> Please restart Homebridge to activate your changes.</div>");
            fs.writeFile(process.argv[process.argv.indexOf('-U') + 1] + '/config.json', newConfig, "utf8", reloadConfig(res));
        }
    }

After storing the configuration successfully, the function calls the reloadConfig(res)-function to reflect the changes visually in the browser. This doesn't restart the HomeBridge so that the changes won't effect the running system. To make the changes not only permanent but also applicable, the user must restart its HomeBridge-service. This can either be done by restarting the service itself or by restarting the whole system. The latter can be triggered by the Reboot-button in the navbar of the webpage.

Handle the user-inputs

The last but important function is the handleRequest(req, res)-function. This function instructs how the server responds to different requested webpages. It is implemented as a switch-case-statement.

    function handleRequest(req, res) {
        switch (req.url) {

The first case handles the root / request by preparing the configuration and sending back the main-page.

            case '/':
                prepareConfig();
                printMainPage(res);
                break;

The second case handles the /saveBridgeSettings request. This is a little bit complex because the client has to POST the settings to save. The function then splits the received string and strips all the escape-codes.

            case '/saveBridgeSettings':
                if (req.method == 'POST') {
                    req.on('data', function(chunk) {
                        var receivedData = chunk.toString();
                        console.log("[Homebridge-Server] received body data: " + receivedData);
                        var arr = receivedData.split("&");
                        configJSON.bridge.name = stripEscapeCodes(arr[0].replace('bridgeName=',''));
                        configJSON.bridge.username = arr[1].replace('bridgeUsername=','').replace(/\%3A/g,':');
                        configJSON.bridge.pin = arr[2].replace('bridgePin=','');
                        saveConfig(res);
                        console.log("[Homebridge-Server] Saved bridge settings.");
                    });
                    req.on('end', function(chunk) { });
                } else {
                    console.log("[Homebridge-Server] [405] " + req.method + " to " + req.url);
                }
                break;

The third case /addPlatform and the fourth case /addAccessory are used to present the user the add-page for the individual item.

            case '/addPlatform':
                printAddPage(res, "Platform");
                break;
            case '/addAccessory':
                printAddPage(res, "Accessory");
                break;

The next two cases /savePlatformSettings and /saveAccessorySettings parse the given JSON-code and stores it at the correct place in the config file. If it detects an error, it prints a banner with the wrong code and throws the user back to the appropriate add-page.

            case '/savePlatformSettings':
                if (req.method == 'POST') {
                    req.on('data', function(chunk) {
                        var receivedData = stripEscapeCodes(chunk).replace('PlatformToAdd=','');
                            try {
                                configJSON.platforms.push(JSON.parse(receivedData));
                                if(configJSON.platforms.length == 1) {
                                    configJSON.platforms = JSON.parse(JSON.stringify(configJSON.platforms).replace('[,','['));
                                }
                                saveConfig(res);
                                console.log("[Homebridge-Server] Saved platform " + JSON.parse(receivedData).name + ".");
                            } catch (ex) {
                                res.write(header + navBar);
                                res.write("<div class='alert alert-danger alert-dismissible fade in out'><a href='/addPlatform' class='close' data-dismiss='alert'>&times;</a><strong>Error!</strong> Invalid JSON-entry detected. Please verify your input!</div>");
                                printAddPage(res, "Platform", "{gfm-js-extract-pre-1}");
                            }
                    });
                    req.on('end', function(chunk) { });
                } else {
                    console.log("[Homebridge-Server] [405] " + req.method + " to " + req.url);
                }
                break;
            case '/saveAccessorySettings':
                if (req.method == 'POST') {
                    req.on('data', function(chunk) {
                        var receivedData = stripEscapeCodes(chunk).replace('AccessoryToAdd=','');
                            try {
                                configJSON.accessories.push(JSON.parse(receivedData));
                                if(configJSON.accessories.length == 1) {
                                    configJSON.accessories = JSON.parse(JSON.stringify(configJSON.accessories).replace('[,','['));
                                }
                                saveConfig(res);
                                console.log("[Homebridge-Server] Saved accessory " + JSON.parse(receivedData).name + ".");
                            } catch (ex) {
                                res.write(header + navBar);
                                res.write("<div class='alert alert-danger alert-dismissible fade in out'><a href='/addAccessory' class='close' data-dismiss='alert'>&times;</a><strong>Error!</strong> Invalid JSON-entry detected. Please verify your input!</div>");
                                printAddPage(res, "Accessory", "{gfm-js-extract-pre-2}");
                            }
                    });
                    req.on('end', function(chunk) { });
                } else {
                    console.log("[Homebridge-Server] [405] " + req.method + " to " + req.url);
                }
                break;

If the user wants to create a backup, the request will be /createBackup and the handler calls the saveConfig(res, true)-function.

            case '/createBackup':
                saveConfig(res, true);
                break;

Overview

To present the latest log-file of the HomeBridge, the service reads the stored log-file from the storage, packs it into a code-container and serves the client as a webpage. This webpage is static and needs to be refreshed manually.

            case '/showLog':
                logFile = require('fs');
                logFile.readFile(self.config.log, 'utf8', function (err, log) {
                    if (err) {
                      return console.log(err);
                    }
                    res.write(header + navBar);
                    res.write("<div class='container'>");
                    res.write("<h2>Log</h2>");
                    res.write("{gfm-js-extract-pre-3}");
                    res.write("</div>");
                    res.end(footer);
                });
                break;

Overview

To make all changes active, the service needs to be restarted. Because there are a lot of different possibilities to autostart the HomeBridge-service, I decided to only provide the possibility to reboot the whole system. Maybe in the future I could add another command in the config that will be called on this button-click.

            case '/reboot':
                var exec = require('child_process').exec;
                var cmd = "sudo reboot";

                exec(cmd, function(error, stdout, stderr) {
                  // command output is in stdout
                });
                break;

The default case verifies if the user requested a page with the /remove prefix. The second part of the address is either Platform or Accessory and the third part is the index of the item in the order of the config.json. If the server finds the given index it is deleted from the configJSON-variable and the changes will be stored.

            default:
                url = req.url;
                if (url.indexOf('/remove') !== -1) {
                    object = url.replace('/remove', '');
                    if (object.indexOf('Platform') !== -1) {
                        platform = object.replace('Platform', '');
                        delete configJSON.platforms[platform];
                        console.log("[Homebridge-Server] Removed platform " + platform + ".");
                    } else if (object.indexOf('Accessory') !== -1) {
                        accessory = object.replace('Accessory', '');
                        delete configJSON.accessories[accessory];
                        console.log("[Homebridge-Server] Removed accessory " + accessory + ".");
                    }
                    saveConfig(res);
                }
        };
    }

Launch the server

To conclude the program I finally start the server, let it listen at the defined port and print out the IP-address where the server is reachable.

    var server = http.createServer(handleRequest);

    server.listen(self.config.port, function() {
        require('dns').lookup(require('os').hostname(), function(err, add, fam) {
            console.log("[Homebridge-Server] is listening on: http://%s:%s", add, self.config.port);
        })
    });
}

To complete the plugin, I implement an empty accessories-callback that returns an empty array, so that the server won't be listed as a real HomeKit-accessible device.

Server.prototype.accessories = function(callback) {  
    var self = this;
    self.accessories = [];
    callback(self.accessories);
}