Homebridge 玩法:使用 HomeBridge-Server 定制 WEB 界面教程

你知道 Apple HomeKit 吗?这是一个简单的解决方案,使您的家 “聪明”。但是这也是非常昂贵的,因为每个 HomeKit-Accessory 都需要获得许可。

这个障碍可以通过 HomeBridge 来解决。该项目旨在使不兼容的设备在 HomeKit 生态系统中可用。

要配置 HomeBridge 并添加新设备,您必须手动调整 JSON 文件。在我自己和我父母的家中实施 HomeBridge 后,我发现需要一个更简单的解决方案来配置这一切。

我想出了一个提供 Node.js 网页的 HomeBridge 插件。 这个插件是由 HomeBridge 本身启动的,读取 config.json 文件,解析所有的部分,并在简单的网站上显示。该项目现在在 HomeBridge-Server 下的 GitHub 上,可以通过 npm 来安装:

npm install homebridge-server@latest -g  

有关如何设置以及如何使用插件的更多信息,请参阅 Wiki

它能做什么?

该网站使用Twitter Bootstrap框架,并提供一个菜单栏来对 config.json 进行备份,显示 HomeBridge 的日志文件并重启系统。并且,它提供了一个可视化的方式,来调整 config.json 与不同的输入字段。您可以轻松添加或删除平台和插件。

所有可用的平台和附件都列在包含信息栏中的类型(type),给定名称(name)和所有附加字段的信息栏(info-column)中。信息栏实际上是最难的部分,因为插件需要的附加字段取决于单个插件开发人员。我认为最简单的方法是向用户提供这些信息,只是正确地输出 JSON。

它是如何工作的?

这是我的第一个 Node.js 项目,所以我尽量保持它尽可能小,也许很多事情都不是完美的。我是开放的,愿意为每一个改进!

              .------------.
              | 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                 |
'----------------------------------------'  '------------------------'  

该插件由一个请求处理程序组成,该处理程序对该地址作出反应并调用相应的函数。所有其他的都是辅助功能,下面将详细介绍。

实现

为了使我的开发过程更加清晰,我将一步一步地浏览整个文件。

准备饭菜

第一部分是使得 HomeBridge 可以访问插件,并设置基本参数:

var Service, Characteristic, LastUpdate;

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

接下来我提供 HomeBridge-plugin-manager 调用的主函数,要求文件系统访问读写 config.json 以及所需的节点 http-server 来为页面服务:

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

接下来,我读取并解析实际的 config.json

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

然后我准备两个变量的平台和两个配件。其中一个包含 JSON 对象,而另一个包含相应的字符串,以便我可以稍后在网页中替换并连接它们。

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

接下来,我准备 header 和 footer 变量。这些包含了 jQuery 和 Bootstrap 的资源,以及接近 Apples 字体和配色方案的视觉适应。

    var bootstrap = "<link href="//maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap.min.css" rel="stylesheet"/>"
        //+ "<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 content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/><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>";

我已经注意到了一些资源,因为它们现在看起来很坏,所以我可以有一个完全响应的导航栏或功能的输入字段。 我认为输入字段是最重要的用途。;)

要创建一个导航栏,我实现了以下字符串:

    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];

然后我使用 html 的 table 语法以 table1 变量开始和以 table2 变量结束。这其中被用来包围即时创建的表格。我在网上发现了一个黑客行为,允许在代码中换行,以便表代码易于阅读。 hack 由一个内联函数组成,用于替换换行符以及作为注释给出的字符串中的 /**/

    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];

然后我创建了最后一个重要的变量,它们将包含 HomeBridge 实例的名称(name),用户名(username)和引脚(pin)。

    var bridgeName;
    var bridgeUsername;
    var bridgePin;

设置了所有的变量后,我开始实现所有需要的内联函数。

去除奇怪的字符

第一个函数是 ``stripEscapeCodes(chunk)。 这个函数接受一个带有转义码的字符串,替换它们并返回清理后的字符串。

    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,'&lt;')
         .replace(/\%3E/g,'&gt;')
         .replace(/\%5B/g,'[')
         .replace(/\%5D/g,']');
        return receivedData;
    }

解析 JSON 并创建对象

服务器的一个非常重要的步骤是,正确地将 JSON 内容转换为正确的 html 代码。这是由 prepareConfig() 函数提供的。主要是迭代 JSON 的内容,将每个项目添加到平台或附件字符串,并用 html 标记填充它以分隔表格列和行。

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

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

        const wastebasket = "🗑";
        const pen = "✍";
        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 class="btn btn-default center-block" href='/removePlatform" + id_platform + "' 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 class="btn btn-default center-block" href='/removeAccessory" + id_accessory + "' style="height: 34px; line-height: 16px; vertical-align:middle;outline:none !important;"><span '="" style="font-size:25px;">" + symbolToPresent + ";</span></a>"
                 + "</td></tr>";
        }
    }

添加平台和配件

为了向用户展示添加平台或附件的能力,服务器提供了由 printAddPage(res,type,additionalInput) 函数创建的添加页面。 此功能只显示一个文本字段(texture-field ),用户可以在其中添加所需平台或附件的复制 JSON 代码。 如果页面显示为添加平台(platform)或配件(accessory)取决于用户来自哪里。该函数接受三个参数。用于 html 代码的响应变量 res,类型变量类型,简单地添加到 html 标记的字符串以及在平台和附件之间分离的 POST-link,以及额外的输入字符串 additionalInput 来提供反馈,如果 用户输入了不正确的JSON代码。

    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 action='/save" + type + "Settings' enctype="application/x-www-form-urlencoded" method="post">")
        res.write("<textarea class="form-control" name='" + type + "ToAdd' placeholder='{ \"" + type  + "\": \"test\" }' required="" rows="10" type="text"></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 class="btn btn-default center-block" style="width:135px" type="submit" value="Save">");
        res.write("<a class="btn btn-default center-block" href="/" style="width:135px">Cancel</a>");
        res.write("</input></div>");
        res.write("</div>");
        res.write("</div></form>");

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

展示页面

第二个输出函数是 printMainPage(res),它连接预编译的变量来为主网页提供服务。用户可以与导航栏交互,保存调用 /saveBridgeSettings 处理程序的更改设置(请参阅下面的更多信息),通过调用 /addPlatform-/addAccessory 处理程序添加平台和附件,并通过单击删除每个平台或设备到废纸篓里(这是早些时候,当每个项目被添加到表中)。

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

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

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

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

WEB UI 展示

保存并重新加载配置

reloadConfig(res) 函数重新读取并解析 config.json,重置变量并输出到主页面。

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

为了使更改永久,服务器提供 saveConfig(res,backup) 功能。这个函数使用一个响应变量 res,来打印网页上的一个标题和一个 bool 变量 backup 来决定如何保存配置。它将存储的 configJSON 转换成一个有效的字符串,替换所有空的项目,最后写入文件。

    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 class="close" data-dismiss="success" href="/">×</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 class="close" data-dismiss="alert" href="/">×</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));
        }
    }

在成功存储配置之后,该函数调用 reloadConfig(res) 函数来反映浏览器中的可视变化。这不会重新启动 HomeBridge,以便更改不会影响正在运行的系统。为了使这些改变不仅是永久性的,而且也是适用的,用户必须重新启动 HomeBridge 服务。 这可以通过重新启动服务本身或重新启动整个系统来完成。后者可以通过网页导航栏中的重新启动按钮触发。

处理用户输入

最后一个重要的功能是 handleRequest(req,res) 功能。 此功能指示服务器如何响应不同的请求的网页。 它是作为 switch-case-statement 来实现的。

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

第一种情况是通过准备配置,并返回主页面来处理根 / 请求。

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

第二种情况处理 /saveBridgeSettings 请求。 这有点复杂,因为客户端必须发 POST 请求配置来保存。该函数然后拆分接收到的字符串,并去除所有的转义码。

            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("&amp;");
                        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;

第三种情况 /addPlatform 和第四种情况 /addAccessory 用于向用户呈现单个项目的添加页面。

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

接下来的两种情况 /savePlatformSettings/saveAccessorySettings 解析给定的 JSON 代码,并将其存储在配置文件中的正确位置。如果检测到错误,则会打印带有错误代码的横幅,并将用户返回到相应的添加页面。

            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 class="close" data-dismiss="alert" href="/addPlatform">×</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 class="close" data-dismiss="alert" href="/addAccessory">×</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;

如果用户想要创建备份,请求将是 /createBackup,处理程序将调用 saveConfig(res,true) 函数。

Homebridge Server 备份 UI

为了呈现 HomeBridge 的最新日志文件,服务将从存储中读取存储的日志文件,将其打包到代码容器中,并将客户端作为网页提供。这个网页是静态的,需要手动刷新。

            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;

Homebridge 服务日志

要使所有更改处于活动状态,需要重新启动服务。由于有很多不同的可能性,来自动启动 HomeBridge 服务,我决定只提供重启整个系统的可能性。也许将来我可以在配置中添加另一个命令,这个命令将在这个按钮上单击。

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

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

默认情况下,验证用户是否请求带有 /remove 前缀的页面。 地址的第二部分是 PlatformAccessory,第三部分是按照 config.json 顺序的项目的索引。如果服务器发现给定的索引,它将从 configJSON 变量中删除,并且更改将被存储。

            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);
                }
        };
    }

运行服务器

为了完成程序,我最终启动服务器,让它监听定义的端口并打印服务器可到达的 IP 地址。

    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);
        })
    });
}

为了完成这个插件,我实现了一个空的附件回调函数,返回一个空的数组,这样服务器就不会被列为一个真正的 HomeKit 可访问的设备。

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

原文链接:https://gismo141.github.io/configure-your-homebridge-2/index.html

尚未评分
您的评分将帮助我们做出更好的玩法

观光\评论区

Copyright © 2017 玩点什么. All Rights Reserved.