Ver código fonte

embed gzipped UI in firmware rather than storing it on SPIFFS

Chris Mullins 8 anos atrás
pai
commit
81e398655b

+ 2 - 0
.gitignore

@@ -3,3 +3,5 @@
 .clang_complete
 .gcc-flags.json
 /dist
+/web/node_modules
+/web/build

+ 2 - 628
data/web/index.html

@@ -16,643 +16,17 @@
   <!--[if lt IE 9]>
     <script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.js"></script>
   <![endif]-->
-
-  <style>
-    .header-row { border-bottom: 1px solid #ccc; }
-    label { display: block; }
-    .radio-option { padding: 0 5px; cursor: pointer; }
-    .command-buttons { list-style: none; margin: 0; padding: 0; }
-    .command-buttons li { display: inline-block; margin-right: 1em; }
-    .form-entry { margin: 0 0 20px 0; }
-    .form-entry .form-control { width: 20em; }
-    .form-entry label { display: inline-block; }
-    label:not(.error) .error-info { display: none; }
-    .error-info { color: #f00; font-size: 0.7em; }
-    .error-info:before { content: '('; }
-    .error-info:after { content: ')'; }
-    .header-btn { margin: 20px; }
-    #sniffed-traffic { max-height: 50em; overflow-y: auto; }
-    .btn-secondary {
-      background-color: #fff;
-      border: 1px solid #ccc;
-    }
-    .inline { display: inline-block; }
-    .white-temp-picker {
-      height: 2em;
-      background: linear-gradient(to right,
-        rgb(166, 209, 255) 0%,
-        rgb(255, 255, 255) 50%,
-        rgb(255, 160, 0) 100%
-      );
-      display: inline-block;
-      padding: 3px 0;
-    }
-    .hue-picker {
-      height: 2em;
-      width: 100%;
-      display: block;
-    }
-    .hue-picker-inner {
-      height: 2em;
-      width: calc(100% - 3em);
-      display: inline-block;
-      cursor: pointer;
-      background: linear-gradient(to right,
-        rgb(255, 0, 0) 0%,
-        rgb(255, 255, 0) 16.6667%,
-        rgb(0, 255, 0) 33.3333%,
-        rgb(0, 255, 255) 50%,
-        rgb(0, 0, 255) 66.6667%,
-        rgb(255, 0, 255) 83.3333%,
-        rgb(255, 0, 0) 100%
-      );
-    }
-    .hue-value-display {
-      border: 1px solid #000;
-      margin-left: 0.5em;
-      width: 2em;
-      height: 2em;
-      display: inline-block;
-    }
-    .plus-minus-group {
-      overflow: auto;
-      width: 100%;
-      clear: both;
-      display: block;
-    }
-    .plus-minus-group button:first-of-type {
-      border-bottom-right-radius: 0;
-      border-top-right-radius: 0;
-      float: left;
-      display: block;
-    }
-    .plus-minus-group button:last-of-type {
-      border-bottom-left-radius: 0;
-      border-top-left-radius: 0;
-      display: block;
-    }
-    .plus-minus-group .title {
-      border-width: 1px 0;
-      border-color: #ccc;
-      border-style: solid;
-      padding: 5px 5px 5px 7px;
-      margin: 0;
-      height: 34px;
-      line-height: 1.49;
-      float: left;
-      display: block;
-    }
-    .field-help {
-      display: inline-block;
-      font-size: 1.25em;
-      margin-left: 0.5em;
-    }
-    .glyphicon.spinning {
-      animation: spin 1s infinite linear;
-      -webkit-animation: spin2 1s infinite linear;
-    }
-
-    @keyframes spin {
-      from { transform: scale(1) rotate(0deg); }
-      to { transform: scale(1) rotate(360deg); }
-    }
-
-    @-webkit-keyframes spin2 {
-      from { -webkit-transform: rotate(0deg); }
-      to { -webkit-transform: rotate(360deg); }
-    }
-  </style>
 </head>
 
 <body>
-  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
   <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
   <script src="https://gitcdn.github.io/bootstrap-toggle/2.2.2/js/bootstrap-toggle.min.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.7.0/bootstrap-slider.min.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.12.4/js/standalone/selectize.min.js"></script>
 
-  <script lang="text/javascript">
-    var FORM_SETTINGS = [
-      "admin_username", "admin_password", "ce_pin", "csn_pin", "reset_pin","packet_repeats",
-      "http_repeat_factor", "auto_restart_period", "discovery_port", "mqtt_server",
-      "mqtt_topic_pattern", "mqtt_update_topic_pattern", "mqtt_username", "mqtt_password",
-      "radio_interface_type", "listen_repeats"
-    ];
-
-    var FORM_SETTINGS_HELP = {
-      ce_pin : "'CE' for NRF24L01 interface, and 'PKT' for 'PL1167/LT8900' interface",
-      packet_repeats : "The number of times to repeat RF packets sent to bulbs",
-      http_repeat_factor : "Multiplicative factor on packet_repeats for " +
-        "requests initiated by the HTTP API. UDP API typically receives " +
-        "duplicate packets, so more repeats should be used for HTTP.",
-      auto_restart_period : "Automatically restart the device every number of " +
-        "minutes specified. Use 0 for disabled.",
-      radio_interface_type : "2.4 GHz radio model. Only change this if you know " +
-        "You're not using an NRF24L01!",
-      mqtt_server : "Domain or IP address of MQTT broker. Optionally specify a port " +
-        "with (example) mymqqtbroker.com:1884.",
-      mqtt_topic_pattern : "Pattern for MQTT topics to listen on. Example: " +
-        "lights/:device_id/:device_type/:group_id. See README for further details.",
-      mqtt_update_topic_pattern : "Pattern to publish MQTT updates. Packets that " +
-        "are received from other devices, and packets that are sent from this device will " +
-        "result in updates being sent.",
-      discovery_port : "UDP port to listen for discovery packets on. Defaults to " +
-        "the same port used by MiLight devices, 48899. Use 0 to disable.",
-      listen_repeats : "Increasing this increases the amount of time spent listening for " +
-        "packets. Set to 0 to disable listening. Default is 3."
-    }
-
-    var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
-    var DEFAULT_UDP_PROTOCL_VERSION = 5;
-
-    var selectize;
-
-    var toHex = function(v) {
-      return "0x" + (v).toString(16).toUpperCase();
-    }
-
-    var activeUrl = function() {
-      var deviceId = $('#deviceId option:selected').val()
-        , groupId = $('#groupId .active input').data('value')
-        , mode = getCurrentMode();
-
-      if (deviceId == "") {
-        alert("Please enter a device ID.");
-        throw "Must enter device ID";
-      }
-
-      if (! $('#group-option').data('for').split(',').includes(mode)) {
-        groupId = 0;
-      }
-
-      return "/gateways/" + deviceId + "/" + mode + "/" + groupId;
-    }
-
-    var getCurrentMode = function() {
-      return $('input[name="mode"]:checked').data('value');
-    };
-
-    var updateGroup = _.throttle(
-      function(params) {
-        $.ajax(
-          activeUrl(),
-          {
-            method: 'PUT',
-            data: JSON.stringify(params),
-            contentType: 'application/json'
-          }
-        );
-      },
-      1000
-    );
-
-    var sendCommand = _.throttle(
-      function(params) {
-        $.ajax(
-          '/system',
-          {
-            method: 'POST',
-            data: JSON.stringify(params),
-            contentType: 'application/json'
-          }
-        );
-      },
-      1000
-    )
-
-    var sniffRequest;
-    var sniffing = false;
-    var getTraffic = function() {
-      sniffRequest = $.get('/gateway_traffic', function(data) {
-        $('#sniffed-traffic').prepend('<pre>' + data + '</pre>');
-        getTraffic();
-      });
-    };
-
-    var gatewayServerRow = function(deviceId, port, version) {
-      var elmt = '<tr>';
-      elmt += '<td>';
-      elmt += '<input name="deviceIds[]" class="form-control" value="' + deviceId + '"/>';
-      elmt += '</td>';
-      elmt += '<td>'
-      elmt += '<input name="ports[]" class="form-control" value="' + port + '"/>';;
-      elmt += '</td>';
-      elmt += '<td>';
-      elmt += '<div class="btn-group" data-toggle="buttons">';
-
-      for (var i = 0; i < UDP_PROTOCOL_VERSIONS.length; i++) {
-        var val = UDP_PROTOCOL_VERSIONS[i]
-          , selected = (version == val || (val == DEFAULT_UDP_PROTOCL_VERSION && !UDP_PROTOCOL_VERSIONS.includes(version)));
-
-        elmt += '<label class="btn btn-secondary' + (selected ? ' active' : '') + '">';
-        elmt += '<input type="radio" name="versions[]" autocomplete="off" data-value="' + val + '" '
-          + (selected ? 'checked' : '') +'> ' + val;
-        elmt += '</label>';
-      }
-
-      elmt += '</div></td>';
-      elmt += '<td>';
-      elmt += '<button class="btn btn-danger remove-gateway-server">';
-      elmt += '<i class="glyphicon glyphicon-remove"></i>';
-      elmt += '</button>';
-      elmt += '</td>';
-      elmt += '</tr>';
-      return elmt;
-    }
-
-    var loadSettings = function() {
-      $.getJSON('/settings', function(val) {
-        Object.keys(val).forEach(function(k) {
-          var field = $('#settings input[name="' + k + '"]');
-
-          if (field.length > 0) {
-            if (field.attr('type') === 'radio') {
-              field.filter('[value="' + val[k] + '"]').click();
-            } else {
-              field.val(val[k]);
-            }
-          }
-        });
-
-        if (val.device_ids) {
-          selectize.clearOptions();
-          val.device_ids.forEach(function(v) {
-            selectize.addOption({text: toHex(v), value: v});
-          });
-          selectize.refreshOptions();
-        }
-
-        var gatewayForm = $('#gateway-server-configs').html('');
-        if (val.gateway_configs) {
-          val.gateway_configs.forEach(function(v) {
-            gatewayForm.append(gatewayServerRow(toHex(v[0]), v[1], v[2]));
-          });
-        }
-      });
-    };
-
-    var saveGatewayConfigs = function() {
-      var form = $('#gateway-server-form')
-        , errors = false;
-
-      $('input', form).removeClass('error');
-
-      var deviceIds = $('input[name="deviceIds[]"]', form).map(function(i, v) {
-        var val = $(v).val();
-
-        if (isNaN(val)) {
-          errors = true;
-          $(v).addClass('error');
-          return null;
-        } else {
-          return val;
-        }
-      });
-
-      var ports = $('input[name="ports[]"]', form).map(function(i, v) {
-        var val = $(v).val();
-
-        if (isNaN(val)) {
-          errors = true;
-          $(v).addClass('error');
-          return null;
-        } else {
-          return val;
-        }
-      });
-
-      var versions = $('.active input[name="versions[]"]', form).map(function(i, v) {
-        return $(v).data('value');
-      });
-
-      if (!errors) {
-        var data = [];
-        for (var i = 0; i < deviceIds.length; i++) {
-          data[i] = [deviceIds[i], ports[i], versions[i]];
-        }
-        $.ajax(
-          '/settings',
-          {
-            method: 'put',
-            contentType: 'application/json',
-            data: JSON.stringify({gateway_configs: data})
-          }
-        )
-      }
-    };
-
-    var deviceIdError = function(v) {
-      if (!v) {
-        $('#device-id-label').removeClass('error');
-      } else {
-        $('#device-id-label').addClass('error');
-        $('#device-id-label .error-info').html(v);
-      }
-    };
-
-    var updateModeOptions = function() {
-      var currentMode = getCurrentMode();
-
-      $('.mode-option').map(function() {
-        if ($(this).data('for').split(',').includes(currentMode)) {
-          $(this).show();
-        } else {
-          $(this).hide();
-        }
-      });
-    };
-
-    var parseVersion = function(v) {
-      var matches = v.match(/(\d+)\.(\d+)\.(\d+)(-(.*))?/);
-
-      return {
-        major: matches[1],
-        minor: matches[2],
-        patch: matches[3],
-        revision: matches[5],
-        parts: [matches[1], matches[2], matches[3], matches[5]]
-      };
-    };
-
-    var isNewerVersion = function(a, b) {
-      var va = parseVersion(a)
-        , vb = parseVersion(b);
-
-      return va.parts > vb.parts;
-    };
-
-    var handleCheckForUpdates = function() {
-      var currentVersion = null
-        , latestRelease = null;
-
-      var handleReceiveData = function() {
-        if (currentVersion != null) {
-          $('#current-version').html(currentVersion.version + " (" + currentVersion.variant + ")");
-        }
-
-        if (latestRelease != null) {
-          $('#latest-version .info-key').each(function() {
-            var value = latestRelease[$(this).data('key')];
-            var prop = $(this).data('prop');
-
-            if (prop) {
-              $(this).prop(prop, value);
-            } else {
-              $(this).html(value);
-            }
-          });
-        }
-
-        if (currentVersion != null && latestRelease != null) {
-          $('#latest-version .status').html('');
-          $('#latest-version-info').show();
-
-          var summary;
-          if (isNewerVersion(latestRelease.tag_name, currentVersion.version)) {
-            summary = "New version available!";
-          } else {
-            summary = "You're on the most recent version.";
-          }
-          $('#version-summary').html(summary);
-
-          var releaseAsset = latestRelease.assets.filter(function(x) {
-            return x.name.indexOf(currentVersion.variant) != -1;
-          });
-
-          if (releaseAsset.length > 0) {
-            console.log(releaseAsset[0].url);
-            $('#firmware-link').prop('href', releaseAsset[0].browser_download_url);
-          }
-        }
-
-        console.log(latestRelease);
-      }
-
-      var handleError = function(e, d) {
-        console.log(e);
-        console.log(d);
-      }
-
-      $('#current-version,#latest-version .status').html('<i class="spinning glyphicon glyphicon-refresh"></i>');
-      $('#version-summary').html('');
-      $('#latest-version-info').hide();
-      $('#updates-modal').modal();
-
-      $.ajax(
-        '/about',
-        {
-          success: function(data) {
-            currentVersion = JSON.parse(data);
-            handleReceiveData();
-          },
-          failure: handleError
-        }
-      );
-
-      $.ajax(
-        '/latest_release',
-        {
-          success: function(data) {
-            latestRelease = data;
-            handleReceiveData();
-          },
-          failure: handleError
-        }
-      );
-    };
-
-    $(function() {
-      $('.radio-option').click(function() {
-        $(this).prev().prop('checked', true);
-      });
-
-      var hueDragging = false;
-      var colorUpdated = function(e) {
-        var x = e.pageX - $(this).offset().left
-          , pct = x/(1.0*$(this).width())
-          , hue = Math.round(360*pct)
-          ;
-
-        $('.hue-value-display').css({
-          backgroundColor: "hsl(" + hue + ",100%,50%)"
-        });
-
-        updateGroup({hue: hue});
-      };
-
-      $('.hue-picker-inner')
-        .mousedown(function(e) {
-          hueDragging = true;
-          colorUpdated.call(this, e);
-        })
-        .mouseup(function(e) {
-          hueDragging = false;
-        })
-        .mouseout(function(e) {
-          hueDragging = false;
-        })
-        .mousemove(function(e) {
-          if (hueDragging) {
-            colorUpdated.call(this, e);
-          }
-        });
-
-      $('.slider').slider();
-
-      $('.raw-update').change(function() {
-        var data = {}
-          , val = $(this).attr('type') == 'checkbox' ? $(this).is(':checked') : $(this).val()
-          ;
-
-        data[$(this).attr('name')] = val;
-        updateGroup(data);
-      });
-
-      $('.command-btn').click(function() {
-        updateGroup({command: $(this).data('command')});
-      });
-
-      $('.system-btn').click(function() {
-        sendCommand({command: $(this).data('command')});
-      });
-
-      $('#sniff').click(function() {
-        if (sniffing) {
-          sniffRequest.abort();
-          sniffing = false;
-          $(this).html('Start Sniffing');
-        } else {
-          sniffing = true;
-          getTraffic();
-          $(this).html('Stop Sniffing');
-        }
-      });
-
-      $('#add-server-btn').click(function() {
-        $('#gateway-server-configs').append(gatewayServerRow('', ''));
-      });
-
-      $('#mode').change(updateModeOptions);
-
-      $('body').on('click', '.remove-gateway-server', function() {
-        $(this).closest('tr').remove();
-      });
-
-      selectize = $('#deviceId').selectize({
-        create: true,
-        sortField: 'text',
-        onOptionAdd: function(v, item) {
-          item.value = parseInt(item.value);
-        },
-        createFilter: function(v) {
-          if (! v.match(/^(0x[a-fA-F0-9]{1,4}|[0-9]{1,5})$/)) {
-            deviceIdError("Must be an integer between 0x0000 and 0xFFFF");
-            return false;
-          }
-
-          var value = parseInt(v);
-
-          if (! (0 <= v && v <= 0xFFFF)) {
-            deviceIdError("Must be an integer between 0x0000 and 0xFFFF");
-            return false;
-          }
-
-          deviceIdError(false);
-
-          return true;
-        }
-      });
-      selectize = selectize[0].selectize;
-
-      var settings = "";
-
-      FORM_SETTINGS.forEach(function(k) {
-        var elmt = '<div class="form-entry">';
-        elmt += '<div>';
-        elmt += '<label for="' + k + '">' + k + '</label>';
-
-        if (FORM_SETTINGS_HELP[k]) {
-          elmt += '<div class="field-help" data-help-text="' + FORM_SETTINGS_HELP[k] + '"></div>';
-        }
-
-        elmt += '</div>';
-
-        if(k === "radio_interface_type") {
-          elmt += '<div class="btn-group" id="radio_interface_type" data-toggle="buttons">' +
-            '<label class="btn btn-secondary active">' +
-              '<input type="radio" id="nrf24" name="radio_interface_type" autocomplete="off" value="nRF24" /> nRF24' +
-            '</label>'+
-            '<label class="btn btn-secondary">' +
-              '<input type="radio" id="lt8900" name="radio_interface_type" autocomplete="off" value="LT8900" /> PL1167/LT8900' +
-            '</label>' +
-          '</div>';
-        } else {
-          elmt += '<input type="text" class="form-control" name="' + k + '"/>';
-          elmt += '</div>';
-        }
-
-        settings += elmt;
-      });
-
-      $('#settings').prepend(settings);
-      $('#settings').submit(function(e) {
-        var obj = {};
-
-        FORM_SETTINGS.forEach(function(k) {
-          var elmt = $('#settings input[name="' + k + '"]');
-
-          if (elmt.attr('type') === 'radio') {
-            obj[k] = elmt.filter(':checked').val();
-          } else {
-            obj[k] = elmt.val();
-          }
-        });
-
-        // pretty hacky. whatever.
-        obj.device_ids = _.map(
-          $('.selectize-control .option'),
-          function(x) {
-            return $(x).data('value')
-          }
-        );
-
-        $.ajax(
-          "/settings",
-          {
-            method: 'put',
-            contentType: 'application/json',
-            data: JSON.stringify(obj)
-          }
-        );
-
-        e.preventDefault();
-        return false;
-      });
-
-      $('#gateway-server-form').submit(function(e) {
-        saveGatewayConfigs();
-        e.preventDefault();
-        return false;
-      });
-
-      $('.field-help').each(function() {
-        var elmt = $('<i></i>')
-          .addClass('glyphicon glyphicon-question-sign')
-          .tooltip({
-            placement: 'top',
-            title: $(this).data('help-text'),
-            container: 'body'
-          });
-        $(this).append(elmt);
-      });
-
-      $('#updates-btn').click(handleCheckForUpdates);
-
-      loadSettings();
-      updateModeOptions();
-    });
-  </script>
+  <link rel="stylesheet" href="style.css" />
+  <script src="bundle.js"></script>
 
   <div id="update-firmware-modal" class="modal fade" role="dialog">
     <div class="modal-dialog">

+ 14 - 1
lib/WebServer/MiLightHttpServer.cpp

@@ -7,11 +7,12 @@
 #include <GithubClient.h>
 #include <string.h>
 #include <TokenIterator.h>
+#include <index.html.gz.h>
 
 void MiLightHttpServer::begin() {
   applySettings(settings);
 
-  server.on("/", HTTP_GET, handleServeFile(WEB_INDEX_FILENAME, "text/html", DEFAULT_INDEX_PAGE));
+  server.on("/", HTTP_GET, handleServe_P(index_html_gz, index_html_gz_len));
   server.on("/settings", HTTP_GET, handleServeFile(SETTINGS_FILE, APPLICATION_JSON));
   server.on("/settings", HTTP_PUT, [this]() { handleUpdateSettings(); });
   server.on("/settings", HTTP_POST, [this]() { server.send_P(200, TEXT_PLAIN, PSTR("success. rebooting")); ESP.restart(); }, handleUpdateFile(SETTINGS_FILE));
@@ -436,3 +437,15 @@ void MiLightHttpServer::handleSendRaw(const UrlTokenBindings* bindings) {
 
   server.send(200, TEXT_PLAIN, "true");
 }
+
+ESP8266WebServer::THandlerFunction MiLightHttpServer::handleServe_P(const char* data, size_t length) {
+  return [this, data, length]() {
+    server.sendHeader("Content-Encoding", "gzip");
+    server.sendHeader("Content-Length", String(length));
+    server.setContentLength(CONTENT_LENGTH_UNKNOWN);
+    server.send(200, "text/html", "");
+    server.setContentLength(length);
+    server.sendContent_P(data, length);
+    server.client().stop();
+  };
+}

+ 1 - 0
lib/WebServer/MiLightHttpServer.h

@@ -39,6 +39,7 @@ protected:
 
   bool serveFile(const char* file, const char* contentType = "text/html");
   ESP8266WebServer::THandlerFunction handleUpdateFile(const char* filename);
+  ESP8266WebServer::THandlerFunction handleServe_P(const char* data, size_t length);
   void applySettings(Settings& settings);
 
   void handleUpdateSettings();

+ 1 - 1
platformio.ini

@@ -18,7 +18,7 @@ lib_deps_external =
   ArduinoJson
   PubSubClient
   https://github.com/ratkins/RGBConverter
-build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200
+build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -Idist
 # -D MQTT_DEBUG
 # -D MILIGHT_UDP_DEBUG
 # -D DEBUG_PRINTF

+ 1 - 0
src/main.cpp

@@ -16,6 +16,7 @@
 #include <RGBConverter.h>
 #include <MiLightDiscoveryServer.h>
 #include <MiLightClient.h>
+#include <index.html.gz.h>
 
 WiFiManager wifiManager;
 

+ 65 - 0
web/gulpfile.js

@@ -0,0 +1,65 @@
+const fs = require('fs');
+const gulp = require('gulp');
+const htmlmin = require('gulp-htmlmin');
+const cleancss = require('gulp-clean-css');
+const uglify = require('gulp-uglify');
+const gzip = require('gulp-gzip');
+const del = require('del');
+const inline = require('gulp-inline');
+const inlineImages = require('gulp-css-base64');
+const favicon = require('gulp-base64-favicon');
+
+const dataFolder = 'build/';
+
+gulp.task('clean', function() {
+    del([ dataFolder + '*']);
+    return true;
+});
+
+gulp.task('buildfs_embeded', ['buildfs_inline'], function() {
+
+    var source = dataFolder + 'index.html.gz';
+    var destination = dataFolder + 'index.html.gz.h';
+
+    var wstream = fs.createWriteStream(destination);
+    wstream.on('error', function (err) {
+        console.log(err);
+    });
+
+    var data = fs.readFileSync(source);
+
+    wstream.write('#define index_html_gz_len ' + (data.length+1) + '\n');
+    wstream.write('const char index_html_gz[] PROGMEM = {')
+
+    for (i=0; i<data.length; i++) {
+        wstream.write(data[i].toString());
+        if (i<data.length-1) wstream.write(',');
+    }
+
+    wstream.write('};')
+    wstream.end();
+
+    del();
+
+});
+
+gulp.task('buildfs_inline', ['clean'], function() {
+    return gulp.src('src/*.html')
+        // .pipe(favicon())
+        .pipe(inline({
+            base: 'src/',
+            js: uglify,
+            css: [cleancss, inlineImages],
+            disabledTypes: ['svg', 'img']
+        }))
+        .pipe(htmlmin({
+            collapseWhitespace: true,
+            removeComments: true,
+            minifyCSS: true,
+            minifyJS: true
+        }))
+        .pipe(gzip())
+        .pipe(gulp.dest(dataFolder));
+})
+
+gulp.task('default', ['buildfs_embeded']);

+ 23 - 0
web/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "esp8266-milight-hub-web",
+  "version": "1.0.0",
+  "description": "",
+  "main": "gulpfile.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "devDependencies": {
+    "del": "^2.2.1",
+    "gulp": "^3.9.1",
+    "gulp-base64-favicon": "^1.0.2",
+    "gulp-clean-css": "^3.4.2",
+    "gulp-css-base64": "^1.3.4",
+    "gulp-gzip": "^1.4.0",
+    "gulp-htmlmin": "^2.0.0",
+    "gulp-inline": "^0.1.1",
+    "gulp-uglify": "^1.5.3"
+  }
+}

+ 103 - 0
web/src/css/style.css

@@ -0,0 +1,103 @@
+.header-row { border-bottom: 1px solid #ccc; }
+label { display: block; }
+.radio-option { padding: 0 5px; cursor: pointer; }
+.command-buttons { list-style: none; margin: 0; padding: 0; }
+.command-buttons li { display: inline-block; margin-right: 1em; }
+.form-entry { margin: 0 0 20px 0; }
+.form-entry .form-control { width: 20em; }
+.form-entry label { display: inline-block; }
+label:not(.error) .error-info { display: none; }
+.error-info { color: #f00; font-size: 0.7em; }
+.error-info:before { content: '('; }
+.error-info:after { content: ')'; }
+.header-btn { margin: 20px; }
+#sniffed-traffic { max-height: 50em; overflow-y: auto; }
+.btn-secondary {
+  background-color: #fff;
+  border: 1px solid #ccc;
+}
+.inline { display: inline-block; }
+.white-temp-picker {
+  height: 2em;
+  background: linear-gradient(to right,
+    rgb(166, 209, 255) 0%,
+    rgb(255, 255, 255) 50%,
+    rgb(255, 160, 0) 100%
+  );
+  display: inline-block;
+  padding: 3px 0;
+}
+.hue-picker {
+  height: 2em;
+  width: 100%;
+  display: block;
+}
+.hue-picker-inner {
+  height: 2em;
+  width: calc(100% - 3em);
+  display: inline-block;
+  cursor: pointer;
+  background: linear-gradient(to right,
+    rgb(255, 0, 0) 0%,
+    rgb(255, 255, 0) 16.6667%,
+    rgb(0, 255, 0) 33.3333%,
+    rgb(0, 255, 255) 50%,
+    rgb(0, 0, 255) 66.6667%,
+    rgb(255, 0, 255) 83.3333%,
+    rgb(255, 0, 0) 100%
+  );
+}
+.hue-value-display {
+  border: 1px solid #000;
+  margin-left: 0.5em;
+  width: 2em;
+  height: 2em;
+  display: inline-block;
+}
+.plus-minus-group {
+  overflow: auto;
+  width: 100%;
+  clear: both;
+  display: block;
+}
+.plus-minus-group button:first-of-type {
+  border-bottom-right-radius: 0;
+  border-top-right-radius: 0;
+  float: left;
+  display: block;
+}
+.plus-minus-group button:last-of-type {
+  border-bottom-left-radius: 0;
+  border-top-left-radius: 0;
+  display: block;
+}
+.plus-minus-group .title {
+  border-width: 1px 0;
+  border-color: #ccc;
+  border-style: solid;
+  padding: 5px 5px 5px 7px;
+  margin: 0;
+  height: 34px;
+  line-height: 1.49;
+  float: left;
+  display: block;
+}
+.field-help {
+  display: inline-block;
+  font-size: 1.25em;
+  margin-left: 0.5em;
+}
+.glyphicon.spinning {
+  animation: spin 1s infinite linear;
+  -webkit-animation: spin2 1s infinite linear;
+}
+
+@keyframes spin {
+  from { transform: scale(1) rotate(0deg); }
+  to { transform: scale(1) rotate(360deg); }
+}
+
+@-webkit-keyframes spin2 {
+  from { -webkit-transform: rotate(0deg); }
+  to { -webkit-transform: rotate(360deg); }
+}

+ 442 - 0
web/src/index.html

@@ -0,0 +1,442 @@
+<!doctype html>
+
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+
+  <title>MiLight Hub</title>
+  <meta name="description" content="The HTML5 Herald">
+  <meta name="author" content="SitePoint">
+
+  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"/>
+  <link href="https://gitcdn.github.io/bootstrap-toggle/2.2.2/css/bootstrap-toggle.min.css" rel="stylesheet"/>
+  <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.7.0/css/bootstrap-slider.min.css" rel="stylesheet"/>
+  <link href="https://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.12.4/css/selectize.bootstrap3.min.css" rel="stylesheet"/>
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <!--[if lt IE 9]>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.js"></script>
+  <![endif]-->
+  <link href="css/style.css" rel="stylesheet" />
+</head>
+
+<body>
+  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
+  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
+  <script src="https://gitcdn.github.io/bootstrap-toggle/2.2.2/js/bootstrap-toggle.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.7.0/bootstrap-slider.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.12.4/js/standalone/selectize.min.js"></script>
+
+  <script src="js/script.js" lang="text/javascript"></script>
+
+  <div id="update-firmware-modal" class="modal fade" role="dialog">
+    <div class="modal-dialog">
+      <!-- Modal content-->
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal">&times;</button>
+          <h2 class="modal-title">Update Firmware</h2>
+        </div>
+        <div class="modal-body">
+          <div class="alert alert-warning">
+            <p>
+            Download firmware binaries from the
+            <a href="https://github.com/sidoh/esp8266_milight_hub/releases">GitHub releases page</a>.
+            Check for a new version by clicking on the "Check for Updates" button.
+            </p>
+
+            <p>
+              <b>Make sure the binary you're uploading was compiled for your board!</b>
+              Firmware with incompatible settings could prevent boots. If this happens,
+              reflash the board with USB.
+            </p>
+          </div>
+          <form action="/firmware" method="post" enctype="multipart/form-data">
+            <input type="file" name="file"/>
+            <p>&nbsp;</p>
+            <input type="submit" name="submit" class="btn btn-success"/>
+          </form>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+        </div>
+      </div>
+
+    </div>
+  </div>
+
+  <div id="updates-modal" class="modal fade" role="dialog">
+    <div class="modal-dialog">
+      <!-- Modal content-->
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal">&times;</button>
+          <h2 class="modal-title">Update</h2>
+        </div>
+        <div class="modal-body">
+          <div id="version-summary"></div>
+
+          <hr />
+
+          <h4>Current Version</h4>
+          <div id="current-version"></div>
+
+          <div id="latest-version">
+            <h4>Latest Version</h4>
+            <div class="status"></div>
+            <div id="latest-version-info">
+              <label>Version</label>
+              <div class="info-key" data-key="tag_name"></div>
+
+              <label>Release Date</label>
+              <div class="info-key" data-key="published_at"></div>
+
+              <label>Release Notes</label>
+              <pre class="info-key" data-key="body"></pre>
+
+              <div>
+                <a class="info-key" data-prop="href" data-key="html_url">View on GitHub</a>
+              </div>
+
+              <div>
+                <a class="info-key" id="firmware-link">Download Firmware</a>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <a href="/download_update/web" class="btn btn-primary">Update Web UI</a>
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+        </div>
+      </div>
+
+    </div>
+  </div>
+
+  <div id="restore-settings-modal" class="modal fade" role="dialog">
+    <div class="modal-dialog">
+      <!-- Modal content-->
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal">&times;</button>
+          <h2 class="modal-title">Restore Settings</h2>
+        </div>
+        <div class="modal-body">
+          <form action="/settings" method="post" enctype="multipart/form-data">
+            <input type="file" name="file"/>
+            <p>&nbsp;</p>
+            <input type="submit" name="submit" class="btn btn-success"/>
+          </form>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+        </div>
+      </div>
+
+    </div>
+  </div>
+
+  <div class="container">
+    <div class="row header-row">
+      <div class="col-sm-12">
+        <h1>
+          Control Lights
+        </h1>
+      </div>
+    </div>
+
+    <div>&nbsp;</div>
+
+    <div class="row">
+      <div class="col-sm-4">
+        <label for="deviceId" id="device-id-label">
+          Device Id
+          <span class="error-info"></span>
+        </label>
+        <select id="deviceId" placeholder="Enter hub ID">
+				</select>
+      </div>
+
+      <div class="col-sm-3">
+        <div class="mode-option" id="group-option" data-for="cct,rgbw,rgb_cct">
+          <label for="groupId">Group</label>
+
+          <div class="btn-group" id="groupId" data-toggle="buttons">
+            <label class="btn btn-secondary active">
+              <input type="radio" name="options" autocomplete="off" data-value="1" checked> 1
+            </label>
+            <label class="btn btn-secondary">
+              <input type="radio" name="options" autocomplete="off" data-value="2"> 2
+            </label>
+            <label class="btn btn-secondary">
+              <input type="radio" name="options" autocomplete="off" data-value="3"> 3
+            </label>
+            <label class="btn btn-secondary">
+              <input type="radio" name="options" autocomplete="off" data-value="4"> 4
+            </label>
+            <label class="btn btn-secondary">
+              <input type="radio" name="options" autocomplete="off" data-value="0"> All
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <div class="col-sm-4">
+        <label for="groupId">Mode</label>
+
+        <div class="btn-group" id="mode" data-toggle="buttons">
+          <label class="btn btn-secondary active">
+            <input type="radio" name="mode" autocomplete="off" data-value="rgbw" checked> RGBW
+          </label>
+          <label class="btn btn-secondary">
+            <input type="radio" name="mode" autocomplete="off" data-value="cct"> CCT
+          </label>
+          <label class="btn btn-secondary">
+            <input type="radio" name="mode" autocomplete="off" data-value="rgb_cct"> RGB+CCT
+          </label>
+          <label class="btn btn-secondary">
+            <input type="radio" name="mode" autocomplete="off" data-value="rgb"> RGB
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <div class="row"><div class="col-sm-12">
+    <div class="mode-option" data-for="rgbw,rgb_cct,rgb">
+      <div class="row">
+        <div class="col-sm-12">
+          <h5>Hue</h5>
+        </div>
+      </div>
+
+      <div class="row">
+        <div class="col-sm-6">
+          <span class="hue-picker">
+            <span class="hue-picker-inner"></span>
+            <span class="hue-value-display"></span>
+          </span>
+        </div>
+      </div>
+    </div>
+    </div></div>
+
+    <div class="mode-option" data-for="rgb_cct">
+      <div class="row">
+        <div class="col-sm-12">
+          <h5>Saturation</h5>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-sm-6">
+          <input class="slider raw-update" name="saturation"
+              data-slider-min="0"
+              data-slider-max="100"
+              data-slider-value="100"
+          />
+        </div>
+      </div>
+    </div>
+
+    <div class="mode-option" data-for="cct,rgb_cct">
+      <div class="row">
+        <div class="col-sm-12">
+          <h5>Color Temperature</h5>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-sm-6">
+          <div class="white-temp-picker">
+            <input class="slider raw-update" name="temperature"
+                data-slider-min="0"
+                data-slider-max="100"
+                data-slider-value="100"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <h5>Brightness</h5>
+      </div>
+    </div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <input class="slider raw-update" name="level"
+            data-slider-min="0"
+            data-slider-max="100"
+            data-slider-value="100"
+        />
+      </div>
+    </div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <h5>Commands</h5>
+      </div>
+    </div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <ul class="command-buttons">
+          <li>
+            <input type="checkbox" name="status" class="raw-update" data-toggle="toggle" checked/>
+          </li>
+          <div class="mode-option inline" data-for="rgbw,rgb_cct">
+            <li>
+              <button type="button" class="btn btn-secondary command-btn" data-command="set_white">White</button>
+            </li>
+          </div>
+          <li>
+            <button type="button" class="btn btn-success command-btn" data-command="pair">
+              <i class="glyphicon glyphicon-plus"></i>
+              Pair
+            </button>
+          </li>
+          <li>
+            <button type="button" class="btn btn-danger command-btn" data-command="unpair">
+              <i class="glyphicon glyphicon-remove"></i>
+              Unpair
+            </button>
+          </li>
+        </ul>
+        <p></p>
+        <ul class="command-buttons">
+          <div class="mode-option inline" data-for="rgb,rgbw,rgb_cct">
+            <li>
+              <div class="plus-minus-group">
+                <button type="button" class="btn btn-default btn-number command-btn" data-command="previous_mode">
+                  <span class="glyphicon glyphicon-minus"></span>
+                </button>
+                <span class="title">Mode</span>
+                <button type="button" class="btn btn-default btn-number command-btn clearfix" data-command="next_mode">
+                  <span class="glyphicon glyphicon-plus"></span>
+                </button>
+                <div class="clearfix"></div>
+              </div>
+            </li>
+          </div>
+          <div class="mode-option inline" data-for="rgb,rgbw,rgb_cct">
+            <li>
+              <div class="plus-minus-group">
+                <button type="button" class="btn btn-default btn-number command-btn" data-command="mode_speed_down">
+                  <span class="glyphicon glyphicon-minus"></span>
+                </button>
+                <span class="title">Speed</span>
+                <button type="button" class="btn btn-default btn-number command-btn" data-command="mode_speed_up">
+                  <span class="glyphicon glyphicon-plus"></span>
+                </button>
+                <div class="clearfix"></div>
+              </div>
+            </li>
+          </div>
+        </ul>
+      </div>
+    </div>
+
+    <div class="row header-row">
+      <div class="col col-sm-10">
+        <h1>Gateway Servers</h1>
+      </div>
+
+      <div class="col col-sm-2">
+        <button class="btn btn-success header-btn" id="add-server-btn">
+          <i class="glyphicon glyphicon-plus"></i>
+          Add Server
+        </button>
+      </div>
+    </div>
+
+    <div class="row">
+      <div class="col col-sm-12">
+        <form id="gateway-server-form">
+          <table class="table">
+            <thead>
+              <tr>
+                <th>Device ID</th>
+                <th>UDP Port</th>
+                <th>Protocol Version</th>
+              </tr>
+            </thead>
+            <tbody id="gateway-server-configs">
+            </tbody>
+          </table>
+          <input type="submit" class="btn btn-success" value="Save" />
+        </form>
+      </div>
+    </div>
+
+    <div>&nbsp;</div>
+
+    <div class="row header-row">
+      <div class="col-sm-12">
+        <h1>Settings</h1>
+      </div>
+    </div>
+
+    <div>&nbsp;</div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <form action="#" id="settings">
+          <input type="submit" class="btn btn-success" value="Submit" />
+        </form>
+      </div>
+    </div>
+
+    <div class="row header-row">
+      <div class="col-sm-12">
+        <h1>Sniff Traffic</h1>
+      </div>
+    </div>
+
+    <div>&nbsp;</div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <button type="button" id="sniff" class="btn btn-primary">Start Sniffing</button>
+
+        <div> &nbsp; </div>
+
+        <div id="sniffed-traffic"></div>
+      </div>
+    </div>
+
+    <div class="row header-row">
+      <div class="col-sm-12">
+        <h1>Admin</h1>
+      </div>
+    </div>
+
+    <div>&nbsp;</div>
+
+    <div class="row">
+      <div class="col-sm-12">
+        <button type="button" class="btn btn-danger system-btn" data-command="restart">
+          Restart
+        </button>
+
+        <button type="button" class="btn btn-danger system-btn" data-command="clear_wifi_config">
+          Clear Wifi Config
+        </button>
+
+        <button type="button" id="updates-btn" class="btn btn-primary">
+          Check for Updates
+        </button>
+
+        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#update-firmware-modal">
+          Update Firmware
+        </button>
+
+        <a href="/settings" download="settings.json" class="btn btn-primary">Backup Settings</a>
+
+        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#restore-settings-modal">
+          Restore Settings
+        </button>
+      </div>
+    </div>
+  </div>
+</body>
+</html>

+ 519 - 0
web/src/js/script.js

@@ -0,0 +1,519 @@
+var FORM_SETTINGS = [
+  "admin_username", "admin_password", "ce_pin", "csn_pin", "reset_pin","packet_repeats",
+  "http_repeat_factor", "auto_restart_period", "discovery_port", "mqtt_server",
+  "mqtt_topic_pattern", "mqtt_update_topic_pattern", "mqtt_username", "mqtt_password",
+  "radio_interface_type", "listen_repeats"
+];
+
+var FORM_SETTINGS_HELP = {
+  ce_pin : "'CE' for NRF24L01 interface, and 'PKT' for 'PL1167/LT8900' interface",
+  packet_repeats : "The number of times to repeat RF packets sent to bulbs",
+  http_repeat_factor : "Multiplicative factor on packet_repeats for " +
+    "requests initiated by the HTTP API. UDP API typically receives " +
+    "duplicate packets, so more repeats should be used for HTTP.",
+  auto_restart_period : "Automatically restart the device every number of " +
+    "minutes specified. Use 0 for disabled.",
+  radio_interface_type : "2.4 GHz radio model. Only change this if you know " +
+    "You're not using an NRF24L01!",
+  mqtt_server : "Domain or IP address of MQTT broker. Optionally specify a port " +
+    "with (example) mymqqtbroker.com:1884.",
+  mqtt_topic_pattern : "Pattern for MQTT topics to listen on. Example: " +
+    "lights/:device_id/:device_type/:group_id. See README for further details.",
+  mqtt_update_topic_pattern : "Pattern to publish MQTT updates. Packets that " +
+    "are received from other devices, and packets that are sent from this device will " +
+    "result in updates being sent.",
+  discovery_port : "UDP port to listen for discovery packets on. Defaults to " +
+    "the same port used by MiLight devices, 48899. Use 0 to disable.",
+  listen_repeats : "Increasing this increases the amount of time spent listening for " +
+    "packets. Set to 0 to disable listening. Default is 3."
+}
+
+var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
+var DEFAULT_UDP_PROTOCL_VERSION = 5;
+
+var selectize;
+
+var toHex = function(v) {
+  return "0x" + (v).toString(16).toUpperCase();
+}
+
+var activeUrl = function() {
+  var deviceId = $('#deviceId option:selected').val()
+    , groupId = $('#groupId .active input').data('value')
+    , mode = getCurrentMode();
+
+  if (deviceId == "") {
+    alert("Please enter a device ID.");
+    throw "Must enter device ID";
+  }
+
+  if (! $('#group-option').data('for').split(',').includes(mode)) {
+    groupId = 0;
+  }
+
+  return "/gateways/" + deviceId + "/" + mode + "/" + groupId;
+}
+
+var getCurrentMode = function() {
+  return $('input[name="mode"]:checked').data('value');
+};
+
+var updateGroup = _.throttle(
+  function(params) {
+    $.ajax(
+      activeUrl(),
+      {
+        method: 'PUT',
+        data: JSON.stringify(params),
+        contentType: 'application/json'
+      }
+    );
+  },
+  1000
+);
+
+var sendCommand = _.throttle(
+  function(params) {
+    $.ajax(
+      '/system',
+      {
+        method: 'POST',
+        data: JSON.stringify(params),
+        contentType: 'application/json'
+      }
+    );
+  },
+  1000
+)
+
+var sniffRequest;
+var sniffing = false;
+var getTraffic = function() {
+  sniffRequest = $.get('/gateway_traffic', function(data) {
+    $('#sniffed-traffic').prepend('<pre>' + data + '</pre>');
+    getTraffic();
+  });
+};
+
+var gatewayServerRow = function(deviceId, port, version) {
+  var elmt = '<tr>';
+  elmt += '<td>';
+  elmt += '<input name="deviceIds[]" class="form-control" value="' + deviceId + '"/>';
+  elmt += '</td>';
+  elmt += '<td>'
+  elmt += '<input name="ports[]" class="form-control" value="' + port + '"/>';;
+  elmt += '</td>';
+  elmt += '<td>';
+  elmt += '<div class="btn-group" data-toggle="buttons">';
+
+  for (var i = 0; i < UDP_PROTOCOL_VERSIONS.length; i++) {
+    var val = UDP_PROTOCOL_VERSIONS[i]
+      , selected = (version == val || (val == DEFAULT_UDP_PROTOCL_VERSION && !UDP_PROTOCOL_VERSIONS.includes(version)));
+
+    elmt += '<label class="btn btn-secondary' + (selected ? ' active' : '') + '">';
+    elmt += '<input type="radio" name="versions[]" autocomplete="off" data-value="' + val + '" '
+      + (selected ? 'checked' : '') +'> ' + val;
+    elmt += '</label>';
+  }
+
+  elmt += '</div></td>';
+  elmt += '<td>';
+  elmt += '<button class="btn btn-danger remove-gateway-server">';
+  elmt += '<i class="glyphicon glyphicon-remove"></i>';
+  elmt += '</button>';
+  elmt += '</td>';
+  elmt += '</tr>';
+  return elmt;
+}
+
+var loadSettings = function() {
+  $.getJSON('/settings', function(val) {
+    Object.keys(val).forEach(function(k) {
+      var field = $('#settings input[name="' + k + '"]');
+
+      if (field.length > 0) {
+        if (field.attr('type') === 'radio') {
+          field.filter('[value="' + val[k] + '"]').click();
+        } else {
+          field.val(val[k]);
+        }
+      }
+    });
+
+    if (val.device_ids) {
+      selectize.clearOptions();
+      val.device_ids.forEach(function(v) {
+        selectize.addOption({text: toHex(v), value: v});
+      });
+      selectize.refreshOptions();
+    }
+
+    var gatewayForm = $('#gateway-server-configs').html('');
+    if (val.gateway_configs) {
+      val.gateway_configs.forEach(function(v) {
+        gatewayForm.append(gatewayServerRow(toHex(v[0]), v[1], v[2]));
+      });
+    }
+  });
+};
+
+var saveGatewayConfigs = function() {
+  var form = $('#gateway-server-form')
+    , errors = false;
+
+  $('input', form).removeClass('error');
+
+  var deviceIds = $('input[name="deviceIds[]"]', form).map(function(i, v) {
+    var val = $(v).val();
+
+    if (isNaN(val)) {
+      errors = true;
+      $(v).addClass('error');
+      return null;
+    } else {
+      return val;
+    }
+  });
+
+  var ports = $('input[name="ports[]"]', form).map(function(i, v) {
+    var val = $(v).val();
+
+    if (isNaN(val)) {
+      errors = true;
+      $(v).addClass('error');
+      return null;
+    } else {
+      return val;
+    }
+  });
+
+  var versions = $('.active input[name="versions[]"]', form).map(function(i, v) {
+    return $(v).data('value');
+  });
+
+  if (!errors) {
+    var data = [];
+    for (var i = 0; i < deviceIds.length; i++) {
+      data[i] = [deviceIds[i], ports[i], versions[i]];
+    }
+    $.ajax(
+      '/settings',
+      {
+        method: 'put',
+        contentType: 'application/json',
+        data: JSON.stringify({gateway_configs: data})
+      }
+    )
+  }
+};
+
+var deviceIdError = function(v) {
+  if (!v) {
+    $('#device-id-label').removeClass('error');
+  } else {
+    $('#device-id-label').addClass('error');
+    $('#device-id-label .error-info').html(v);
+  }
+};
+
+var updateModeOptions = function() {
+  var currentMode = getCurrentMode();
+
+  $('.mode-option').map(function() {
+    if ($(this).data('for').split(',').includes(currentMode)) {
+      $(this).show();
+    } else {
+      $(this).hide();
+    }
+  });
+};
+
+var parseVersion = function(v) {
+  var matches = v.match(/(\d+)\.(\d+)\.(\d+)(-(.*))?/);
+
+  return {
+    major: matches[1],
+    minor: matches[2],
+    patch: matches[3],
+    revision: matches[5],
+    parts: [matches[1], matches[2], matches[3], matches[5]]
+  };
+};
+
+var isNewerVersion = function(a, b) {
+  var va = parseVersion(a)
+    , vb = parseVersion(b);
+
+  return va.parts > vb.parts;
+};
+
+var handleCheckForUpdates = function() {
+  var currentVersion = null
+    , latestRelease = null;
+
+  var handleReceiveData = function() {
+    if (currentVersion != null) {
+      $('#current-version').html(currentVersion.version + " (" + currentVersion.variant + ")");
+    }
+
+    if (latestRelease != null) {
+      $('#latest-version .info-key').each(function() {
+        var value = latestRelease[$(this).data('key')];
+        var prop = $(this).data('prop');
+
+        if (prop) {
+          $(this).prop(prop, value);
+        } else {
+          $(this).html(value);
+        }
+      });
+    }
+
+    if (currentVersion != null && latestRelease != null) {
+      $('#latest-version .status').html('');
+      $('#latest-version-info').show();
+
+      var summary;
+      if (isNewerVersion(latestRelease.tag_name, currentVersion.version)) {
+        summary = "New version available!";
+      } else {
+        summary = "You're on the most recent version.";
+      }
+      $('#version-summary').html(summary);
+
+      var releaseAsset = latestRelease.assets.filter(function(x) {
+        return x.name.indexOf(currentVersion.variant) != -1;
+      });
+
+      if (releaseAsset.length > 0) {
+        console.log(releaseAsset[0].url);
+        $('#firmware-link').prop('href', releaseAsset[0].browser_download_url);
+      }
+    }
+
+    console.log(latestRelease);
+  }
+
+  var handleError = function(e, d) {
+    console.log(e);
+    console.log(d);
+  }
+
+  $('#current-version,#latest-version .status').html('<i class="spinning glyphicon glyphicon-refresh"></i>');
+  $('#version-summary').html('');
+  $('#latest-version-info').hide();
+  $('#updates-modal').modal();
+
+  $.ajax(
+    '/about',
+    {
+      success: function(data) {
+        currentVersion = JSON.parse(data);
+        handleReceiveData();
+      },
+      failure: handleError
+    }
+  );
+
+  $.ajax(
+    '/latest_release',
+    {
+      success: function(data) {
+        latestRelease = data;
+        handleReceiveData();
+      },
+      failure: handleError
+    }
+  );
+};
+
+$(function() {
+  $('.radio-option').click(function() {
+    $(this).prev().prop('checked', true);
+  });
+
+  var hueDragging = false;
+  var colorUpdated = function(e) {
+    var x = e.pageX - $(this).offset().left
+      , pct = x/(1.0*$(this).width())
+      , hue = Math.round(360*pct)
+      ;
+
+    $('.hue-value-display').css({
+      backgroundColor: "hsl(" + hue + ",100%,50%)"
+    });
+
+    updateGroup({hue: hue});
+  };
+
+  $('.hue-picker-inner')
+    .mousedown(function(e) {
+      hueDragging = true;
+      colorUpdated.call(this, e);
+    })
+    .mouseup(function(e) {
+      hueDragging = false;
+    })
+    .mouseout(function(e) {
+      hueDragging = false;
+    })
+    .mousemove(function(e) {
+      if (hueDragging) {
+        colorUpdated.call(this, e);
+      }
+    });
+
+  $('.slider').slider();
+
+  $('.raw-update').change(function() {
+    var data = {}
+      , val = $(this).attr('type') == 'checkbox' ? $(this).is(':checked') : $(this).val()
+      ;
+
+    data[$(this).attr('name')] = val;
+    updateGroup(data);
+  });
+
+  $('.command-btn').click(function() {
+    updateGroup({command: $(this).data('command')});
+  });
+
+  $('.system-btn').click(function() {
+    sendCommand({command: $(this).data('command')});
+  });
+
+  $('#sniff').click(function() {
+    if (sniffing) {
+      sniffRequest.abort();
+      sniffing = false;
+      $(this).html('Start Sniffing');
+    } else {
+      sniffing = true;
+      getTraffic();
+      $(this).html('Stop Sniffing');
+    }
+  });
+
+  $('#add-server-btn').click(function() {
+    $('#gateway-server-configs').append(gatewayServerRow('', ''));
+  });
+
+  $('#mode').change(updateModeOptions);
+
+  $('body').on('click', '.remove-gateway-server', function() {
+    $(this).closest('tr').remove();
+  });
+
+  selectize = $('#deviceId').selectize({
+    create: true,
+    sortField: 'text',
+    onOptionAdd: function(v, item) {
+      item.value = parseInt(item.value);
+    },
+    createFilter: function(v) {
+      if (! v.match(/^(0x[a-fA-F0-9]{1,4}|[0-9]{1,5})$/)) {
+        deviceIdError("Must be an integer between 0x0000 and 0xFFFF");
+        return false;
+      }
+
+      var value = parseInt(v);
+
+      if (! (0 <= v && v <= 0xFFFF)) {
+        deviceIdError("Must be an integer between 0x0000 and 0xFFFF");
+        return false;
+      }
+
+      deviceIdError(false);
+
+      return true;
+    }
+  });
+  selectize = selectize[0].selectize;
+
+  var settings = "";
+
+  FORM_SETTINGS.forEach(function(k) {
+    var elmt = '<div class="form-entry">';
+    elmt += '<div>';
+    elmt += '<label for="' + k + '">' + k + '</label>';
+
+    if (FORM_SETTINGS_HELP[k]) {
+      elmt += '<div class="field-help" data-help-text="' + FORM_SETTINGS_HELP[k] + '"></div>';
+    }
+
+    elmt += '</div>';
+
+    if(k === "radio_interface_type") {
+      elmt += '<div class="btn-group" id="radio_interface_type" data-toggle="buttons">' +
+        '<label class="btn btn-secondary active">' +
+          '<input type="radio" id="nrf24" name="radio_interface_type" autocomplete="off" value="nRF24" /> nRF24' +
+        '</label>'+
+        '<label class="btn btn-secondary">' +
+          '<input type="radio" id="lt8900" name="radio_interface_type" autocomplete="off" value="LT8900" /> PL1167/LT8900' +
+        '</label>' +
+      '</div>';
+    } else {
+      elmt += '<input type="text" class="form-control" name="' + k + '"/>';
+      elmt += '</div>';
+    }
+
+    settings += elmt;
+  });
+
+  $('#settings').prepend(settings);
+  $('#settings').submit(function(e) {
+    var obj = {};
+
+    FORM_SETTINGS.forEach(function(k) {
+      var elmt = $('#settings input[name="' + k + '"]');
+
+      if (elmt.attr('type') === 'radio') {
+        obj[k] = elmt.filter(':checked').val();
+      } else {
+        obj[k] = elmt.val();
+      }
+    });
+
+    // pretty hacky. whatever.
+    obj.device_ids = _.map(
+      $('.selectize-control .option'),
+      function(x) {
+        return $(x).data('value')
+      }
+    );
+
+    $.ajax(
+      "/settings",
+      {
+        method: 'put',
+        contentType: 'application/json',
+        data: JSON.stringify(obj)
+      }
+    );
+
+    e.preventDefault();
+    return false;
+  });
+
+  $('#gateway-server-form').submit(function(e) {
+    saveGatewayConfigs();
+    e.preventDefault();
+    return false;
+  });
+
+  $('.field-help').each(function() {
+    var elmt = $('<i></i>')
+      .addClass('glyphicon glyphicon-question-sign')
+      .tooltip({
+        placement: 'top',
+        title: $(this).data('help-text'),
+        container: 'body'
+      });
+    $(this).append(elmt);
+  });
+
+  $('#updates-btn').click(handleCheckForUpdates);
+
+  loadSettings();
+  updateModeOptions();
+});