Przeglądaj źródła

v1.7.0 release (#275)

* Fix build issues on Windows (#225)

* Added 'Night' button (#226)

* Improve state debugging (#228)

 nice debugging code to help debug state changes

* Switched to using library ESP8266WebServer (#231)

* Switched to using library ESP8266WebServer

* Refactored to share authentication logic,

* Add LED status to indicate wifi condition and packet handling (#241)

* LED status feature

* Switch to new git for wifimanager

* LED flashing class

* Added F(), fixed tabs/{}, enum class

* re-generate webpage

* disable group state debugging

* Enable night mode for all types

* Handle inconsistency on saturation/temp on v2 types (#227)

* Handle inconsistency on saturation/temp on v2 types
* Changed temp/sat to be optional, refacted for settings now in formatters
* When not mode switching, switch to white on update temp
* Added debug to print version number
* Reverted back to no longer using state for saturation changes
* Fixed refactored union member name (Data to rawData)

* remove commented out code (#243)

* Remove outdated instruction

* No longer need to use beta version of Arduino SDK

* whitespace

* Use consistent flag

* Add method to check if bulb is on

* Clear night mode when changing status

* Debug printing

* Check if bulb is on before applying state

* Better night mode handling

* well, that was silly

* fix javascript error

* add options to include device_id, device_type, and group_id in state updates

* Acknowledgement :)

* document new fields

* Added UI support for enable_solid_led

* Implemented support for blinking vs. solid LED

* Updated README to reflect enable_solid_led and general improvements.

* Fix next/prev mode for RGBW

* Use correct name for white mode command (fixes #249)

* styling

* Measure R/G/B relative distance instead of distance from max (addresses #258)

* No transitivity with abs

* Updated UI and LED support

* Fix wrong parsed command for RGBW (fixes #260)

* Fixed MQQT to MQTT!

* Fixed "tab-MQTT" to "tab-mqtt" for consistent style

* add generated webpage

* Add correct default

* De-select unselectable groups when changing modes

* regen page

* Throw alert when group not selected

* put nav in navbar

* Shorten tab title

* Keep track of state for increment commands that have hard boundaries

* comments

* Update UI screenshot

* dont set content length manually

* more accurate hints for config fields

* set cct bulb id correctly on on/off command

ignore group id when setting bulb id in favour of defined group id against the on/off command code

* dont set content length manually 2
Chris Mullins 7 lat temu
rodzic
commit
0342315924
47 zmienionych plików z 1898 dodań i 2124 usunięć
  1. 17 6
      .build_web.py
  2. 3 0
      .gitignore
  3. 25 11
      README.md
  4. 2 2
      dist/index.html.gz.h
  5. 0 36
      lib/ESP8266WebServer/keywords.txt
  6. 0 9
      lib/ESP8266WebServer/library.properties
  7. 0 534
      lib/ESP8266WebServer/src/ESP8266WebServer.cpp
  8. 0 181
      lib/ESP8266WebServer/src/ESP8266WebServer.h
  9. 0 589
      lib/ESP8266WebServer/src/Parsing.cpp
  10. 0 19
      lib/ESP8266WebServer/src/detail/RequestHandler.h
  11. 0 150
      lib/ESP8266WebServer/src/detail/RequestHandlersImpl.h
  12. 225 0
      lib/LEDStatus/LEDStatus.cpp
  13. 49 0
      lib/LEDStatus/LEDStatus.h
  14. 1 1
      lib/MQTT/BulbStateUpdater.cpp
  15. 13 5
      lib/MiLight/CctPacketFormatter.cpp
  16. 1 1
      lib/MiLight/CctPacketFormatter.h
  17. 41 3
      lib/MiLight/FUT089PacketFormatter.cpp
  18. 4 4
      lib/MiLight/FUT089PacketFormatter.h
  19. 88 19
      lib/MiLight/MiLightClient.cpp
  20. 8 10
      lib/MiLight/MiLightClient.h
  21. 5 3
      lib/MiLight/MiLightRemoteConfig.cpp
  22. 27 8
      lib/MiLight/PacketFormatter.cpp
  23. 13 3
      lib/MiLight/PacketFormatter.h
  24. 39 4
      lib/MiLight/RgbCctPacketFormatter.cpp
  25. 1 1
      lib/MiLight/RgbCctPacketFormatter.h
  26. 1 1
      lib/MiLight/RgbPacketFormatter.cpp
  27. 1 1
      lib/MiLight/RgbPacketFormatter.h
  28. 9 6
      lib/MiLight/RgbwPacketFormatter.cpp
  29. 3 5
      lib/MiLight/RgbwPacketFormatter.h
  30. 23 0
      lib/MiLight/V2PacketFormatter.cpp
  31. 1 0
      lib/MiLight/V2PacketFormatter.h
  32. 279 23
      lib/MiLightState/GroupState.cpp
  33. 61 8
      lib/MiLightState/GroupState.h
  34. 13 1
      lib/MiLightState/GroupStateStore.cpp
  35. 2 0
      lib/MiLightState/GroupStateStore.h
  36. 26 0
      lib/Settings/Settings.cpp
  37. 17 1
      lib/Settings/Settings.h
  38. 8 2
      lib/Types/GroupStateField.h
  39. 80 69
      lib/WebServer/MiLightHttpServer.cpp
  40. 6 2
      lib/WebServer/MiLightHttpServer.h
  41. 48 65
      lib/WebServer/WebServer.cpp
  42. 7 9
      lib/WebServer/WebServer.h
  43. 5 3
      platformio.ini
  44. 51 10
      src/main.cpp
  45. 63 2
      web/src/css/style.css
  46. 221 190
      web/src/index.html
  47. 411 127
      web/src/js/script.js

+ 17 - 6
.build_web.py

@@ -1,5 +1,5 @@
 from shutil import copyfile
-from subprocess import check_output
+from subprocess import check_output, CalledProcessError
 import sys
 import os
 import platform
@@ -20,14 +20,25 @@ def build_web():
         os.chdir("web")
         print("Attempting to build webpage...")
         try:
-            print check_output(["npm", "install"])
-            print check_output(["node_modules/.bin/gulp"])
+            if platform.system() == "Windows":
+                print check_output(["npm.cmd", "install", "--only=dev"])
+                print check_output(["node_modules\\.bin\\gulp.cmd"])
+            else:
+                print check_output(["npm", "install"])
+                print check_output(["node_modules/.bin/gulp"])
             copyfile("build/index.html.gz.h", "../dist/index.html.gz.h")
-
+        except OSError as e:
+            print "Encountered error OSError building webpage:", e
+            if e.filename:
+                print "Filename is", e.filename
+            print "WARNING: Failed to build web package. Using pre-built page."
+        except CalledProcessError as e:
+            print e.output
+            print "Encountered error CalledProcessError building webpage:", e
+            print "WARNING: Failed to build web package. Using pre-built page."
         except Exception as e:
-            print "Encountered error building webpage: ", e
+            print "Encountered error", type(e).__name__, "building webpage:", e
             print "WARNING: Failed to build web package. Using pre-built page."
-            pass
         finally:
             os.chdir("..");
 

+ 3 - 0
.gitignore

@@ -6,3 +6,6 @@
 /web/build
 /dist/*.bin
 .vscode/
+.vscode/.browse.c_cpp.db*
+.vscode/c_cpp_properties.json
+.vscode/launch.json

+ 25 - 11
README.md

@@ -58,19 +58,13 @@ platformio run -e $ESP_BOARD --target upload
 
 Of course make sure to substitute `nodemcuv2` with the board that you're using.
 
-**Note that currently you'll need to use the beta version of PlatformIO.**  To install with pip:
-
-```
-pip install -U https://github.com/platformio/platformio-core/archive/develop.zip
-```
-
 You can find pre-compiled firmware images on the [releases](https://github.com/sidoh/esp8266_milight_hub/releases).
 
 #### Configure WiFi
 
 This project uses [WiFiManager](https://github.com/tzapu/WiFiManager) to avoid the need to hardcode AP credentials in the firmware.
 
-When the ESP powers on, you should be able to see a network named "ESPXXXXX", with XXXXX being an identifier for your ESP. Connect to this AP and a window should pop up prompting you to enter WiFi credentials.
+When the ESP powers on, you should be able to see a network named "ESPXXXXX", with XXXXX being an identifier for your ESP. Connect to this AP and a window should pop up prompting you to enter WiFi credentials.  If your board has a built-in LED (or you wire up an LED), it will [flash to indicate the status](#led-status).
 
 The network password is "**milightHub**".
 
@@ -86,11 +80,31 @@ Both mDNS and SSDP are supported.
 
 The HTTP endpoints (shown below) will be fully functional at this point. You should also be able to navigate to `http://<ip_of_esp>`, or `http://milight-hub.local` if your client supports mDNS. The UI should look like this:
 
-![Web UI](http://imgur.com/XNNigvL.png)
+![Web UI](https://user-images.githubusercontent.com/589893/39412360-0d95ab2e-4bd0-11e8-915c-7fef7ee38761.png)
+
+## LED Status
+
+Some ESP boards have a built-in LED, on pin #2.  This LED will flash to indicate the current status of the hub:
+
+* Wifi not configured: Fast flash (on/off once per second).  See [Configure Wifi](#configure-wifi) to configure the hub.
+* Wifi connected and ready: Occasional blips of light (a flicker of light every 1.5 seconds).
+* Packets sending/receiving: Rapid blips of light for brief periods (three rapid flashes).
+* Wifi failed to configure: Solid light.
+
+In the setup UI, you can turn on "enable_solid_led" to change the LED behavior to:
+
+* Wifi connected and ready: Solid LED light
+* Wifi failed to configure: Light off
+
+Note that you must restart the hub to affect the change in "enable_solid_led".
+
+You can configure the LED pin from the web console.  Note that pin means the GPIO number, not the D number ... for example, D2 is actually GPIO4 and therefore its pin 4.  If you specify the pin as a negative number, it will invert the LED signal (the built-in LED on pin 2 is inverted, so the default is -2).
+
+If you want to wire up your own LED on a pin, such as on D2/GPIO4, put a wire from D2 to one side of a 220 ohm resister.  On the other side, connect it to the positive side (the longer wire) of a 3.3V LED.  Then connect the negative side of the LED (the shorter wire) to ground.  If you use a different voltage LED, or a high current LED, you will need to add a driver circuit.
 
 ## REST endpoints
 
-1. `GET /`. Opens web UI. 
+1. `GET /`. Opens web UI.
 1. `GET /about`. Return information about current firmware version.
 1. `POST /system`. Post commands in the form `{"comamnd": <command>}`. Currently supports the commands: `restart`.
 1. `POST /firmware`. OTA firmware update.
@@ -233,6 +247,7 @@ You can select which fields should be included in state updates by configuring t
 1. `kelvin / color_temp` - [0, 100] and [153, 370] scales for the same value.  The later's unit is mireds.
 1. `bulb_mode` - what mode the bulb is in: white, rgb, etc.
 1. `color` / `computed_color` - behaves the same when bulb is in rgb mode.  `computed_color` will send RGB = 255,255,255 when in white mode.  This is useful for HomeAssistant where it always expects the color to be set.
+1. `device_id` / `device_type` / `group_id` - this information is in the MQTT topic or REST route, but can be included in the payload in the case that processing the topic or route is more difficult.
 
 ## UDP Gateways
 
@@ -243,8 +258,7 @@ You can select between versions 5 and 6 of the UDP protocol (documented [here](h
 ## Acknowledgements
 
 * @WoodsterDK added support for LT8900 radios.
-
-
+* @cmidgley contributed many substantial features to the 1.7 release.
 
 [info-license]:   https://github.com/sidoh/esp8266_milight_hub/blob/master/LICENSE
 [shield-license]: https://img.shields.io/badge/license-MIT-blue.svg

Plik diff jest za duży
+ 2 - 2
dist/index.html.gz.h


+ 0 - 36
lib/ESP8266WebServer/keywords.txt

@@ -1,36 +0,0 @@
-#######################################
-# Syntax Coloring Map For Ultrasound
-#######################################
-
-#######################################
-# Datatypes (KEYWORD1)
-#######################################
-
-ESP8266WebServer	KEYWORD1
-HTTPMethod	KEYWORD1
-
-#######################################
-# Methods and Functions (KEYWORD2)
-#######################################
-
-begin	KEYWORD2
-handleClient	KEYWORD2
-on	KEYWORD2
-addHandler	KEYWORD2
-uri	KEYWORD2
-method	KEYWORD2
-client	KEYWORD2
-send	KEYWORD2
-arg	KEYWORD2
-argName	KEYWORD2
-args	KEYWORD2
-hasArg	KEYWORD2
-onNotFound	KEYWORD2
-
-#######################################
-# Constants (LITERAL1)
-#######################################
-
-HTTP_GET	LITERAL1
-HTTP_POST	LITERAL1
-HTTP_ANY	LITERAL1

+ 0 - 9
lib/ESP8266WebServer/library.properties

@@ -1,9 +0,0 @@
-name=ESP8266WebServer
-version=1.0
-author=Ivan Grokhotkov
-maintainer=Ivan Grokhtkov <ivan@esp8266.com>
-sentence=Simple web server library
-paragraph=The library supports HTTP GET and POST requests, provides argument parsing, handles one client at a time.
-category=Communication
-url=
-architectures=esp8266

+ 0 - 534
lib/ESP8266WebServer/src/ESP8266WebServer.cpp

@@ -1,534 +0,0 @@
-/*
-  ESP8266WebServer.cpp - Dead simple web-server.
-  Supports only one simultaneous client, knows how to handle GET and POST.
-
-  Copyright (c) 2014 Ivan Grokhotkov. All rights reserved.
-
-  This library is free software; you can redistribute it and/or
-  modify it under the terms of the GNU Lesser General Public
-  License as published by the Free Software Foundation; either
-  version 2.1 of the License, or (at your option) any later version.
-
-  This library is distributed in the hope that it will be useful,
-  but WITHOUT ANY WARRANTY; without even the implied warranty of
-  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-  Lesser General Public License for more details.
-
-  You should have received a copy of the GNU Lesser General Public
-  License along with this library; if not, write to the Free Software
-  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
-  Modified 8 May 2015 by Hristo Gochkov (proper post and file upload handling)
-*/
-
-
-#include <Arduino.h>
-#include <libb64/cencode.h>
-#include "WiFiServer.h"
-#include "WiFiClient.h"
-#include "ESP8266WebServer.h"
-#include "FS.h"
-#include "detail/RequestHandlersImpl.h"
-
-//#define DEBUG_ESP_HTTP_SERVER
-#ifdef DEBUG_ESP_PORT
-#define DEBUG_OUTPUT DEBUG_ESP_PORT
-#else
-#define DEBUG_OUTPUT Serial
-#endif
-
-const char * AUTHORIZATION_HEADER = "Authorization";
-
-ESP8266WebServer::ESP8266WebServer(IPAddress addr, int port)
-: _server(addr, port)
-, _currentMethod(HTTP_ANY)
-, _currentHandler(0)
-, _firstHandler(0)
-, _lastHandler(0)
-, _currentArgCount(0)
-, _currentArgs(0)
-, _headerKeysCount(0)
-, _currentHeaders(0)
-, _contentLength(0)
-{
-}
-
-ESP8266WebServer::ESP8266WebServer(int port)
-: _server(port)
-, _currentMethod(HTTP_ANY)
-, _currentHandler(0)
-, _firstHandler(0)
-, _lastHandler(0)
-, _currentArgCount(0)
-, _currentArgs(0)
-, _headerKeysCount(0)
-, _currentHeaders(0)
-, _contentLength(0)
-{
-}
-
-ESP8266WebServer::~ESP8266WebServer() {
-  if (_currentHeaders)
-    delete[]_currentHeaders;
-  _headerKeysCount = 0;
-  RequestHandler* handler = _firstHandler;
-  while (handler) {
-    RequestHandler* next = handler->next();
-    delete handler;
-    handler = next;
-  }
-  close();
-}
-
-void ESP8266WebServer::begin() {
-  _currentStatus = HC_NONE;
-  _server.begin();
-  if(!_headerKeysCount)
-    collectHeaders(0, 0);
-}
-
-bool ESP8266WebServer::authenticate(const char * username, const char * password){
-  if(hasHeader(AUTHORIZATION_HEADER)){
-    String authReq = header(AUTHORIZATION_HEADER);
-    if(authReq.startsWith("Basic")){
-      authReq = authReq.substring(6);
-      authReq.trim();
-      char toencodeLen = strlen(username)+strlen(password)+1;
-      char *toencode = new char[toencodeLen + 1];
-      if(toencode == NULL){
-        authReq = String();
-        return false;
-      }
-      char *encoded = new char[base64_encode_expected_len(toencodeLen)+1];
-      if(encoded == NULL){
-        authReq = String();
-        delete[] toencode;
-        return false;
-      }
-      sprintf(toencode, "%s:%s", username, password);
-      if(base64_encode_chars(toencode, toencodeLen, encoded) > 0 && authReq.equals(encoded)){
-        authReq = String();
-        delete[] toencode;
-        delete[] encoded;
-        return true;
-      }
-      delete[] toencode;
-      delete[] encoded;
-    }
-    authReq = String();
-  }
-  return false;
-}
-
-void ESP8266WebServer::requestAuthentication(){
-  sendHeader("WWW-Authenticate", "Basic realm=\"Login Required\"");
-  send(401);
-}
-
-void ESP8266WebServer::on(const char* uri, ESP8266WebServer::THandlerFunction handler) {
-  on(uri, HTTP_ANY, handler);
-}
-
-void ESP8266WebServer::on(const char* uri, HTTPMethod method, ESP8266WebServer::THandlerFunction fn) {
-  on(uri, method, fn, _fileUploadHandler);
-}
-
-void ESP8266WebServer::on(const char* uri, HTTPMethod method, ESP8266WebServer::THandlerFunction fn, ESP8266WebServer::THandlerFunction ufn) {
-  _addRequestHandler(new FunctionRequestHandler(fn, ufn, uri, method));
-}
-
-void ESP8266WebServer::addHandler(RequestHandler* handler) {
-    _addRequestHandler(handler);
-}
-
-void ESP8266WebServer::_addRequestHandler(RequestHandler* handler) {
-    if (!_lastHandler) {
-      _firstHandler = handler;
-      _lastHandler = handler;
-    }
-    else {
-      _lastHandler->next(handler);
-      _lastHandler = handler;
-    }
-}
-
-void ESP8266WebServer::serveStatic(const char* uri, FS& fs, const char* path, const char* cache_header) {
-    _addRequestHandler(new StaticRequestHandler(fs, path, uri, cache_header));
-}
-
-void ESP8266WebServer::handleClient() {
-  if (_currentStatus == HC_NONE) {
-    WiFiClient client = _server.available();
-    if (!client) {
-      return;
-    }
-
-#ifdef DEBUG_ESP_HTTP_SERVER
-    DEBUG_OUTPUT.println("New client");
-#endif
-
-    _currentClient = client;
-    _currentStatus = HC_WAIT_READ;
-    _statusChange = millis();
-  }
-
-  if (!_currentClient.connected()) {
-    _currentClient = WiFiClient();
-    _currentStatus = HC_NONE;
-    return;
-  }
-
-  // Wait for data from client to become available
-  if (_currentStatus == HC_WAIT_READ) {
-    if (!_currentClient.available()) {
-      if (millis() - _statusChange > HTTP_MAX_DATA_WAIT) {
-        _currentClient = WiFiClient();
-        _currentStatus = HC_NONE;
-      }
-      yield();
-      return;
-    }
-
-    if (!_parseRequest(_currentClient)) {
-      _currentClient = WiFiClient();
-      _currentStatus = HC_NONE;
-      return;
-    }
-
-    _contentLength = CONTENT_LENGTH_NOT_SET;
-    _handleRequest();
-
-    if (!_currentClient.connected()) {
-      _currentClient = WiFiClient();
-      _currentStatus = HC_NONE;
-      return;
-    } else {
-      _currentStatus = HC_WAIT_CLOSE;
-      _statusChange = millis();
-      return;
-    }
-  }
-
-  if (_currentStatus == HC_WAIT_CLOSE) {
-    if (millis() - _statusChange > HTTP_MAX_CLOSE_WAIT) {
-      _currentClient = WiFiClient();
-      _currentStatus = HC_NONE;
-    } else {
-      yield();
-      return;
-    }
-  }
-}
-
-void ESP8266WebServer::close() {
-  _server.close();
-}
-
-void ESP8266WebServer::stop() {
-  close();
-}
-
-void ESP8266WebServer::sendHeader(const String& name, const String& value, bool first) {
-  String headerLine = name;
-  headerLine += ": ";
-  headerLine += value;
-  headerLine += "\r\n";
-
-  if (first) {
-    _responseHeaders = headerLine + _responseHeaders;
-  }
-  else {
-    _responseHeaders += headerLine;
-  }
-}
-
-
-void ESP8266WebServer::_prepareHeader(String& response, int code, const char* content_type, size_t contentLength) {
-    response = "HTTP/1.1 ";
-    response += String(code);
-    response += " ";
-    response += _responseCodeToString(code);
-    response += "\r\n";
-
-    if (!content_type)
-        content_type = "text/html";
-
-    sendHeader("Content-Type", content_type, true);
-    if (_contentLength == CONTENT_LENGTH_NOT_SET) {
-        sendHeader("Content-Length", String(contentLength));
-    } else if (_contentLength != CONTENT_LENGTH_UNKNOWN) {
-        sendHeader("Content-Length", String(_contentLength));
-    }
-    sendHeader("Connection", "close");
-    sendHeader("Access-Control-Allow-Origin", "*");
-
-    response += _responseHeaders;
-    response += "\r\n";
-    _responseHeaders = String();
-}
-
-void ESP8266WebServer::send(int code, const char* content_type, const String& content) {
-    String header;
-    _prepareHeader(header, code, content_type, content.length());
-    sendContent(header);
-
-    sendContent(content);
-}
-
-void ESP8266WebServer::send_P(int code, PGM_P content_type, PGM_P content) {
-    size_t contentLength = 0;
-
-    if (content != NULL) {
-        contentLength = strlen_P(content);
-    }
-
-    String header;
-    char type[64];
-    memccpy_P((void*)type, (PGM_VOID_P)content_type, 0, sizeof(type));
-    _prepareHeader(header, code, (const char* )type, contentLength);
-    sendContent(header);
-    sendContent_P(content);
-}
-
-void ESP8266WebServer::send_P(int code, PGM_P content_type, PGM_P content, size_t contentLength) {
-    String header;
-    char type[64];
-    memccpy_P((void*)type, (PGM_VOID_P)content_type, 0, sizeof(type));
-    _prepareHeader(header, code, (const char* )type, contentLength);
-    sendContent(header);
-    sendContent_P(content, contentLength);
-}
-
-void ESP8266WebServer::send(int code, char* content_type, const String& content) {
-  send(code, (const char*)content_type, content);
-}
-
-void ESP8266WebServer::send(int code, const String& content_type, const String& content) {
-  send(code, (const char*)content_type.c_str(), content);
-}
-
-void ESP8266WebServer::sendContent(const String& content) {
-  const size_t unit_size = HTTP_DOWNLOAD_UNIT_SIZE;
-  size_t size_to_send = content.length();
-  const char* send_start = content.c_str();
-
-  while (size_to_send) {
-    size_t will_send = (size_to_send < unit_size) ? size_to_send : unit_size;
-    size_t sent = _currentClient.write(send_start, will_send);
-    if (sent == 0) {
-      break;
-    }
-    size_to_send -= sent;
-    send_start += sent;
-  }
-}
-
-void ESP8266WebServer::sendContent_P(PGM_P content) {
-    char contentUnit[HTTP_DOWNLOAD_UNIT_SIZE + 1];
-
-    contentUnit[HTTP_DOWNLOAD_UNIT_SIZE] = '\0';
-
-    while (content != NULL) {
-        size_t contentUnitLen;
-        PGM_P contentNext;
-
-        // due to the memccpy signature, lots of casts are needed
-        contentNext = (PGM_P)memccpy_P((void*)contentUnit, (PGM_VOID_P)content, 0, HTTP_DOWNLOAD_UNIT_SIZE);
-
-        if (contentNext == NULL) {
-            // no terminator, more data available
-            content += HTTP_DOWNLOAD_UNIT_SIZE;
-            contentUnitLen = HTTP_DOWNLOAD_UNIT_SIZE;
-        }
-        else {
-            // reached terminator. Do not send the terminator
-            contentUnitLen = contentNext - contentUnit - 1;
-            content = NULL;
-        }
-
-        // write is so overloaded, had to use the cast to get it pick the right one
-        _currentClient.write((const char*)contentUnit, contentUnitLen);
-    }
-}
-
-void ESP8266WebServer::sendContent_P(PGM_P content, size_t size) {
-    char contentUnit[HTTP_DOWNLOAD_UNIT_SIZE + 1];
-    contentUnit[HTTP_DOWNLOAD_UNIT_SIZE] = '\0';
-    size_t remaining_size = size;
-
-    while (content != NULL && remaining_size > 0) {
-        size_t contentUnitLen = HTTP_DOWNLOAD_UNIT_SIZE;
-
-        if (remaining_size < HTTP_DOWNLOAD_UNIT_SIZE) contentUnitLen = remaining_size;
-        // due to the memcpy signature, lots of casts are needed
-        memcpy_P((void*)contentUnit, (PGM_VOID_P)content, contentUnitLen);
-
-        content += contentUnitLen;
-        remaining_size -= contentUnitLen;
-
-        // write is so overloaded, had to use the cast to get it pick the right one
-        _currentClient.write((const char*)contentUnit, contentUnitLen);
-    }
-}
-
-
-String ESP8266WebServer::arg(String name) {
-  for (int i = 0; i < _currentArgCount; ++i) {
-    if ( _currentArgs[i].key == name )
-      return _currentArgs[i].value;
-  }
-  return String();
-}
-
-String ESP8266WebServer::arg(int i) {
-  if (i < _currentArgCount)
-    return _currentArgs[i].value;
-  return String();
-}
-
-String ESP8266WebServer::argName(int i) {
-  if (i < _currentArgCount)
-    return _currentArgs[i].key;
-  return String();
-}
-
-int ESP8266WebServer::args() {
-  return _currentArgCount;
-}
-
-bool ESP8266WebServer::hasArg(String  name) {
-  for (int i = 0; i < _currentArgCount; ++i) {
-    if (_currentArgs[i].key == name)
-      return true;
-  }
-  return false;
-}
-
-
-String ESP8266WebServer::header(String name) {
-  for (int i = 0; i < _headerKeysCount; ++i) {
-    if (_currentHeaders[i].key == name)
-      return _currentHeaders[i].value;
-  }
-  return String();
-}
-
-void ESP8266WebServer::collectHeaders(const char* headerKeys[], const size_t headerKeysCount) {
-  _headerKeysCount = headerKeysCount + 1;
-  if (_currentHeaders)
-     delete[]_currentHeaders;
-  _currentHeaders = new RequestArgument[_headerKeysCount];
-  _currentHeaders[0].key = AUTHORIZATION_HEADER;
-  for (int i = 1; i < _headerKeysCount; i++){
-    _currentHeaders[i].key = headerKeys[i-1];
-  }
-}
-
-String ESP8266WebServer::header(int i) {
-  if (i < _headerKeysCount)
-    return _currentHeaders[i].value;
-  return String();
-}
-
-String ESP8266WebServer::headerName(int i) {
-  if (i < _headerKeysCount)
-    return _currentHeaders[i].key;
-  return String();
-}
-
-int ESP8266WebServer::headers() {
-  return _headerKeysCount;
-}
-
-bool ESP8266WebServer::hasHeader(String name) {
-  for (int i = 0; i < _headerKeysCount; ++i) {
-    if ((_currentHeaders[i].key == name) &&  (_currentHeaders[i].value.length() > 0))
-      return true;
-  }
-  return false;
-}
-
-String ESP8266WebServer::hostHeader() {
-  return _hostHeader;
-}
-
-void ESP8266WebServer::onFileUpload(THandlerFunction fn) {
-  _fileUploadHandler = fn;
-}
-
-void ESP8266WebServer::onNotFound(THandlerFunction fn) {
-  _notFoundHandler = fn;
-}
-
-void ESP8266WebServer::_handleRequest() {
-  bool handled = false;
-  if (!_currentHandler){
-#ifdef DEBUG_ESP_HTTP_SERVER
-    DEBUG_OUTPUT.println("request handler not found");
-#endif
-  }
-  else {
-    handled = _currentHandler->handle(*this, _currentMethod, _currentUri);
-#ifdef DEBUG_ESP_HTTP_SERVER
-    if (!handled) {
-      DEBUG_OUTPUT.println("request handler failed to handle request");
-    }
-#endif
-  }
-
-  if (!handled) {
-    if(_notFoundHandler) {
-      _notFoundHandler();
-    }
-    else {
-      send(404, "text/plain", String("Not found: ") + _currentUri);
-    }
-  }
-
-  _currentUri = String();
-}
-
-String ESP8266WebServer::_responseCodeToString(int code) {
-  switch (code) {
-    case 100: return F("Continue");
-    case 101: return F("Switching Protocols");
-    case 200: return F("OK");
-    case 201: return F("Created");
-    case 202: return F("Accepted");
-    case 203: return F("Non-Authoritative Information");
-    case 204: return F("No Content");
-    case 205: return F("Reset Content");
-    case 206: return F("Partial Content");
-    case 300: return F("Multiple Choices");
-    case 301: return F("Moved Permanently");
-    case 302: return F("Found");
-    case 303: return F("See Other");
-    case 304: return F("Not Modified");
-    case 305: return F("Use Proxy");
-    case 307: return F("Temporary Redirect");
-    case 400: return F("Bad Request");
-    case 401: return F("Unauthorized");
-    case 402: return F("Payment Required");
-    case 403: return F("Forbidden");
-    case 404: return F("Not Found");
-    case 405: return F("Method Not Allowed");
-    case 406: return F("Not Acceptable");
-    case 407: return F("Proxy Authentication Required");
-    case 408: return F("Request Time-out");
-    case 409: return F("Conflict");
-    case 410: return F("Gone");
-    case 411: return F("Length Required");
-    case 412: return F("Precondition Failed");
-    case 413: return F("Request Entity Too Large");
-    case 414: return F("Request-URI Too Large");
-    case 415: return F("Unsupported Media Type");
-    case 416: return F("Requested range not satisfiable");
-    case 417: return F("Expectation Failed");
-    case 500: return F("Internal Server Error");
-    case 501: return F("Not Implemented");
-    case 502: return F("Bad Gateway");
-    case 503: return F("Service Unavailable");
-    case 504: return F("Gateway Time-out");
-    case 505: return F("HTTP Version not supported");
-    default:  return "";
-  }
-}

+ 0 - 181
lib/ESP8266WebServer/src/ESP8266WebServer.h

@@ -1,181 +0,0 @@
-/*
-  ESP8266WebServer.h - Dead simple web-server.
-  Supports only one simultaneous client, knows how to handle GET and POST.
-
-  Copyright (c) 2014 Ivan Grokhotkov. All rights reserved.
-
-  This library is free software; you can redistribute it and/or
-  modify it under the terms of the GNU Lesser General Public
-  License as published by the Free Software Foundation; either
-  version 2.1 of the License, or (at your option) any later version.
-
-  This library is distributed in the hope that it will be useful,
-  but WITHOUT ANY WARRANTY; without even the implied warranty of
-  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-  Lesser General Public License for more details.
-
-  You should have received a copy of the GNU Lesser General Public
-  License along with this library; if not, write to the Free Software
-  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
-  Modified 8 May 2015 by Hristo Gochkov (proper post and file upload handling)
-*/
-
-
-#ifndef ESP8266WEBSERVER_H
-#define ESP8266WEBSERVER_H
-
-#include <functional>
-#include <ESP8266WiFi.h>
-
-enum HTTPMethod { HTTP_ANY, HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE, HTTP_OPTIONS };
-enum HTTPUploadStatus { UPLOAD_FILE_START, UPLOAD_FILE_WRITE, UPLOAD_FILE_END,
-                        UPLOAD_FILE_ABORTED };
-enum HTTPClientStatus { HC_NONE, HC_WAIT_READ, HC_WAIT_CLOSE };
-
-#define HTTP_DOWNLOAD_UNIT_SIZE 1460
-#define HTTP_UPLOAD_BUFLEN 20
-#define HTTP_MAX_DATA_WAIT 1000 //ms to wait for the client to send the request
-#define HTTP_MAX_POST_WAIT 1000 //ms to wait for POST data to arrive
-#define HTTP_MAX_CLOSE_WAIT 2000 //ms to wait for the client to close the connection
-
-#define CONTENT_LENGTH_UNKNOWN ((size_t) -1)
-#define CONTENT_LENGTH_NOT_SET ((size_t) -2)
-
-class ESP8266WebServer;
-
-typedef struct {
-  HTTPUploadStatus status;
-  String  filename;
-  String  name;
-  String  type;
-  size_t  totalSize;    // file size
-  size_t  currentSize;  // size of data currently in buf
-  uint8_t buf[HTTP_UPLOAD_BUFLEN];
-} HTTPUpload;
-
-#include "detail/RequestHandler.h"
-
-namespace fs {
-class FS;
-}
-
-class ESP8266WebServer
-{
-public:
-  ESP8266WebServer(IPAddress addr, int port = 80);
-  ESP8266WebServer(int port = 80);
-  ~ESP8266WebServer();
-
-  void begin();
-  void handleClient();
-
-  void close();
-  void stop();
-
-  bool authenticate(const char * username, const char * password);
-  void requestAuthentication();
-
-  typedef std::function<void(void)> THandlerFunction;
-  void on(const char* uri, THandlerFunction handler);
-  void on(const char* uri, HTTPMethod method, THandlerFunction fn);
-  void on(const char* uri, HTTPMethod method, THandlerFunction fn, THandlerFunction ufn);
-  void addHandler(RequestHandler* handler);
-  void serveStatic(const char* uri, fs::FS& fs, const char* path, const char* cache_header = NULL );
-  void onNotFound(THandlerFunction fn);  //called when handler is not assigned
-  void onFileUpload(THandlerFunction fn); //handle file uploads
-
-  String uri() { return _currentUri; }
-  HTTPMethod method() { return _currentMethod; }
-  WiFiClient client() { return _currentClient; }
-  HTTPUpload& upload() { return _currentUpload; }
-
-  String arg(String name);        // get request argument value by name
-  String arg(int i);              // get request argument value by number
-  String argName(int i);          // get request argument name by number
-  int args();                     // get arguments count
-  bool hasArg(String name);       // check if argument exists
-  void collectHeaders(const char* headerKeys[], const size_t headerKeysCount); // set the request headers to collect
-  String header(String name);      // get request header value by name
-  String header(int i);              // get request header value by number
-  String headerName(int i);          // get request header name by number
-  int headers();                     // get header count
-  bool hasHeader(String name);       // check if header exists
-
-  String hostHeader();            // get request host header if available or empty String if not
-
-  // send response to the client
-  // code - HTTP response code, can be 200 or 404
-  // content_type - HTTP content type, like "text/plain" or "image/png"
-  // content - actual content body
-  void send(int code, const char* content_type = NULL, const String& content = String(""));
-  void send(int code, char* content_type, const String& content);
-  void send(int code, const String& content_type, const String& content);
-  void send_P(int code, PGM_P content_type, PGM_P content);
-  void send_P(int code, PGM_P content_type, PGM_P content, size_t contentLength);
-
-  void setContentLength(size_t contentLength) { _contentLength = contentLength; }
-  void sendHeader(const String& name, const String& value, bool first = false);
-  void sendContent(const String& content);
-  void sendContent_P(PGM_P content);
-  void sendContent_P(PGM_P content, size_t size);
-
-  static String urlDecode(const String& text);
-
-template<typename T> size_t streamFile(T &file, const String& contentType){
-  setContentLength(file.size());
-  if (String(file.name()).endsWith(".gz") &&
-      contentType != "application/x-gzip" &&
-      contentType != "application/octet-stream"){
-    sendHeader("Content-Encoding", "gzip");
-  }
-  send(200, contentType, "");
-  return _currentClient.write(file, HTTP_DOWNLOAD_UNIT_SIZE);
-}
-
-protected:
-  void _addRequestHandler(RequestHandler* handler);
-  void _handleRequest();
-  bool _parseRequest(WiFiClient& client);
-  void _parseArguments(String data);
-  static String _responseCodeToString(int code);
-  bool _parseForm(WiFiClient& client, String boundary, uint32_t len);
-  bool _parseFormUploadAborted();
-  void _uploadWriteByte(uint8_t b);
-  uint8_t _uploadReadByte(WiFiClient& client);
-  void _prepareHeader(String& response, int code, const char* content_type, size_t contentLength);
-  bool _collectHeader(const char* headerName, const char* headerValue);
-
-  struct RequestArgument {
-    String key;
-    String value;
-  };
-
-  WiFiServer  _server;
-
-  WiFiClient  _currentClient;
-  HTTPMethod  _currentMethod;
-  String      _currentUri;
-  HTTPClientStatus _currentStatus;
-  unsigned long _statusChange;
-
-  RequestHandler*  _currentHandler;
-  RequestHandler*  _firstHandler;
-  RequestHandler*  _lastHandler;
-  THandlerFunction _notFoundHandler;
-  THandlerFunction _fileUploadHandler;
-
-  int              _currentArgCount;
-  RequestArgument* _currentArgs;
-  HTTPUpload       _currentUpload;
-
-  int              _headerKeysCount;
-  RequestArgument* _currentHeaders;
-  size_t           _contentLength;
-  String           _responseHeaders;
-
-  String           _hostHeader;
-
-};
-
-
-#endif //ESP8266WEBSERVER_H

+ 0 - 589
lib/ESP8266WebServer/src/Parsing.cpp

@@ -1,589 +0,0 @@
-/*
-  Parsing.cpp - HTTP request parsing.
-
-  Copyright (c) 2015 Ivan Grokhotkov. All rights reserved.
-
-  This library is free software; you can redistribute it and/or
-  modify it under the terms of the GNU Lesser General Public
-  License as published by the Free Software Foundation; either
-  version 2.1 of the License, or (at your option) any later version.
-
-  This library is distributed in the hope that it will be useful,
-  but WITHOUT ANY WARRANTY; without even the implied warranty of
-  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-  Lesser General Public License for more details.
-
-  You should have received a copy of the GNU Lesser General Public
-  License along with this library; if not, write to the Free Software
-  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
-  Modified 8 May 2015 by Hristo Gochkov (proper post and file upload handling)
-*/
-
-#include <Arduino.h>
-#include "WiFiServer.h"
-#include "WiFiClient.h"
-#include "ESP8266WebServer.h"
-
-//#define DEBUG_ESP_HTTP_SERVER
-#ifdef DEBUG_ESP_PORT
-#define DEBUG_OUTPUT DEBUG_ESP_PORT
-#else
-#define DEBUG_OUTPUT Serial
-#endif
-
-static char* readBytesWithTimeout(WiFiClient& client, size_t maxLength, size_t& dataLength, int timeout_ms)
-{
-  char *buf = nullptr;
-  dataLength = 0;
-  while (dataLength < maxLength) {
-    int tries = timeout_ms;
-    size_t newLength;
-    while (!(newLength = client.available()) && tries--) delay(1);
-    if (!newLength) {
-      break;
-    }
-    if (!buf) {
-      buf = (char *) malloc(newLength + 1);
-      if (!buf) {
-        return nullptr;
-      }
-    }
-    else {
-      char* newBuf = (char *) realloc(buf, dataLength + newLength + 1);
-      if (!newBuf) {
-        free(buf);
-        return nullptr;
-      }
-      buf = newBuf;
-    }
-    client.readBytes(buf + dataLength, newLength);
-    dataLength += newLength;
-    buf[dataLength] = '\0';
-  }
-  return buf;
-}
-
-bool ESP8266WebServer::_parseRequest(WiFiClient& client) {
-  // Read the first line of HTTP request
-  String req = client.readStringUntil('\r');
-  client.readStringUntil('\n');
-  //reset header value
-  for (int i = 0; i < _headerKeysCount; ++i) {
-    _currentHeaders[i].value =String();
-   }
-
-  // First line of HTTP request looks like "GET /path HTTP/1.1"
-  // Retrieve the "/path" part by finding the spaces
-  int addr_start = req.indexOf(' ');
-  int addr_end = req.indexOf(' ', addr_start + 1);
-  if (addr_start == -1 || addr_end == -1) {
-#ifdef DEBUG_ESP_HTTP_SERVER
-    DEBUG_OUTPUT.print("Invalid request: ");
-    DEBUG_OUTPUT.println(req);
-#endif
-    return false;
-  }
-
-  String methodStr = req.substring(0, addr_start);
-  String url = req.substring(addr_start + 1, addr_end);
-  String searchStr = "";
-  int hasSearch = url.indexOf('?');
-  if (hasSearch != -1){
-    searchStr = url.substring(hasSearch + 1);
-    url = url.substring(0, hasSearch);
-  }
-  _currentUri = url;
-
-  HTTPMethod method = HTTP_GET;
-  if (methodStr == "POST") {
-    method = HTTP_POST;
-  } else if (methodStr == "DELETE") {
-    method = HTTP_DELETE;
-  } else if (methodStr == "OPTIONS") {
-    method = HTTP_OPTIONS;
-  } else if (methodStr == "PUT") {
-    method = HTTP_PUT;
-  } else if (methodStr == "PATCH") {
-    method = HTTP_PATCH;
-  }
-  _currentMethod = method;
-
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("method: ");
-  DEBUG_OUTPUT.print(methodStr);
-  DEBUG_OUTPUT.print(" url: ");
-  DEBUG_OUTPUT.print(url);
-  DEBUG_OUTPUT.print(" search: ");
-  DEBUG_OUTPUT.println(searchStr);
-#endif
-
-  //attach handler
-  RequestHandler* handler;
-  for (handler = _firstHandler; handler; handler = handler->next()) {
-    if (handler->canHandle(_currentMethod, _currentUri))
-      break;
-  }
-  _currentHandler = handler;
-
-  String formData;
-  // below is needed only when POST type request
-  if (method == HTTP_POST || method == HTTP_PUT || method == HTTP_PATCH || method == HTTP_DELETE){
-    String boundaryStr;
-    String headerName;
-    String headerValue;
-    bool isForm = false;
-    uint32_t contentLength = 0;
-    //parse headers
-    while(1){
-      req = client.readStringUntil('\r');
-      client.readStringUntil('\n');
-      if (req == "") break;//no moar headers
-      int headerDiv = req.indexOf(':');
-      if (headerDiv == -1){
-        break;
-      }
-      headerName = req.substring(0, headerDiv);
-      headerValue = req.substring(headerDiv + 1);
-      headerValue.trim();
-       _collectHeader(headerName.c_str(),headerValue.c_str());
-
-	  #ifdef DEBUG_ESP_HTTP_SERVER
-	  DEBUG_OUTPUT.print("headerName: ");
-	  DEBUG_OUTPUT.println(headerName);
-	  DEBUG_OUTPUT.print("headerValue: ");
-	  DEBUG_OUTPUT.println(headerValue);
-	  #endif
-
-      if (headerName == "Content-Type"){
-        if (headerValue.startsWith("text/plain")){
-          isForm = false;
-        } else if (headerValue.startsWith("multipart/form-data")){
-          boundaryStr = headerValue.substring(headerValue.indexOf('=')+1);
-          isForm = true;
-        }
-      } else if (headerName == "Content-Length"){
-        contentLength = headerValue.toInt();
-      } else if (headerName == "Host"){
-        _hostHeader = headerValue;
-      }
-    }
-
-    if (!isForm){
-      size_t plainLength;
-      char* plainBuf = readBytesWithTimeout(client, contentLength, plainLength, HTTP_MAX_POST_WAIT);
-      if (plainLength < contentLength) {
-      	free(plainBuf);
-      	return false;
-      }
-#ifdef DEBUG_ESP_HTTP_SERVER
-      DEBUG_OUTPUT.print("Plain: ");
-      DEBUG_OUTPUT.println(plainBuf);
-#endif
-      if (contentLength > 0) {
-        if (searchStr != "") searchStr += '&';
-        if(plainBuf[0] == '{' || plainBuf[0] == '[' || strstr(plainBuf, "=") == NULL){
-          //plain post json or other data
-          searchStr += "plain=";
-          searchStr += plainBuf;
-        } else {
-          searchStr += plainBuf;
-        }
-        free(plainBuf);
-      }
-    }
-    _parseArguments(searchStr);
-    if (isForm){
-      if (!_parseForm(client, boundaryStr, contentLength)) {
-        return false;
-      }
-    }
-  } else {
-    String headerName;
-    String headerValue;
-    //parse headers
-    while(1){
-      req = client.readStringUntil('\r');
-      client.readStringUntil('\n');
-      if (req == "") break;//no moar headers
-      int headerDiv = req.indexOf(':');
-      if (headerDiv == -1){
-        break;
-      }
-      headerName = req.substring(0, headerDiv);
-      headerValue = req.substring(headerDiv + 2);
-      _collectHeader(headerName.c_str(),headerValue.c_str());
-
-	  #ifdef DEBUG_ESP_HTTP_SERVER
-	  DEBUG_OUTPUT.print("headerName: ");
-	  DEBUG_OUTPUT.println(headerName);
-	  DEBUG_OUTPUT.print("headerValue: ");
-	  DEBUG_OUTPUT.println(headerValue);
-	  #endif
-
-	  if (headerName == "Host"){
-        _hostHeader = headerValue;
-      }
-    }
-    _parseArguments(searchStr);
-  }
-  client.flush();
-
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("Request: ");
-  DEBUG_OUTPUT.println(url);
-  DEBUG_OUTPUT.print(" Arguments: ");
-  DEBUG_OUTPUT.println(searchStr);
-#endif
-
-  return true;
-}
-
-bool ESP8266WebServer::_collectHeader(const char* headerName, const char* headerValue) {
-  for (int i = 0; i < _headerKeysCount; i++) {
-    if (_currentHeaders[i].key==headerName) {
-            _currentHeaders[i].value=headerValue;
-            return true;
-        }
-  }
-  return false;
-}
-
-void ESP8266WebServer::_parseArguments(String data) {
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("args: ");
-  DEBUG_OUTPUT.println(data);
-#endif
-  if (_currentArgs)
-    delete[] _currentArgs;
-  _currentArgs = 0;
-  if (data.length() == 0) {
-    _currentArgCount = 0;
-    return;
-  }
-  _currentArgCount = 1;
-
-  for (int i = 0; i < (int)data.length(); ) {
-    i = data.indexOf('&', i);
-    if (i == -1)
-      break;
-    ++i;
-    ++_currentArgCount;
-  }
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("args count: ");
-  DEBUG_OUTPUT.println(_currentArgCount);
-#endif
-
-  _currentArgs = new RequestArgument[_currentArgCount];
-  int pos = 0;
-  int iarg;
-  for (iarg = 0; iarg < _currentArgCount;) {
-    int equal_sign_index = data.indexOf('=', pos);
-    int next_arg_index = data.indexOf('&', pos);
-#ifdef DEBUG_ESP_HTTP_SERVER
-    DEBUG_OUTPUT.print("pos ");
-    DEBUG_OUTPUT.print(pos);
-    DEBUG_OUTPUT.print("=@ ");
-    DEBUG_OUTPUT.print(equal_sign_index);
-    DEBUG_OUTPUT.print(" &@ ");
-    DEBUG_OUTPUT.println(next_arg_index);
-#endif
-    if ((equal_sign_index == -1) || ((equal_sign_index > next_arg_index) && (next_arg_index != -1))) {
-#ifdef DEBUG_ESP_HTTP_SERVER
-      DEBUG_OUTPUT.print("arg missing value: ");
-      DEBUG_OUTPUT.println(iarg);
-#endif
-      if (next_arg_index == -1)
-        break;
-      pos = next_arg_index + 1;
-      continue;
-    }
-    RequestArgument& arg = _currentArgs[iarg];
-    arg.key = data.substring(pos, equal_sign_index);
-	arg.value = urlDecode(data.substring(equal_sign_index + 1, next_arg_index));
-#ifdef DEBUG_ESP_HTTP_SERVER
-    DEBUG_OUTPUT.print("arg ");
-    DEBUG_OUTPUT.print(iarg);
-    DEBUG_OUTPUT.print(" key: ");
-    DEBUG_OUTPUT.print(arg.key);
-    DEBUG_OUTPUT.print(" value: ");
-    DEBUG_OUTPUT.println(arg.value);
-#endif
-    ++iarg;
-    if (next_arg_index == -1)
-      break;
-    pos = next_arg_index + 1;
-  }
-  _currentArgCount = iarg;
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("args count: ");
-  DEBUG_OUTPUT.println(_currentArgCount);
-#endif
-
-}
-
-void ESP8266WebServer::_uploadWriteByte(uint8_t b){
-  if (_currentUpload.currentSize == HTTP_UPLOAD_BUFLEN){
-    if(_currentHandler && _currentHandler->canUpload(_currentUri))
-      _currentHandler->upload(*this, _currentUri, _currentUpload);
-    _currentUpload.totalSize += _currentUpload.currentSize;
-    _currentUpload.currentSize = 0;
-  }
-  _currentUpload.buf[_currentUpload.currentSize++] = b;
-}
-
-uint8_t ESP8266WebServer::_uploadReadByte(WiFiClient& client){
-  int res = client.read();
-  if(res == -1){
-    while(!client.available() && client.connected())
-      yield();
-    res = client.read();
-  }
-  return (uint8_t)res;
-}
-
-bool ESP8266WebServer::_parseForm(WiFiClient& client, String boundary, uint32_t len){
-
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("Parse Form: Boundary: ");
-  DEBUG_OUTPUT.print(boundary);
-  DEBUG_OUTPUT.print(" Length: ");
-  DEBUG_OUTPUT.println(len);
-#endif
-  String line;
-  int retry = 0;
-  do {
-    line = client.readStringUntil('\r');
-    ++retry;
-  } while (line.length() == 0 && retry < 3);
-
-  client.readStringUntil('\n');
-  //start reading the form
-  if (line == ("--"+boundary)){
-    RequestArgument* postArgs = new RequestArgument[32];
-    int postArgsLen = 0;
-    while(1){
-      String argName;
-      String argValue;
-      String argType;
-      String argFilename;
-      bool argIsFile = false;
-
-      line = client.readStringUntil('\r');
-      client.readStringUntil('\n');
-      if (line.startsWith("Content-Disposition")){
-        int nameStart = line.indexOf('=');
-        if (nameStart != -1){
-          argName = line.substring(nameStart+2);
-          nameStart = argName.indexOf('=');
-          if (nameStart == -1){
-            argName = argName.substring(0, argName.length() - 1);
-          } else {
-            argFilename = argName.substring(nameStart+2, argName.length() - 1);
-            argName = argName.substring(0, argName.indexOf('"'));
-            argIsFile = true;
-#ifdef DEBUG_ESP_HTTP_SERVER
-            DEBUG_OUTPUT.print("PostArg FileName: ");
-            DEBUG_OUTPUT.println(argFilename);
-#endif
-            //use GET to set the filename if uploading using blob
-            if (argFilename == "blob" && hasArg("filename")) argFilename = arg("filename");
-          }
-#ifdef DEBUG_ESP_HTTP_SERVER
-          DEBUG_OUTPUT.print("PostArg Name: ");
-          DEBUG_OUTPUT.println(argName);
-#endif
-          argType = "text/plain";
-          line = client.readStringUntil('\r');
-          client.readStringUntil('\n');
-          if (line.startsWith("Content-Type")){
-            argType = line.substring(line.indexOf(':')+2);
-            //skip next line
-            client.readStringUntil('\r');
-            client.readStringUntil('\n');
-          }
-#ifdef DEBUG_ESP_HTTP_SERVER
-          DEBUG_OUTPUT.print("PostArg Type: ");
-          DEBUG_OUTPUT.println(argType);
-#endif
-          if (!argIsFile){
-            while(1){
-              line = client.readStringUntil('\r');
-              client.readStringUntil('\n');
-              if (line.startsWith("--"+boundary)) break;
-              if (argValue.length() > 0) argValue += "\n";
-              argValue += line;
-            }
-#ifdef DEBUG_ESP_HTTP_SERVER
-            DEBUG_OUTPUT.print("PostArg Value: ");
-            DEBUG_OUTPUT.println(argValue);
-            DEBUG_OUTPUT.println();
-#endif
-
-            RequestArgument& arg = postArgs[postArgsLen++];
-            arg.key = argName;
-            arg.value = argValue;
-
-            if (line == ("--"+boundary+"--")){
-#ifdef DEBUG_ESP_HTTP_SERVER
-              DEBUG_OUTPUT.println("Done Parsing POST");
-#endif
-              break;
-            }
-          } else {
-            _currentUpload.status = UPLOAD_FILE_START;
-            _currentUpload.name = argName;
-            _currentUpload.filename = argFilename;
-            _currentUpload.type = argType;
-            _currentUpload.totalSize = 0;
-            _currentUpload.currentSize = 0;
-#ifdef DEBUG_ESP_HTTP_SERVER
-            DEBUG_OUTPUT.print("Start File: ");
-            DEBUG_OUTPUT.print(_currentUpload.filename);
-            DEBUG_OUTPUT.print(" Type: ");
-            DEBUG_OUTPUT.println(_currentUpload.type);
-#endif
-            if(_currentHandler && _currentHandler->canUpload(_currentUri))
-              _currentHandler->upload(*this, _currentUri, _currentUpload);
-            _currentUpload.status = UPLOAD_FILE_WRITE;
-            uint8_t argByte = _uploadReadByte(client);
-readfile:
-            while(argByte != 0x0D){
-              if (!client.connected()) return _parseFormUploadAborted();
-              _uploadWriteByte(argByte);
-              argByte = _uploadReadByte(client);
-            }
-
-            argByte = _uploadReadByte(client);
-            if (!client.connected()) return _parseFormUploadAborted();
-            if (argByte == 0x0A){
-              argByte = _uploadReadByte(client);
-              if (!client.connected()) return _parseFormUploadAborted();
-              if ((char)argByte != '-'){
-                //continue reading the file
-                _uploadWriteByte(0x0D);
-                _uploadWriteByte(0x0A);
-                goto readfile;
-              } else {
-                argByte = _uploadReadByte(client);
-                if (!client.connected()) return _parseFormUploadAborted();
-                if ((char)argByte != '-'){
-                  //continue reading the file
-                  _uploadWriteByte(0x0D);
-                  _uploadWriteByte(0x0A);
-                  _uploadWriteByte((uint8_t)('-'));
-                  goto readfile;
-                }
-              }
-
-              uint8_t endBuf[boundary.length()];
-              client.readBytes(endBuf, boundary.length());
-
-              if (strstr((const char*)endBuf, boundary.c_str()) != NULL){
-                if(_currentHandler && _currentHandler->canUpload(_currentUri))
-                  _currentHandler->upload(*this, _currentUri, _currentUpload);
-                _currentUpload.totalSize += _currentUpload.currentSize;
-                _currentUpload.status = UPLOAD_FILE_END;
-                if(_currentHandler && _currentHandler->canUpload(_currentUri))
-                  _currentHandler->upload(*this, _currentUri, _currentUpload);
-#ifdef DEBUG_ESP_HTTP_SERVER
-                DEBUG_OUTPUT.print("End File: ");
-                DEBUG_OUTPUT.print(_currentUpload.filename);
-                DEBUG_OUTPUT.print(" Type: ");
-                DEBUG_OUTPUT.print(_currentUpload.type);
-                DEBUG_OUTPUT.print(" Size: ");
-                DEBUG_OUTPUT.println(_currentUpload.totalSize);
-#endif
-                line = client.readStringUntil(0x0D);
-                client.readStringUntil(0x0A);
-                if (line == "--"){
-#ifdef DEBUG_ESP_HTTP_SERVER
-                  DEBUG_OUTPUT.println("Done Parsing POST");
-#endif
-                  break;
-                }
-                continue;
-              } else {
-                _uploadWriteByte(0x0D);
-                _uploadWriteByte(0x0A);
-                _uploadWriteByte((uint8_t)('-'));
-                _uploadWriteByte((uint8_t)('-'));
-                uint32_t i = 0;
-                while(i < boundary.length()){
-                  _uploadWriteByte(endBuf[i++]);
-                }
-                argByte = _uploadReadByte(client);
-                goto readfile;
-              }
-            } else {
-              _uploadWriteByte(0x0D);
-              goto readfile;
-            }
-            break;
-          }
-        }
-      }
-    }
-
-    int iarg;
-    int totalArgs = ((32 - postArgsLen) < _currentArgCount)?(32 - postArgsLen):_currentArgCount;
-    for (iarg = 0; iarg < totalArgs; iarg++){
-      RequestArgument& arg = postArgs[postArgsLen++];
-      arg.key = _currentArgs[iarg].key;
-      arg.value = _currentArgs[iarg].value;
-    }
-    if (_currentArgs) delete[] _currentArgs;
-    _currentArgs = new RequestArgument[postArgsLen];
-    for (iarg = 0; iarg < postArgsLen; iarg++){
-      RequestArgument& arg = _currentArgs[iarg];
-      arg.key = postArgs[iarg].key;
-      arg.value = postArgs[iarg].value;
-    }
-    _currentArgCount = iarg;
-    if (postArgs) delete[] postArgs;
-    return true;
-  }
-#ifdef DEBUG_ESP_HTTP_SERVER
-  DEBUG_OUTPUT.print("Error: line: ");
-  DEBUG_OUTPUT.println(line);
-#endif
-  return false;
-}
-
-String ESP8266WebServer::urlDecode(const String& text)
-{
-	String decoded = "";
-	char temp[] = "0x00";
-	unsigned int len = text.length();
-	unsigned int i = 0;
-	while (i < len)
-	{
-		char decodedChar;
-		char encodedChar = text.charAt(i++);
-		if ((encodedChar == '%') && (i + 1 < len))
-		{
-			temp[2] = text.charAt(i++);
-			temp[3] = text.charAt(i++);
-
-			decodedChar = strtol(temp, NULL, 16);
-		}
-		else {
-			if (encodedChar == '+')
-			{
-				decodedChar = ' ';
-			}
-			else {
-				decodedChar = encodedChar;  // normal ascii char
-			}
-		}
-		decoded += decodedChar;
-	}
-	return decoded;
-}
-
-bool ESP8266WebServer::_parseFormUploadAborted(){
-  _currentUpload.status = UPLOAD_FILE_ABORTED;
-  if(_currentHandler && _currentHandler->canUpload(_currentUri))
-    _currentHandler->upload(*this, _currentUri, _currentUpload);
-  return false;
-}

+ 0 - 19
lib/ESP8266WebServer/src/detail/RequestHandler.h

@@ -1,19 +0,0 @@
-#ifndef REQUESTHANDLER_H
-#define REQUESTHANDLER_H
-
-class RequestHandler {
-public:
-    virtual ~RequestHandler() { }
-    virtual bool canHandle(HTTPMethod method, String uri) { return false; }
-    virtual bool canUpload(String uri) { return false; }
-    virtual bool handle(ESP8266WebServer& server, HTTPMethod requestMethod, String requestUri) { return false; }
-    virtual void upload(ESP8266WebServer& server, String requestUri, HTTPUpload& upload) {}
-
-    RequestHandler* next() { return _next; }
-    void next(RequestHandler* r) { _next = r; }
-
-private:
-    RequestHandler* _next = nullptr;
-};
-
-#endif //REQUESTHANDLER_H

+ 0 - 150
lib/ESP8266WebServer/src/detail/RequestHandlersImpl.h

@@ -1,150 +0,0 @@
-#ifndef REQUESTHANDLERSIMPL_H
-#define REQUESTHANDLERSIMPL_H
-
-#include "RequestHandler.h"
-
-class FunctionRequestHandler : public RequestHandler {
-public:
-    FunctionRequestHandler(ESP8266WebServer::THandlerFunction fn, ESP8266WebServer::THandlerFunction ufn, const char* uri, HTTPMethod method)
-    : _fn(fn)
-    , _ufn(ufn)
-    , _uri(uri)
-    , _method(method)
-    {
-    }
-
-    bool canHandle(HTTPMethod requestMethod, String requestUri) override  {
-        if (_method != HTTP_ANY && _method != requestMethod)
-            return false;
-
-        if (requestUri != _uri)
-            return false;
-
-        return true;
-    }
-
-    bool canUpload(String requestUri) override  {
-        if (!_ufn || !canHandle(HTTP_POST, requestUri))
-            return false;
-
-        return true;
-    }
-
-    bool handle(ESP8266WebServer& server, HTTPMethod requestMethod, String requestUri) override {
-        if (!canHandle(requestMethod, requestUri))
-            return false;
-
-        _fn();
-        return true;
-    }
-
-    void upload(ESP8266WebServer& server, String requestUri, HTTPUpload& upload) override {
-        if (canUpload(requestUri))
-            _ufn();
-    }
-
-protected:
-    ESP8266WebServer::THandlerFunction _fn;
-    ESP8266WebServer::THandlerFunction _ufn;
-    String _uri;
-    HTTPMethod _method;
-};
-
-class StaticRequestHandler : public RequestHandler {
-public:
-    StaticRequestHandler(FS& fs, const char* path, const char* uri, const char* cache_header)
-    : _fs(fs)
-    , _uri(uri)
-    , _path(path)
-    , _cache_header(cache_header)
-    {
-        _isFile = fs.exists(path);
-        DEBUGV("StaticRequestHandler: path=%s uri=%s isFile=%d, cache_header=%s\r\n", path, uri, _isFile, cache_header);
-        _baseUriLength = _uri.length();
-    }
-
-    bool canHandle(HTTPMethod requestMethod, String requestUri) override  {
-        if (requestMethod != HTTP_GET)
-            return false;
-
-        if ((_isFile && requestUri != _uri) || !requestUri.startsWith(_uri))
-            return false;
-
-        return true;
-    }
-
-    bool handle(ESP8266WebServer& server, HTTPMethod requestMethod, String requestUri) override {
-        if (!canHandle(requestMethod, requestUri))
-            return false;
-
-        DEBUGV("StaticRequestHandler::handle: request=%s _uri=%s\r\n", requestUri.c_str(), _uri.c_str());
-
-        String path(_path);
-
-        if (!_isFile) {
-            // Base URI doesn't point to a file.
-            // If a directory is requested, look for index file.
-            if (requestUri.endsWith("/")) requestUri += "index.htm";
-
-            // Append whatever follows this URI in request to get the file path.
-            path += requestUri.substring(_baseUriLength);
-        }
-        DEBUGV("StaticRequestHandler::handle: path=%s, isFile=%d\r\n", path.c_str(), _isFile);
-
-        String contentType = getContentType(path);
-
-        // look for gz file, only if the original specified path is not a gz.  So part only works to send gzip via content encoding when a non compressed is asked for
-        // if you point the the path to gzip you will serve the gzip as content type "application/x-gzip", not text or javascript etc...
-        if (!path.endsWith(".gz") && !_fs.exists(path))  {
-            String pathWithGz = path + ".gz";
-            if(_fs.exists(pathWithGz))
-                path += ".gz";
-        }
-
-        File f = _fs.open(path, "r");
-        if (!f)
-            return false;
-
-        if (_cache_header.length() != 0)
-            server.sendHeader("Cache-Control", _cache_header);
-
-        server.streamFile(f, contentType);
-        return true;
-    }
-
-    static String getContentType(const String& path) {
-        if (path.endsWith(".html")) return "text/html";
-        else if (path.endsWith(".htm")) return "text/html";
-        else if (path.endsWith(".css")) return "text/css";
-        else if (path.endsWith(".txt")) return "text/plain";
-        else if (path.endsWith(".js")) return "application/javascript";
-        else if (path.endsWith(".png")) return "image/png";
-        else if (path.endsWith(".gif")) return "image/gif";
-        else if (path.endsWith(".jpg")) return "image/jpeg";
-        else if (path.endsWith(".ico")) return "image/x-icon";
-        else if (path.endsWith(".svg")) return "image/svg+xml";
-        else if (path.endsWith(".ttf")) return "application/x-font-ttf";
-        else if (path.endsWith(".otf")) return "application/x-font-opentype";
-        else if (path.endsWith(".woff")) return "application/font-woff";
-        else if (path.endsWith(".woff2")) return "application/font-woff2";
-        else if (path.endsWith(".eot")) return "application/vnd.ms-fontobject";
-        else if (path.endsWith(".sfnt")) return "application/font-sfnt";
-        else if (path.endsWith(".xml")) return "text/xml";
-        else if (path.endsWith(".pdf")) return "application/pdf";
-        else if (path.endsWith(".zip")) return "application/zip";
-        else if(path.endsWith(".gz")) return "application/x-gzip";
-        else if (path.endsWith(".appcache")) return "text/cache-manifest";
-        return "application/octet-stream";
-    }
-
-protected:
-    FS _fs;
-    String _uri;
-    String _path;
-    String _cache_header;
-    bool _isFile;
-    size_t _baseUriLength;
-};
-
-
-#endif //REQUESTHANDLERSIMPL_H

+ 225 - 0
lib/LEDStatus/LEDStatus.cpp

@@ -0,0 +1,225 @@
+#include "LEDStatus.h"
+
+// constructor defines which pin the LED is attached to
+LEDStatus::LEDStatus(int8_t ledPin) {
+  // if pin negative, reverse and set inverse on pin outputs
+  if (ledPin < 0) {
+    ledPin = -ledPin;
+    _inverse = true;
+  } else {
+    _inverse = false;
+  }
+  // set up the pin
+  _ledPin = ledPin;
+  pinMode(_ledPin, OUTPUT);
+  digitalWrite(_ledPin, _pinState(LOW));
+  _timer = millis();
+}
+
+// change pin at runtime
+void LEDStatus::changePin(int8_t ledPin) {
+  bool inverse;
+  // if pin negative, reverse and set inverse on pin outputs
+  if (ledPin < 0) {
+    ledPin = -ledPin;
+    inverse = true;
+  } else {
+    inverse = false;
+  }
+
+  if ((ledPin != _ledPin) && (inverse != _inverse)) {
+    // make sure old pin is off
+    digitalWrite(_ledPin, _pinState(LOW));
+    _ledPin = ledPin;
+    _inverse = inverse;
+    // and make sure new pin is also off
+    pinMode(_ledPin, OUTPUT);
+    digitalWrite(_ledPin, _pinState(LOW));
+  }
+}
+
+
+// identify how to flash the LED by mode, continuously until changed
+void LEDStatus::continuous(LEDStatus::LEDMode mode) {
+  uint16_t ledOffMs, ledOnMs;
+  _modeToTime(mode, ledOffMs, ledOnMs);
+  continuous(ledOffMs, ledOnMs);
+}
+
+// identify how to flash the LED by on/off times (in ms), continuously until changed
+void LEDStatus::continuous(uint16_t ledOffMs, uint16_t ledOnMs) {
+  _continuousOffMs = ledOffMs;
+  _continuousOnMs = ledOnMs;
+  _continuousCurrentlyOn = false;
+  // reset LED to off
+  if (_ledPin > 0) {
+    digitalWrite(_ledPin, _pinState(LOW));
+  }
+  // restart timer
+  _timer = millis();
+}
+
+// identify a one-shot LED action (overrides continuous until done) by mode
+void LEDStatus::oneshot(LEDStatus::LEDMode mode, uint8_t count) {
+  uint16_t ledOffMs, ledOnMs;
+  _modeToTime(mode, ledOffMs, ledOnMs);
+  oneshot(ledOffMs, ledOnMs, count);
+}
+
+// identify a one-shot LED action (overrides continuous until done) by times (in ms)
+void LEDStatus::oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count) {
+  _oneshotOffMs = ledOffMs;
+  _oneshotOnMs = ledOnMs;
+  _oneshotCountRemaining = count;
+  _oneshotCurrentlyOn = false;
+  // reset LED to off
+  if (_ledPin > 0) {
+    digitalWrite(_ledPin, _pinState(LOW));
+  }
+  // restart timer
+  _timer = millis();
+}
+
+// call this function in your loop - it will return quickly after calculating if any changes need to 
+// be made to the pin to flash the LED
+void LEDStatus::LEDStatus::handle() {
+  // is a pin defined?
+  if (_ledPin == 0) {
+    return;
+  }
+
+  // are we currently running a one-shot?
+  if (_oneshotCountRemaining > 0) {
+      if (_oneshotCurrentlyOn) {
+          if ((_timer + _oneshotOnMs) < millis()) {
+              if (_oneshotOffMs > 0) {
+                  digitalWrite(_ledPin, _pinState(LOW));
+              }
+              _oneshotCurrentlyOn = false;
+              --_oneshotCountRemaining;
+              if (_oneshotCountRemaining == 0) {
+                  _continuousCurrentlyOn = false;
+              }
+              _timer += _oneshotOnMs;
+          }
+      } else {
+          if ((_timer + _oneshotOffMs) < millis()) {
+            if (_oneshotOnMs > 0) {
+                digitalWrite(_ledPin, _pinState(HIGH));
+            }
+            _oneshotCurrentlyOn = true;
+            _timer += _oneshotOffMs;
+          }            
+      }
+  } else {
+    // operate using continuous
+    if (_continuousCurrentlyOn) {
+      if ((_timer + _continuousOnMs) < millis()) {
+        if (_continuousOffMs > 0) {
+          digitalWrite(_ledPin, _pinState(LOW));
+        }
+        _continuousCurrentlyOn = false;
+        _timer += _continuousOnMs;
+      }
+    } else {
+      if ((_timer + _continuousOffMs) < millis()) {
+        if (_continuousOnMs > 0) {
+          digitalWrite(_ledPin, _pinState(HIGH));
+        }
+        _continuousCurrentlyOn = true;
+        _timer += _continuousOffMs;
+      }
+    }
+  }
+}
+
+// helper function to convert an LEDMode enum to a string
+String LEDStatus::LEDModeToString(LEDStatus::LEDMode mode) {
+  switch (mode) {
+    case LEDStatus::LEDMode::Off:
+      return "Off";
+    case LEDStatus::LEDMode::SlowToggle:
+      return "Slow toggle";
+    case LEDStatus::LEDMode::FastToggle:
+      return "Fast toggle";
+    case LEDStatus::LEDMode::SlowBlip:
+      return "Slow blip";
+    case LEDStatus::LEDMode::FastBlip:
+      return "Fast blip";
+    case LEDStatus::LEDMode::Flicker:
+      return "Flicker";
+    case LEDStatus::LEDMode::On:
+      return "On";
+    default:
+      return "Unknown";
+  }
+}
+
+// helper function to convert a string to an LEDMode enum (note, mismatch returns LedMode::Unknown)
+LEDStatus::LEDMode LEDStatus::stringToLEDMode(String mode) {
+  if (mode == "Off")
+    return LEDStatus::LEDMode::Off;
+  if (mode == "Slow toggle")
+    return LEDStatus::LEDMode::SlowToggle;
+  if (mode == "Fast toggle")
+    return LEDStatus::LEDMode::FastToggle;
+  if (mode == "Slow blip")
+    return LEDStatus::LEDMode::SlowBlip;
+  if (mode == "Fast blip")
+    return LEDStatus::LEDMode::FastBlip;
+  if (mode == "Flicker")
+    return LEDStatus::LEDMode::Flicker;
+  if (mode == "On")
+    return LEDStatus::LEDMode::On;
+  // unable to match...
+  return LEDStatus::LEDMode::Unknown;
+}
+
+
+// private helper converts mode to on/off times in ms
+void LEDStatus::_modeToTime(LEDStatus::LEDMode mode, uint16_t& ledOffMs, uint16_t& ledOnMs) {
+  switch (mode) {
+    case LEDMode::Off:
+      ledOffMs = 1000;
+      ledOnMs = 0;
+      break;
+    case LEDMode::SlowToggle:
+      ledOffMs = 1000;
+      ledOnMs = 1000;
+      break;
+    case LEDMode::FastToggle:
+      ledOffMs = 100;
+      ledOnMs = 100;
+      break;
+    case LEDMode::SlowBlip:
+      ledOffMs = 1500;
+      ledOnMs = 50;
+      break;
+    case LEDMode::FastBlip:
+      ledOffMs = 333;
+      ledOnMs = 50;
+      break;
+    case LEDMode::On:
+      ledOffMs = 0;
+      ledOnMs = 1000;
+      break;
+    case LEDMode::Flicker:
+      ledOffMs = 50;
+      ledOnMs = 30;
+      break;
+    default:
+      Serial.printf_P(PSTR("LEDStatus::_modeToTime: Uknown LED mode %d\n"), mode);
+      ledOffMs = 500;
+      ledOnMs = 2000;
+      break;
+  }
+}
+
+// private helper to optionally inverse the LED
+uint8_t LEDStatus::_pinState(uint8_t val) {
+  if (_inverse) {
+    return (val == LOW) ? HIGH : LOW;
+  }
+  return val;
+}
+

+ 49 - 0
lib/LEDStatus/LEDStatus.h

@@ -0,0 +1,49 @@
+#include <Arduino.h>
+#include <string.h>
+
+#ifndef _LED_STATUS_H
+#define _LED_STATUS_H
+
+class LEDStatus {
+  public:
+    enum class LEDMode {
+      Off,
+      SlowToggle,
+      FastToggle,
+      SlowBlip,
+      FastBlip,
+      Flicker,
+      On,
+      Unknown
+    };
+    LEDStatus(int8_t ledPin);
+    void changePin(int8_t ledPin);
+    void continuous(LEDMode mode);
+    void continuous(uint16_t ledOffMs, uint16_t ledOnMs);
+    void oneshot(LEDMode mode, uint8_t count = 1);
+    void oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count = 1);
+
+    static String LEDModeToString(LEDMode mode);
+    static LEDMode stringToLEDMode(String mode);
+
+    void handle();
+
+  private:
+    void _modeToTime(LEDMode mode, uint16_t& ledOffMs, uint16_t& ledOnMs);
+    uint8_t _pinState(uint8_t val);
+    uint8_t _ledPin;
+    bool _inverse;
+
+    uint16_t _continuousOffMs = 1000;
+    uint16_t _continuousOnMs = 0;
+    bool _continuousCurrentlyOn = false;
+
+    uint16_t _oneshotOffMs;
+    uint16_t _oneshotOnMs;
+    uint8_t _oneshotCountRemaining = 0;
+    bool _oneshotCurrentlyOn = false;
+
+    unsigned long _timer = 0;
+};
+
+#endif

+ 1 - 1
lib/MQTT/BulbStateUpdater.cpp

@@ -41,7 +41,7 @@ inline void BulbStateUpdater::flushGroup(BulbId bulbId, GroupState& state) {
   char buffer[200];
   StaticJsonBuffer<200> jsonBuffer;
   JsonObject& message = jsonBuffer.createObject();
-  state.applyState(message, settings.groupStateFields, settings.numGroupStateFields);
+  state.applyState(message, bulbId, settings.groupStateFields, settings.numGroupStateFields);
   message.printTo(buffer);
 
   mqttClient.sendState(

+ 13 - 5
lib/MiLight/CctPacketFormatter.cpp

@@ -48,20 +48,28 @@ void CctPacketFormatter::finalizePacket(uint8_t* packet) {
 }
 
 void CctPacketFormatter::updateBrightness(uint8_t value) {
+  const GroupState& state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_CCT);
+  int8_t knownValue = state.isSetBrightness() ? state.getBrightness() : -1;
+
   valueByStepFunction(
     &PacketFormatter::increaseBrightness,
     &PacketFormatter::decreaseBrightness,
     CCT_INTERVALS,
-    value / CCT_INTERVALS
+    value / CCT_INTERVALS,
+    knownValue / CCT_INTERVALS
   );
 }
 
 void CctPacketFormatter::updateTemperature(uint8_t value) {
+  const GroupState& state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_CCT);
+  int8_t knownValue = state.isSetKelvin() ? state.getKelvin() : -1;
+
   valueByStepFunction(
     &PacketFormatter::increaseTemperature,
     &PacketFormatter::decreaseTemperature,
     CCT_INTERVALS,
-    value / CCT_INTERVALS
+    value / CCT_INTERVALS,
+    knownValue / CCT_INTERVALS
   );
 }
 
@@ -180,16 +188,16 @@ MiLightStatus CctPacketFormatter::cctCommandToStatus(uint8_t command) {
   }
 }
 
-BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore) {
+BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
   uint8_t command = packet[CCT_COMMAND_INDEX] & 0x7F;
 
+  uint8_t onOffGroupId = cctCommandIdToGroup(command);
   BulbId bulbId(
     (packet[1] << 8) | packet[2],
-    packet[3],
+    onOffGroupId < 255 ? onOffGroupId : packet[3],
     REMOTE_TYPE_CCT
   );
 
-  uint8_t onOffGroupId = cctCommandIdToGroup(command);
   if (onOffGroupId < 255) {
     result["state"] = cctCommandToStatus(command) == ON ? "ON" : "OFF";
   } else if (command == CCT_BRIGHTNESS_DOWN) {

+ 1 - 1
lib/MiLight/CctPacketFormatter.h

@@ -46,7 +46,7 @@ public:
   virtual void format(uint8_t const* packet, char* buffer);
   virtual void initializePacket(uint8_t* packet);
   virtual void finalizePacket(uint8_t* packet);
-  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
 
   static uint8_t getCctStatusButton(uint8_t groupId, MiLightStatus status);
   static uint8_t cctCommandIdToGroup(uint8_t command);

+ 41 - 3
lib/MiLight/FUT089PacketFormatter.cpp

@@ -18,6 +18,7 @@ void FUT089PacketFormatter::updateBrightness(uint8_t brightness) {
   command(FUT089_BRIGHTNESS, brightness);
 }
 
+// change the hue (which may also change to color mode).
 void FUT089PacketFormatter::updateHue(uint16_t value) {
   uint8_t remapped = Units::rescale(value, 255, 360);
   updateColorRaw(remapped);
@@ -27,13 +28,50 @@ void FUT089PacketFormatter::updateColorRaw(uint8_t value) {
   command(FUT089_COLOR, FUT089_COLOR_OFFSET + value);
 }
 
+// change the temperature (kelvin).  Note that temperature and saturation share the same command 
+// number (7), and they change which they do based on the mode of the lamp (white vs. color mode).
+// To make this command work, we need to switch to white mode, make the change, and then flip
+// back to the original mode.
 void FUT089PacketFormatter::updateTemperature(uint8_t value) {
-  updateColorWhite();
+  // look up our current mode 
+  GroupState ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_FUT089);
+  BulbMode originalBulbMode = ourState.getBulbMode();
+
+  // are we already in white?  If not, change to white
+  if (originalBulbMode != BulbMode::BULB_MODE_WHITE) {
+    updateColorWhite();
+  }
+
+  // now make the temperature change
   command(FUT089_KELVIN, 100 - value);
+
+  // and return to our original mode
+  if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_WHITE)) {
+    switchMode(ourState, originalBulbMode);
+  }
 }
 
+// change the saturation.  Note that temperature and saturation share the same command 
+// number (7), and they change which they do based on the mode of the lamp (white vs. color mode).
+// Therefore, if we are not in color mode, we need to switch to color mode, make the change,
+// and switch back to the original mode.
 void FUT089PacketFormatter::updateSaturation(uint8_t value) {
+  // look up our current mode 
+  GroupState ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_FUT089);
+  BulbMode originalBulbMode = ourState.getBulbMode();
+
+  // are we already in color?  If not, we need to flip modes
+  if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) {
+    updateHue(ourState.getHue());
+  }
+
+  // now make the saturation change
   command(FUT089_SATURATION, 100 - value);
+
+  // and revert back if necessary
+  if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) {
+    switchMode(ourState, originalBulbMode);
+  }
 }
 
 void FUT089PacketFormatter::updateColorWhite() {
@@ -45,7 +83,7 @@ void FUT089PacketFormatter::enableNightMode() {
   command(FUT089_ON | 0x80, arg);
 }
 
-BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result, GroupStateStore* stateStore) {
+BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
   uint8_t packetCopy[V2_PACKET_LEN];
   memcpy(packetCopy, packet, V2_PACKET_LEN);
   V2RFEncoding::decodeV2Packet(packetCopy);
@@ -67,7 +105,7 @@ BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& res
     } else if (arg == FUT089_MODE_SPEED_UP) {
       result["command"] = "mode_speed_up";
     } else if (arg == FUT089_WHITE_MODE) {
-      result["command"] = "white_mode";
+      result["command"] = "set_white";
     } else if (arg <= 8) { // Group is not reliably encoded in group byte. Extract from arg byte
       result["state"] = "ON";
       bulbId.groupId = arg;

+ 4 - 4
lib/MiLight/FUT089PacketFormatter.h

@@ -11,8 +11,8 @@ enum MiLightFUT089Command {
   FUT089_COLOR = 0x02,
   FUT089_BRIGHTNESS = 0x05,
   FUT089_MODE = 0x06,
-  FUT089_KELVIN = 0x07,
-  FUT089_SATURATION = 0x07
+  FUT089_KELVIN = 0x07,     // Controls Kelvin when in White mode
+  FUT089_SATURATION = 0x07  // Controls Saturation when in Color mode
 };
 
 enum MiLightFUT089Arguments {
@@ -24,7 +24,7 @@ enum MiLightFUT089Arguments {
 class FUT089PacketFormatter : public V2PacketFormatter {
 public:
   FUT089PacketFormatter()
-    : V2PacketFormatter(0x25, 8)
+    : V2PacketFormatter(0x25, 8)    // protocol is 0x25, and there are 8 groups
   { }
 
   virtual void updateBrightness(uint8_t value);
@@ -39,7 +39,7 @@ public:
   virtual void modeSpeedUp();
   virtual void updateMode(uint8_t mode);
 
-  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
 };
 
 #endif

+ 88 - 19
lib/MiLight/MiLightClient.cpp

@@ -6,10 +6,8 @@
 
 MiLightClient::MiLightClient(
   MiLightRadioFactory* radioFactory,
-  GroupStateStore& stateStore,
-  size_t throttleThreshold,
-  size_t throttleSensitivity,
-  size_t packetRepeatMinimum
+  GroupStateStore* stateStore,
+  Settings* settings
 )
   : baseResendCount(MILIGHT_DEFAULT_RESEND_COUNT),
     currentRadio(NULL),
@@ -19,10 +17,8 @@ MiLightClient::MiLightClient(
     updateBeginHandler(NULL),
     updateEndHandler(NULL),
     stateStore(stateStore),
-    lastSend(0),
-    throttleThreshold(throttleThreshold),
-    throttleSensitivity(throttleSensitivity),
-    packetRepeatMinimum(packetRepeatMinimum)
+    settings(settings),
+    lastSend(0)
 {
   radios = new MiLightRadio*[numRadios];
 
@@ -82,7 +78,7 @@ void MiLightClient::prepare(const MiLightRemoteConfig* config,
   this->currentRemote = config;
 
   if (deviceId >= 0 && groupId >= 0) {
-    currentRemote->packetFormatter->prepare(deviceId, groupId);
+    currentRemote->packetFormatter->prepare(deviceId, groupId, stateStore, settings);
   }
 }
 
@@ -96,7 +92,7 @@ void MiLightClient::prepare(const MiLightRemoteType type,
 void MiLightClient::setResendCount(const unsigned int resendCount) {
   this->baseResendCount = resendCount;
   this->currentResendCount = resendCount;
-  this->throttleMultiplier = ceil((throttleSensitivity / 1000.0) * this->baseResendCount);
+  this->throttleMultiplier = ceil((settings->packetRepeatThrottleSensitivity / 1000.0) * this->baseResendCount);
 }
 
 bool MiLightClient::available() {
@@ -124,18 +120,21 @@ void MiLightClient::write(uint8_t packet[]) {
   }
 
 #ifdef DEBUG_PRINTF
-  Serial.printf("Sending packet (%d repeats): \n", this->currentResendCount);
+  Serial.printf_P(PSTR("Sending packet (%d repeats): \n"), this->currentResendCount);
   for (int i = 0; i < currentRemote->packetFormatter->getPacketLength(); i++) {
-    Serial.printf("%02X ", packet[i]);
+    Serial.printf_P(PSTR("%02X "), packet[i]);
   }
   Serial.println();
   int iStart = millis();
 #endif
 
+  // send the packet out (multiple times for "reliability")
   for (int i = 0; i < this->currentResendCount; i++) {
     currentRadio->write(packet, currentRemote->packetFormatter->getPacketLength());
   }
 
+  // if we have a packetSendHandler defined (see MiLightClient::onPacketSent), call it now that
+  // the packet has been dispatched
   if (this->packetSentHandler) {
     this->packetSentHandler(packet, *currentRemote);
   }
@@ -148,105 +147,168 @@ void MiLightClient::write(uint8_t packet[]) {
 }
 
 void MiLightClient::updateColorRaw(const uint8_t color) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateColorRaw: Change color to %d\n"), color);
+#endif
   currentRemote->packetFormatter->updateColorRaw(color);
   flushPacket();
 }
 
 void MiLightClient::updateHue(const uint16_t hue) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateHue: Change hue to %d\n"), hue);
+#endif
   currentRemote->packetFormatter->updateHue(hue);
   flushPacket();
 }
 
 void MiLightClient::updateBrightness(const uint8_t brightness) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateBrightness: Change brightness to %d\n"), brightness);
+#endif
   currentRemote->packetFormatter->updateBrightness(brightness);
   flushPacket();
 }
 
 void MiLightClient::updateMode(uint8_t mode) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateMode: Change mode to %d\n"), mode);
+#endif
   currentRemote->packetFormatter->updateMode(mode);
   flushPacket();
 }
 
 void MiLightClient::nextMode() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::nextMode: Switch to next mode"));
+#endif
   currentRemote->packetFormatter->nextMode();
   flushPacket();
 }
 
 void MiLightClient::previousMode() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::previousMode: Switch to previous mode"));
+#endif
   currentRemote->packetFormatter->previousMode();
   flushPacket();
 }
 
 void MiLightClient::modeSpeedDown() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::modeSpeedDown: Speed down\n"));
+#endif
   currentRemote->packetFormatter->modeSpeedDown();
   flushPacket();
 }
 void MiLightClient::modeSpeedUp() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::modeSpeedUp: Speed up"));
+#endif
   currentRemote->packetFormatter->modeSpeedUp();
   flushPacket();
 }
 
 void MiLightClient::updateStatus(MiLightStatus status, uint8_t groupId) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateStatus: Status %s, groupId %d\n"), status == MiLightStatus::OFF ? "OFF" : "ON", groupId);
+#endif
   currentRemote->packetFormatter->updateStatus(status, groupId);
   flushPacket();
 }
 
 void MiLightClient::updateStatus(MiLightStatus status) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateStatus: Status %s\n"), status == MiLightStatus::OFF ? "OFF" : "ON");
+#endif
   currentRemote->packetFormatter->updateStatus(status);
   flushPacket();
 }
 
 void MiLightClient::updateSaturation(const uint8_t value) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateSaturation: Saturation %d\n"), value);
+#endif
   currentRemote->packetFormatter->updateSaturation(value);
   flushPacket();
 }
 
 void MiLightClient::updateColorWhite() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::updateColorWhite: Color white"));
+#endif
   currentRemote->packetFormatter->updateColorWhite();
   flushPacket();
 }
 
 void MiLightClient::enableNightMode() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::enableNightMode: Night mode"));
+#endif
   currentRemote->packetFormatter->enableNightMode();
   flushPacket();
 }
 
 void MiLightClient::pair() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::pair: Pair"));
+#endif
   currentRemote->packetFormatter->pair();
   flushPacket();
 }
 
 void MiLightClient::unpair() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::unpair: Unpair"));
+#endif
   currentRemote->packetFormatter->unpair();
   flushPacket();
 }
 
 void MiLightClient::increaseBrightness() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::increaseBrightness: Increase brightness"));
+#endif
   currentRemote->packetFormatter->increaseBrightness();
   flushPacket();
 }
 
 void MiLightClient::decreaseBrightness() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::decreaseBrightness: Decrease brightness"));
+#endif
   currentRemote->packetFormatter->decreaseBrightness();
   flushPacket();
 }
 
 void MiLightClient::increaseTemperature() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::increaseTemperature: Increase temperature"));
+#endif
   currentRemote->packetFormatter->increaseTemperature();
   flushPacket();
 }
 
 void MiLightClient::decreaseTemperature() {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.println(F("MiLightClient::decreaseTemperature: Decrease temperature"));
+#endif
   currentRemote->packetFormatter->decreaseTemperature();
   flushPacket();
 }
 
 void MiLightClient::updateTemperature(const uint8_t temperature) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::updateTemperature: Set temperature to %d\n"), temperature);
+#endif
   currentRemote->packetFormatter->updateTemperature(temperature);
   flushPacket();
 }
 
 void MiLightClient::command(uint8_t command, uint8_t arg) {
+#ifdef DEBUG_CLIENT_COMMANDS
+  Serial.printf_P(PSTR("MiLightClient::command: Execute command %d, argument %d\n"), command, arg);
+#endif
   currentRemote->packetFormatter->command(command, arg);
   flushPacket();
 }
@@ -293,11 +355,15 @@ void MiLightClient::update(const JsonObject& request) {
   if (request.containsKey("color")) {
     JsonObject& color = request["color"];
 
-    uint8_t r = color["r"];
-    uint8_t g = color["g"];
-    uint8_t b = color["b"];
-    //If close to white
-    if( r > 256 - RGB_WHITE_BOUNDARY && g > 256 - RGB_WHITE_BOUNDARY && b > 256 - RGB_WHITE_BOUNDARY) {
+    int16_t r = color["r"];
+    int16_t g = color["g"];
+    int16_t b = color["b"];
+
+    // We consider an RGB color "white" if all color intensities are roughly the
+    // same value.  An unscientific value of 10 (~4%) is chosen.
+    if ( abs(r - g) < RGB_WHITE_THRESHOLD
+      && abs(g - b) < RGB_WHITE_THRESHOLD
+      && abs(r - b) < RGB_WHITE_THRESHOLD) {
         this->updateColorWhite();
     } else {
       double hsv[3];
@@ -405,10 +471,10 @@ uint8_t MiLightClient::parseStatus(const JsonObject& object) {
 void MiLightClient::updateResendCount() {
   unsigned long now = millis();
   long millisSinceLastSend = now - lastSend;
-  long x = (millisSinceLastSend - throttleThreshold);
+  long x = (millisSinceLastSend - settings->packetRepeatThrottleThreshold);
   long delta = x * throttleMultiplier;
 
-  this->currentResendCount = constrain(this->currentResendCount + delta, packetRepeatMinimum, this->baseResendCount);
+  this->currentResendCount = constrain(this->currentResendCount + delta, settings->packetRepeatMinimum, this->baseResendCount);
   this->lastSend = now;
 }
 
@@ -427,6 +493,9 @@ void MiLightClient::flushPacket() {
   currentRemote->packetFormatter->reset();
 }
 
+/*
+  Register a callback for when packets are sent
+*/
 void MiLightClient::onPacketSent(PacketSentHandler handler) {
   this->packetSentHandler = handler;
 }

+ 8 - 10
lib/MiLight/MiLightClient.h

@@ -10,19 +10,19 @@
 #define _MILIGHTCLIENT_H
 
 //#define DEBUG_PRINTF
+//#define DEBUG_CLIENT_COMMANDS     // enable to show each individual change command (like hue, brightness, etc)
 
 #define MILIGHT_DEFAULT_RESEND_COUNT 10
-//Used to determine close to white
-#define RGB_WHITE_BOUNDARY 40
+
+// Used to determine RGB colros that are approximately white
+#define RGB_WHITE_THRESHOLD 10
 
 class MiLightClient {
 public:
   MiLightClient(
     MiLightRadioFactory* radioFactory,
-    GroupStateStore& stateStore,
-    size_t throttleThreshold,
-    size_t throttleSensitivity,
-    size_t packetRepeatMinimum
+    GroupStateStore* stateStore,
+    Settings* settings
   );
 
   ~MiLightClient() {
@@ -89,7 +89,8 @@ protected:
   MiLightRadio* currentRadio;
   const MiLightRemoteConfig* currentRemote;
   const size_t numRadios;
-  GroupStateStore& stateStore;
+  GroupStateStore* stateStore;
+  const Settings* settings;
 
   PacketSentHandler packetSentHandler;
   EventHandler updateBeginHandler;
@@ -99,9 +100,6 @@ protected:
   unsigned long lastSend;
   int currentResendCount;
   unsigned int baseResendCount;
-  int packetRepeatMinimum;
-  size_t throttleThreshold;
-  size_t throttleSensitivity;
 
   // This will be pre-computed, but is simply:
   //

+ 5 - 3
lib/MiLight/MiLightRemoteConfig.cpp

@@ -32,14 +32,16 @@ const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
     return &FUT098Config;
   }
 
-  Serial.println(F("ERROR - tried to fetch remote config for type"));
+  Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for type: "));
+  Serial.println(type);
 
   return NULL;
 }
 
 const MiLightRemoteConfig* MiLightRemoteConfig::fromType(MiLightRemoteType type) {
   if (type == REMOTE_TYPE_UNKNOWN || type >= size(ALL_REMOTES)) {
-    Serial.println(F("ERROR - tried to fetch remote config for unknown type"));
+    Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for unknown type: "));
+    Serial.println(type);
     return NULL;
   }
 
@@ -61,7 +63,7 @@ const MiLightRemoteConfig* MiLightRemoteConfig::fromReceivedPacket(
 
   // This can happen under normal circumstances, so not an error condition
 #ifdef DEBUG_PRINTF
-  Serial.println(F("ERROR - tried to fetch remote config for unknown packet"));
+  Serial.println(F("MiLightRemoteConfig::fromReceivedPacket: ERROR - tried to fetch remote config for unknown packet"));
 #endif
 
   return NULL;

+ 27 - 8
lib/MiLight/PacketFormatter.cpp

@@ -64,7 +64,7 @@ void PacketFormatter::enableNightMode() { }
 void PacketFormatter::updateTemperature(uint8_t value) { }
 void PacketFormatter::updateSaturation(uint8_t value) { }
 
-BulbId PacketFormatter::parsePacket(const uint8_t *packet, JsonObject &result, GroupStateStore* stateStore) {
+BulbId PacketFormatter::parsePacket(const uint8_t *packet, JsonObject &result) {
   return DEFAULT_BULB_ID;
 }
 
@@ -89,25 +89,44 @@ PacketStream& PacketFormatter::buildPackets() {
   return packetStream;
 }
 
-void PacketFormatter::valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t value) {
-  for (size_t i = 0; i < numSteps; i++) {
-    (this->*decrease)();
+void PacketFormatter::valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t targetValue, int8_t knownValue) {
+  StepFunction fn;
+  size_t numCommands = 0;
+
+  // If current value is not known, drive down to minimum value.  Then we can assume that we
+  // know the state (it'll be 0).
+  if (knownValue == -1) {
+    for (size_t i = 0; i < numSteps; i++) {
+      (this->*decrease)();
+    }
+
+    fn = increase;
+    numCommands = targetValue;
+  } else if (targetValue < knownValue) {
+    fn = decrease;
+    numCommands = (knownValue - targetValue);
+  } else if (targetValue > knownValue) {
+    fn = increase;
+    numCommands = (targetValue - knownValue);
   }
 
-  for (size_t i = 0; i < value; i++) {
-    (this->*increase)();
+  // Get to the desired value
+  for (size_t i = 0; i < numCommands; i++) {
+    (this->*fn)();
   }
 }
 
-void PacketFormatter::prepare(uint16_t deviceId, uint8_t groupId) {
+void PacketFormatter::prepare(uint16_t deviceId, uint8_t groupId, GroupStateStore* stateStore, const Settings* settings) {
   this->deviceId = deviceId;
   this->groupId = groupId;
+  this->stateStore = stateStore;
+  this->settings = settings;
   reset();
 }
 
 void PacketFormatter::reset() {
   this->numPackets = 0;
-  this->currentPacket = currentPacket;
+  this->currentPacket = PACKET_BUFFER;
   this->held = false;
 }
 

+ 13 - 3
lib/MiLight/PacketFormatter.h

@@ -5,6 +5,7 @@
 #include <ArduinoJson.h>
 #include <GroupState.h>
 #include <GroupStateStore.h>
+#include <Settings.h>
 
 #ifndef _PACKET_FORMATTER_H
 #define _PACKET_FORMATTER_H
@@ -71,10 +72,10 @@ public:
   virtual void reset();
 
   virtual PacketStream& buildPackets();
-  virtual void prepare(uint16_t deviceId, uint8_t groupId);
+  virtual void prepare(uint16_t deviceId, uint8_t groupId, GroupStateStore* stateStore, const Settings* settings);
   virtual void format(uint8_t const* packet, char* buffer);
 
-  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
 
   static void formatV1Packet(uint8_t const* packet, char* buffer);
 
@@ -89,9 +90,18 @@ protected:
   size_t numPackets;
   bool held;
   PacketStream packetStream;
+  GroupStateStore* stateStore = NULL;
+  const Settings* settings = NULL;
 
   void pushPacket();
-  void valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t value);
+
+  // Get field into a desired state using only increment/decrement commands.  Do this by:
+  //   1. Driving it down to its minimum value
+  //   2. Applying the appropriate number of increase commands to get it to the desired
+  //      value.
+  // If the current state is already known, take that into account and apply the exact
+  // number of rpeeats for the appropriate command.
+  void valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t targetValue, int8_t knownValue = -1);
 
   virtual void initializePacket(uint8_t* packetStart) = 0;
   virtual void finalizePacket(uint8_t* packet);

+ 39 - 4
lib/MiLight/RgbCctPacketFormatter.cpp

@@ -27,6 +27,7 @@ void RgbCctPacketFormatter::updateBrightness(uint8_t brightness) {
   command(RGB_CCT_BRIGHTNESS, RGB_CCT_BRIGHTNESS_OFFSET + brightness);
 }
 
+// change the hue (which may also change to color mode).
 void RgbCctPacketFormatter::updateHue(uint16_t value) {
   uint8_t remapped = Units::rescale(value, 255, 360);
   updateColorRaw(remapped);
@@ -43,19 +44,53 @@ void RgbCctPacketFormatter::updateTemperature(uint8_t value) {
   //   * Multiply by 2
   //   * Reverse direction (increasing values should be cool -> warm)
   //   * Start scale at 0xCC
+  uint8_t cmdValue = ((100 - value) * 2) + RGB_CCT_KELVIN_REMOTE_END;
 
-  value = ((100 - value) * 2) + RGB_CCT_KELVIN_REMOTE_END;
+  // when updating temperature, the bulb switches to white.  If we are not already
+  // in white mode, that makes changing temperature annoying because the current hue/mode
+  // is lost.  So lookup our current bulb mode, and if needed, reset the hue/mode after
+  // changing the temperature
+  GroupState ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT);
+  BulbMode originalBulbMode = ourState.getBulbMode();
 
-  command(RGB_CCT_KELVIN, value);
+  // now make the temperature change
+  command(RGB_CCT_KELVIN, cmdValue);
+
+  // and return to our original mode
+  if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_WHITE)) {
+    switchMode(ourState, originalBulbMode);
+  }
 }
 
+// update saturation.  This only works when in Color mode, so if not in color we switch to color,
+// make the change, and switch back again.
 void RgbCctPacketFormatter::updateSaturation(uint8_t value) {
+   // look up our current mode 
+  GroupState ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT);
+  BulbMode originalBulbMode = ourState.getBulbMode();
+
+  // are we already in white?  If not, change to white
+  if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) {
+    updateHue(ourState.getHue());
+  }
+
+  // now make the saturation change
   uint8_t remapped = value + RGB_CCT_SATURATION_OFFSET;
   command(RGB_CCT_SATURATION, remapped);
+
+  if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) {
+    switchMode(ourState, originalBulbMode);
+  }
 }
 
 void RgbCctPacketFormatter::updateColorWhite() {
-  updateTemperature(100);
+  // there is no direct white command, so let's look up our prior temperature and set that, which
+  // causes the bulb to go white 
+  GroupState ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT);
+  uint8_t value = ((100 - ourState.getKelvin()) * 2) + RGB_CCT_KELVIN_REMOTE_END;
+
+  // issue command to set kelvin to prior value, which will drive to white
+  command(RGB_CCT_KELVIN, value);
 }
 
 void RgbCctPacketFormatter::enableNightMode() {
@@ -63,7 +98,7 @@ void RgbCctPacketFormatter::enableNightMode() {
   command(RGB_CCT_ON | 0x80, arg);
 }
 
-BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result, GroupStateStore* stateStore) {
+BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
   uint8_t packetCopy[V2_PACKET_LEN];
   memcpy(packetCopy, packet, V2_PACKET_LEN);
   V2RFEncoding::decodeV2Packet(packetCopy);

+ 1 - 1
lib/MiLight/RgbCctPacketFormatter.h

@@ -50,7 +50,7 @@ public:
   virtual void nextMode();
   virtual void previousMode();
 
-  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
 
 protected:
 

+ 1 - 1
lib/MiLight/RgbPacketFormatter.cpp

@@ -79,7 +79,7 @@ void RgbPacketFormatter::previousMode() {
   command(RGB_MODE_DOWN, 0);
 }
 
-BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore) {
+BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
   uint8_t command = packet[RGB_COMMAND_INDEX] & 0x7F;
 
   BulbId bulbId(

+ 1 - 1
lib/MiLight/RgbPacketFormatter.h

@@ -39,7 +39,7 @@ public:
   virtual void modeSpeedUp();
   virtual void nextMode();
   virtual void previousMode();
-  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
 
   virtual void initializePacket(uint8_t* packet);
 };

+ 9 - 6
lib/MiLight/RgbwPacketFormatter.cpp

@@ -35,13 +35,16 @@ void RgbwPacketFormatter::modeSpeedUp() {
 }
 
 void RgbwPacketFormatter::nextMode() {
-  lastMode = (lastMode + 1) % RGBW_NUM_MODES;
-  updateMode(lastMode);
+  updateMode((currentMode() + 1) % RGBW_NUM_MODES);
 }
 
 void RgbwPacketFormatter::previousMode() {
-  lastMode = (lastMode - 1) % RGBW_NUM_MODES;
-  updateMode(lastMode);
+  updateMode((currentMode() + RGBW_NUM_MODES - 1) % RGBW_NUM_MODES);
+}
+
+uint8_t RgbwPacketFormatter::currentMode() {
+  GroupState& state = stateStore->get(deviceId, groupId, REMOTE_TYPE_RGBW);
+  return state.getMode();
 }
 
 void RgbwPacketFormatter::updateMode(uint8_t mode) {
@@ -97,7 +100,7 @@ void RgbwPacketFormatter::enableNightMode() {
   command(button | 0x10, 0);
 }
 
-BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore) {
+BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
   uint8_t command = packet[RGBW_COMMAND_INDEX] & 0x7F;
 
   BulbId bulbId(
@@ -118,7 +121,7 @@ BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& resul
       result["state"] = "ON";
       result["command"] = "night_mode";
     } else {
-      result["command"] = "white_mode";
+      result["command"] = "set_white";
     }
     bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command & 0xF);
   } else if (command == RGBW_BRIGHTNESS) {

+ 3 - 5
lib/MiLight/RgbwPacketFormatter.h

@@ -52,8 +52,7 @@ enum MiLightRgbwButton {
 class RgbwPacketFormatter : public PacketFormatter {
 public:
   RgbwPacketFormatter()
-    : PacketFormatter(7),
-      lastMode(0)
+    : PacketFormatter(7)
   { }
 
   virtual bool canHandle(const uint8_t* packet, const size_t len);
@@ -71,14 +70,13 @@ public:
   virtual void previousMode();
   virtual void updateMode(uint8_t mode);
   virtual void enableNightMode();
-  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
 
   virtual void initializePacket(uint8_t* packet);
 
 protected:
-  uint8_t lastMode;
-
   static bool isStatusCommand(const uint8_t command);
+  uint8_t currentMode();
 };
 
 #endif

+ 23 - 0
lib/MiLight/V2PacketFormatter.cpp

@@ -80,3 +80,26 @@ void V2PacketFormatter::format(uint8_t const* packet, char* buffer) {
 uint8_t V2PacketFormatter::groupCommandArg(MiLightStatus status, uint8_t groupId) {
   return GROUP_COMMAND_ARG(status, groupId, numGroups);
 }
+
+// helper method to return a bulb to the prior state
+void V2PacketFormatter::switchMode(GroupState currentState, BulbMode desiredMode) {
+  // revert back to the prior mode
+  switch (desiredMode) {
+    case BulbMode::BULB_MODE_COLOR:
+      updateHue(currentState.getHue());
+      break;
+    case BulbMode::BULB_MODE_NIGHT:
+      enableNightMode();
+      break;
+    case BulbMode::BULB_MODE_SCENE:
+      updateMode(currentState.getMode());
+      break;
+    case BulbMode::BULB_MODE_WHITE:
+      updateColorWhite();
+      break;
+    default:
+      Serial.printf_P(PSTR("V2PacketFormatter::switchMode: Request to switch to unknown mode %d\n"), desiredMode);
+      break;
+  }
+  
+}

+ 1 - 0
lib/MiLight/V2PacketFormatter.h

@@ -29,6 +29,7 @@ public:
 protected:
   const uint8_t protocolId;
   const uint8_t numGroups;
+  void switchMode(GroupState currentState, BulbMode desiredMode);
 };
 
 #endif

+ 279 - 23
lib/MiLightState/GroupState.cpp

@@ -5,6 +5,9 @@
 
 const BulbId DEFAULT_BULB_ID;
 
+// Number of units each increment command counts for
+static const uint8_t INCREMENT_COMMAND_VALUE = 10;
+
 const GroupState& GroupState::defaultState(MiLightRemoteType remoteType) {
   static GroupState instances[MiLightRemoteConfig::NUM_REMOTES];
   GroupState& state = instances[remoteType];
@@ -47,6 +50,9 @@ void BulbId::operator=(const BulbId &other) {
   deviceType = other.deviceType;
 }
 
+// determine if now BulbId's are the same.  This compared deviceID (the controller/remote ID) and
+// groupId (the group number on the controller, 1-4 or 1-8 depending), but ignores the deviceType
+// (type of controller/remote) as this doesn't directly affect the identity of the bulb
 bool BulbId::operator==(const BulbId &other) {
   return deviceId == other.deviceId
     && groupId == other.groupId
@@ -76,6 +82,11 @@ GroupState::GroupState() {
   state.fields._mqttDirty            = 0;
   state.fields._isSetNightMode       = 0;
   state.fields._isNightMode          = 0;
+
+  scratchpad.fields._isSetBrightnessScratch = 0;
+  scratchpad.fields._brightnessScratch      = 0;
+  scratchpad.fields._isSetKelvinScratch     = 0;
+  scratchpad.fields._kelvinScratch          = 0;
 }
 
 bool GroupState::isSetField(GroupStateField field) const {
@@ -83,6 +94,11 @@ bool GroupState::isSetField(GroupStateField field) const {
     case GroupStateField::COMPUTED_COLOR:
       // Always set -- either send RGB color or white
       return true;
+    case GroupStateField::DEVICE_ID:
+    case GroupStateField::GROUP_ID:
+    case GroupStateField::DEVICE_TYPE:
+      // These are always defined
+      return true;
     case GroupStateField::STATE:
     case GroupStateField::STATUS:
       return isSetState();
@@ -111,10 +127,114 @@ bool GroupState::isSetField(GroupStateField field) const {
   return false;
 }
 
+bool GroupState::isSetScratchField(GroupStateField field) const {
+  switch (field) {
+    case GroupStateField::BRIGHTNESS:
+      return scratchpad.fields._isSetBrightnessScratch;
+    case GroupStateField::KELVIN:
+      return scratchpad.fields._isSetKelvinScratch;
+  }
+
+  Serial.print(F("WARNING: tried to check if unknown scratch field was set: "));
+  Serial.println(static_cast<unsigned int>(field));
+
+  return false;
+}
+
+uint16_t GroupState::getFieldValue(GroupStateField field) const {
+  switch (field) {
+    case GroupStateField::STATE:
+    case GroupStateField::STATUS:
+      return getState();
+    case GroupStateField::BRIGHTNESS:
+      return getBrightness();
+    case GroupStateField::HUE:
+      return getHue();
+    case GroupStateField::SATURATION:
+      return getSaturation();
+    case GroupStateField::MODE:
+      return getMode();
+    case GroupStateField::KELVIN:
+      return getKelvin();
+    case GroupStateField::BULB_MODE:
+      return getBulbMode();
+  }
+
+  Serial.print(F("WARNING: tried to fetch value for unknown field: "));
+  Serial.println(static_cast<unsigned int>(field));
+
+  return 0;
+}
+
+uint16_t GroupState::getScratchFieldValue(GroupStateField field) const {
+  switch (field) {
+    case GroupStateField::BRIGHTNESS:
+      return scratchpad.fields._brightnessScratch;
+    case GroupStateField::KELVIN:
+      return scratchpad.fields._kelvinScratch;
+  }
+
+  Serial.print(F("WARNING: tried to fetch value for unknown scratch field: "));
+  Serial.println(static_cast<unsigned int>(field));
+
+  return 0;
+}
+
+void GroupState::setFieldValue(GroupStateField field, uint16_t value) {
+  switch (field) {
+    case GroupStateField::STATE:
+    case GroupStateField::STATUS:
+      setState(static_cast<MiLightStatus>(value));
+      break;
+    case GroupStateField::BRIGHTNESS:
+      setBrightness(value);
+      break;
+    case GroupStateField::HUE:
+      setHue(value);
+      break;
+    case GroupStateField::SATURATION:
+      setSaturation(value);
+      break;
+    case GroupStateField::MODE:
+      setMode(value);
+      break;
+    case GroupStateField::KELVIN:
+      setKelvin(value);
+      break;
+    case GroupStateField::BULB_MODE:
+      setBulbMode(static_cast<BulbMode>(value));
+      break;
+    default:
+      Serial.print(F("WARNING: tried to set value for unknown field: "));
+      Serial.println(static_cast<unsigned int>(field));
+      break;
+  }
+}
+
+void GroupState::setScratchFieldValue(GroupStateField field, uint16_t value) {
+  switch (field) {
+    case GroupStateField::BRIGHTNESS:
+      scratchpad.fields._isSetBrightnessScratch = 1;
+      scratchpad.fields._brightnessScratch = value;
+      break;
+    case GroupStateField::KELVIN:
+      scratchpad.fields._isSetKelvinScratch = 1;
+      scratchpad.fields._kelvinScratch = value;
+      break;
+    default:
+      Serial.print(F("WARNING: tried to set value for unknown scratch field: "));
+      Serial.println(static_cast<unsigned int>(field));
+      break;
+  }
+}
+
 bool GroupState::isSetState() const { return state.fields._isSetState; }
 MiLightStatus GroupState::getState() const { return state.fields._state ? ON : OFF; }
+bool GroupState::isOn() const {
+  return !isNightMode() && (!isSetState() || getState() == MiLightStatus::ON);
+}
 bool GroupState::setState(const MiLightStatus status) {
-  if (isSetState() && getState() == status) {
+  if (!isNightMode() && isSetState() && getState() == status) {
     return false;
   }
 
@@ -122,6 +242,9 @@ bool GroupState::setState(const MiLightStatus status) {
   state.fields._isSetState = 1;
   state.fields._state = status == ON ? 1 : 0;
 
+  // Changing status will clear night mode
+  setNightMode(false);
+
   return true;
 }
 
@@ -308,60 +431,137 @@ bool GroupState::isMqttDirty() const { return state.fields._mqttDirty; }
 bool GroupState::clearMqttDirty() { state.fields._mqttDirty = 0; }
 
 void GroupState::load(Stream& stream) {
-  for (size_t i = 0; i < DATA_BYTES; i++) {
-    stream.readBytes(reinterpret_cast<uint8_t*>(&state.data[i]), 4);
+  for (size_t i = 0; i < DATA_LONGS; i++) {
+    stream.readBytes(reinterpret_cast<uint8_t*>(&state.rawData[i]), 4);
   }
   clearDirty();
 }
 
 void GroupState::dump(Stream& stream) const {
-  for (size_t i = 0; i < DATA_BYTES; i++) {
-    stream.write(reinterpret_cast<const uint8_t*>(&state.data[i]), 4);
+  for (size_t i = 0; i < DATA_LONGS; i++) {
+    stream.write(reinterpret_cast<const uint8_t*>(&state.rawData[i]), 4);
   }
 }
 
+bool GroupState::applyIncrementCommand(GroupStateField field, IncrementDirection dir) {
+  if (field != GroupStateField::KELVIN && field != GroupStateField::BRIGHTNESS) {
+    Serial.print(F("WARNING: tried to apply increment for unsupported field: "));
+    Serial.println(static_cast<uint8_t>(field));
+    return false;
+  }
+
+  int8_t dirValue = static_cast<int8_t>(dir);
+
+  // If there's already a known value, update it
+  if (isSetField(field)) {
+    int8_t currentValue = static_cast<int8_t>(getFieldValue(field));
+    int8_t newValue = currentValue + (dirValue * INCREMENT_COMMAND_VALUE);
+
+#ifdef STATE_DEBUG
+    debugState("Updating field from increment command");
+#endif
+
+    // For now, assume range for both brightness and kelvin is [0, 100]
+    setFieldValue(field, constrain(newValue, 0, 100));
+    return true;
+  // Otherwise start or update scratch state
+  } else {
+    if (isSetScratchField(field)) {
+      int8_t newValue = static_cast<int8_t>(getScratchFieldValue(field)) + dirValue;
+
+      if (newValue == 0 || newValue == 10) {
+        setFieldValue(field, newValue * INCREMENT_COMMAND_VALUE);
+        return true;
+      } else {
+        setScratchFieldValue(field, newValue);
+      }
+    } else if (dir == IncrementDirection::DECREASE) {
+      setScratchFieldValue(field, 9);
+    } else {
+      setScratchFieldValue(field, 1);
+    }
+
+#ifdef STATE_DEBUG
+    Serial.print(F("Updated scratch field: "));
+    Serial.print(static_cast<int8_t>(field));
+    Serial.print(F(" to: "));
+    Serial.println(getScratchFieldValue(field));
+#endif
+  }
+
+  return false;
+}
+
+/*
+  Update group state to reflect a packet state
+
+  Called both when a packet is sent locally, and when an intercepted packet is read
+  (see main.cpp onPacketSentHandler)
+
+  Returns true if the packet changes affects a state change
+*/
 bool GroupState::patch(const JsonObject& state) {
   bool changes = false;
 
+#ifdef STATE_DEBUG
+  Serial.print(F("Patching existing state with: "));
+  state.printTo(Serial);
+  Serial.println();
+#endif
+
   if (state.containsKey("state")) {
-    changes |= setState(state["state"] == "ON" ? ON : OFF);
+    bool stateChange = setState(state["state"] == "ON" ? ON : OFF);
+    changes |= stateChange;
   }
-  if (state.containsKey("brightness")) {
-    changes |= setBrightness(Units::rescale(state.get<uint8_t>("brightness"), 100, 255));
+
+  // Devices do not support changing their state while off, so don't apply state
+  // changes to devices we know are off.
+
+  if (isOn() && state.containsKey("brightness")) {
+    bool stateChange = setBrightness(Units::rescale(state.get<uint8_t>("brightness"), 100, 255));
+    changes |= stateChange;
   }
-  if (state.containsKey("hue")) {
+  if (isOn() && state.containsKey("hue")) {
     changes |= setHue(state["hue"]);
     changes |= setBulbMode(BULB_MODE_COLOR);
   }
-  if (state.containsKey("saturation")) {
+  if (isOn() && state.containsKey("saturation")) {
     changes |= setSaturation(state["saturation"]);
   }
-  if (state.containsKey("mode")) {
+  if (isOn() && state.containsKey("mode")) {
     changes |= setMode(state["mode"]);
     changes |= setBulbMode(BULB_MODE_SCENE);
   }
-  if (state.containsKey("color_temp")) {
+  if (isOn() && state.containsKey("color_temp")) {
     changes |= setMireds(state["color_temp"]);
     changes |= setBulbMode(BULB_MODE_WHITE);
   }
 
-  // Any changes other than setting mode to night should take device out of
-  // night mode.
-  if (changes && getBulbMode() == BULB_MODE_NIGHT) {
-    setNightMode(false);
-  }
-
   if (state.containsKey("command")) {
     const String& command = state["command"];
 
-    if (command == "white_mode") {
+    if (isOn() && command == "set_white") {
       changes |= setBulbMode(BULB_MODE_WHITE);
-      setNightMode(false);
     } else if (command == "night_mode") {
       changes |= setBulbMode(BULB_MODE_NIGHT);
+    } else if (isOn() && command == "brightness_up") {
+      changes |= applyIncrementCommand(GroupStateField::BRIGHTNESS, IncrementDirection::INCREASE);
+    } else if (isOn() && command == "brightness_down") {
+      changes |= applyIncrementCommand(GroupStateField::BRIGHTNESS, IncrementDirection::DECREASE);
+    } else if (isOn() && command == "temperature_up") {
+      changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::INCREASE);
+    } else if (isOn() && command == "temperature_down") {
+      changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::DECREASE);
     }
   }
 
+  if (changes) {
+    debugState("GroupState::patch: State changed");
+  }
+  else {
+    debugState("GroupState::patch: State not changed");
+  }
+
   return changes;
 }
 
@@ -385,7 +585,8 @@ void GroupState::applyColor(ArduinoJson::JsonObject& state, uint8_t r, uint8_t g
   color["b"] = b;
 }
 
-void GroupState::applyField(JsonObject& partialState, GroupStateField field) {
+// gather partial state for a single field; see GroupState::applyState to gather many fields
+void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, GroupStateField field) {
   if (isSetField(field)) {
     switch (field) {
       case GroupStateField::STATE:
@@ -458,12 +659,67 @@ void GroupState::applyField(JsonObject& partialState, GroupStateField field) {
           partialState["kelvin"] = getKelvin();
         }
         break;
+
+      case GroupStateField::DEVICE_ID:
+        partialState["device_id"] = bulbId.deviceId;
+        break;
+
+      case GroupStateField::GROUP_ID:
+        partialState["group_id"] = bulbId.groupId;
+        break;
+
+      case GroupStateField::DEVICE_TYPE:
+        const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(bulbId.deviceType);
+        if (remoteConfig) {
+          partialState["device_type"] = remoteConfig->name;
+        }
+        break;
     }
   }
 }
 
-void GroupState::applyState(JsonObject& partialState, GroupStateField* fields, size_t numFields) {
+// helper function to debug the current state (in JSON) to the serial port
+void GroupState::debugState(char const *debugMessage) {
+#ifdef STATE_DEBUG
+  // using static to keep large buffers off the call stack
+  static StaticJsonBuffer<500> jsonBuffer;
+
+  // define fields to show (if count changes, make sure to update count to applyState below)
+  GroupStateField fields[] {
+      GroupStateField::BRIGHTNESS,
+      GroupStateField::BULB_MODE,
+      GroupStateField::COLOR,
+      GroupStateField::COLOR_TEMP,
+      GroupStateField::COMPUTED_COLOR,
+      GroupStateField::EFFECT,
+      GroupStateField::HUE,
+      GroupStateField::KELVIN,
+      GroupStateField::LEVEL,
+      GroupStateField::MODE,
+      GroupStateField::SATURATION,
+      GroupStateField::STATE,
+      GroupStateField::STATUS };
+
+  // since our buffer is reused, make sure to clear it every time
+  jsonBuffer.clear();
+  JsonObject& jsonState = jsonBuffer.createObject();
+
+  // Fake id
+  BulbId id;
+
+  // use applyState to build JSON of all fields (from above)
+  applyState(jsonState, id, fields, 13);
+  // convert to string and print
+  Serial.printf("%s: ", debugMessage);
+  jsonState.printTo(Serial);
+  Serial.println("");
+#endif
+}
+
+// build up a partial state representation based on the specified GrouipStateField array.  Used
+// to gather a subset of states (configurable in the UI) for sending to MQTT and web responses.
+void GroupState::applyState(JsonObject& partialState, const BulbId& bulbId, GroupStateField* fields, size_t numFields) {
   for (size_t i = 0; i < numFields; i++) {
-    applyField(partialState, fields[i]);
+    applyField(partialState, bulbId, fields[i]);
   }
 }

+ 61 - 8
lib/MiLightState/GroupState.h

@@ -8,6 +8,9 @@
 #ifndef _GROUP_STATE_H
 #define _GROUP_STATE_H
 
+// enable to add debugging on state
+// #define DEBUG_STATE
+
 struct BulbId {
   uint16_t deviceId;
   uint8_t groupId;
@@ -26,6 +29,12 @@ enum BulbMode {
   BULB_MODE_SCENE,
   BULB_MODE_NIGHT
 };
+
+enum class IncrementDirection : unsigned {
+  INCREASE = 1, 
+  DECREASE = -1U
+};
+
 static const char* BULB_MODE_NAMES[] = {
   "white",
   "color",
@@ -39,11 +48,19 @@ public:
   GroupState();
 
   bool isSetField(GroupStateField field) const;
+  uint16_t getFieldValue(GroupStateField field) const;
+  void setFieldValue(GroupStateField field, uint16_t value);
+
+  bool isSetScratchField(GroupStateField field) const;
+  uint16_t getScratchFieldValue(GroupStateField field) const;
+  void setScratchFieldValue(GroupStateField field, uint16_t value);
 
   // 1 bit
   bool isSetState() const;
   MiLightStatus getState() const;
   bool setState(const MiLightStatus on);
+  // Return true if status is ON or if the field is unset (i.e., defaults to ON)
+  bool isOn() const;
 
   // 7 bits
   bool isSetBrightness() const;
@@ -92,18 +109,41 @@ public:
   bool clearMqttDirty();
 
   bool patch(const JsonObject& state);
-  void applyField(JsonObject& state, GroupStateField field);
-  void applyState(JsonObject& state, GroupStateField* fields, size_t numFields);
+
+  // It's a little weird to need to pass in a BulbId here.  The purpose is to
+  // support fields like DEVICE_ID, which aren't otherweise available to the
+  // state in this class.  The alternative is to have every GroupState object
+  // keep a reference to its BulbId, which feels too heavy-weight.
+  void applyField(JsonObject& state, const BulbId& bulbId, GroupStateField field);
+  void applyState(JsonObject& state, const BulbId& bulbId, GroupStateField* fields, size_t numFields);
+
+  // Attempt to keep track of increment commands in such a way that we can
+  // know what state it's in.  When we get an increment command (like "increase 
+  // brightness"):
+  //   1. If there is no value in the scratch state: assume real state is in 
+  //      the furthest value from the direction of the command.  For example, 
+  //      if we get "increase," assume the value was 0.
+  //   2. If there is a value in the scratch state, apply the command to it.
+  //      For example, if we get "decrease," subtract 1 from the scratch.
+  //   3. When scratch reaches a known extreme (either min or max), set the
+  //      persistent field to that value
+  //   4. If there is already a known value for the state, apply it rather
+  //      than messing with scratch state.
+  // 
+  // returns true if a (real, not scratch) state change was made
+  bool applyIncrementCommand(GroupStateField field, IncrementDirection dir);
 
   void load(Stream& stream);
   void dump(Stream& stream) const;
 
+  void debugState(char const *debugMessage);
+
   static const GroupState& defaultState(MiLightRemoteType remoteType);
 
 private:
-  static const size_t DATA_BYTES = 2;
-  union Data {
-    uint32_t data[DATA_BYTES];
+  static const size_t DATA_LONGS = 3;
+  union StateData {
+    uint32_t rawData[DATA_LONGS];
     struct Fields {
       uint32_t
         _state                : 1,
@@ -128,12 +168,25 @@ private:
         _dirty                : 1,
         _mqttDirty            : 1,
         _isSetNightMode       : 1,
-        _isNightMode          : 1,
-                              : 2;
+        _isNightMode          : 1;
+    } fields;
+  };
+
+  // Transient scratchpad that is never persisted.  Used to track and compute state for
+  // protocols that only have increment commands (like CCT).
+  union TransientData {
+    uint16_t rawData;
+    struct Fields {
+      uint16_t 
+        _isSetKelvinScratch     : 1,
+        _kelvinScratch          : 7,
+        _isSetBrightnessScratch : 1,
+        _brightnessScratch      : 8;
     } fields;
   };
 
-  Data state;
+  StateData state;
+  TransientData scratchpad;
 
   void applyColor(JsonObject& state, uint8_t r, uint8_t g, uint8_t b);
   void applyColor(JsonObject& state);

+ 13 - 1
lib/MiLightState/GroupStateStore.cpp

@@ -21,6 +21,13 @@ GroupState& GroupStateStore::get(const BulbId& id) {
   return *state;
 }
 
+GroupState& GroupStateStore::get(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType) {
+  BulbId bulbId(deviceId, groupId, deviceType);
+  return get(bulbId);
+}
+
+// save state for a bulb.  If id.groupId == 0, will iternate across all groups
+// and individually save each group (recursively)
 GroupState& GroupStateStore::set(const BulbId &id, const GroupState& state) {
   GroupState& storedState = get(id);
   storedState = state;
@@ -34,10 +41,15 @@ GroupState& GroupStateStore::set(const BulbId &id, const GroupState& state) {
       set(individualBulb, state);
     }
   }
-
+  
   return storedState;
 }
 
+GroupState& GroupStateStore::set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state) {
+  BulbId bulbId(deviceId, groupId, deviceType);
+  return set(bulbId, state);
+}
+
 void GroupStateStore::trackEviction() {
   if (cache.isFull()) {
     evictedIds.add(cache.getLru());

+ 2 - 0
lib/MiLightState/GroupStateStore.h

@@ -14,12 +14,14 @@ public:
    * default state will be returned.
    */
   GroupState& get(const BulbId& id);
+  GroupState& get(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType);
 
   /*
    * Sets the state for the given BulbId.  State will be marked as dirty and
    * flushed to persistent storage.
    */
   GroupState& set(const BulbId& id, const GroupState& state);
+  GroupState& set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state);
 
   /*
    * Flushes all states to persistent storage.  Returns true iff anything was

+ 26 - 0
lib/Settings/Settings.cpp

@@ -87,6 +87,7 @@ void Settings::patch(JsonObject& parsedSettings) {
     this->setIfPresent(parsedSettings, "ce_pin", cePin);
     this->setIfPresent(parsedSettings, "csn_pin", csnPin);
     this->setIfPresent(parsedSettings, "reset_pin", resetPin);
+    this->setIfPresent(parsedSettings, "led_pin", ledPin);
     this->setIfPresent(parsedSettings, "packet_repeats", packetRepeats);
     this->setIfPresent(parsedSettings, "http_repeat_factor", httpRepeatFactor);
     this->setIfPresent(parsedSettings, "auto_restart_period", _autoRestartPeriod);
@@ -103,6 +104,24 @@ void Settings::patch(JsonObject& parsedSettings) {
     this->setIfPresent(parsedSettings, "packet_repeat_throttle_threshold", packetRepeatThrottleThreshold);
     this->setIfPresent(parsedSettings, "packet_repeat_throttle_sensitivity", packetRepeatThrottleSensitivity);
     this->setIfPresent(parsedSettings, "packet_repeat_minimum", packetRepeatMinimum);
+    this->setIfPresent(parsedSettings, "enable_automatic_mode_switching", enableAutomaticModeSwitching);
+    this->setIfPresent(parsedSettings, "led_mode_packet_count", ledModePacketCount);
+
+    if (parsedSettings.containsKey("led_mode_wifi_config")) {
+      this->ledModeWifiConfig = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_config"]);
+    }
+
+    if (parsedSettings.containsKey("led_mode_wifi_failed")) {
+      this->ledModeWifiFailed = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_failed"]);
+    }
+
+    if (parsedSettings.containsKey("led_mode_operating")) {
+      this->ledModeOperating = LEDStatus::stringToLEDMode(parsedSettings["led_mode_operating"]);
+    }
+
+    if (parsedSettings.containsKey("led_mode_packet")) {
+      this->ledModePacket = LEDStatus::stringToLEDMode(parsedSettings["led_mode_packet"]);
+    }
 
     if (parsedSettings.containsKey("radio_interface_type")) {
       this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]);
@@ -162,6 +181,7 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
   root["ce_pin"] = this->cePin;
   root["csn_pin"] = this->csnPin;
   root["reset_pin"] = this->resetPin;
+  root["led_pin"] = this->ledPin;
   root["radio_interface_type"] = typeToString(this->radioInterfaceType);
   root["packet_repeats"] = this->packetRepeats;
   root["http_repeat_factor"] = this->httpRepeatFactor;
@@ -179,6 +199,12 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
   root["packet_repeat_throttle_sensitivity"] = this->packetRepeatThrottleSensitivity;
   root["packet_repeat_throttle_threshold"] = this->packetRepeatThrottleThreshold;
   root["packet_repeat_minimum"] = this->packetRepeatMinimum;
+  root["enable_automatic_mode_switching"] = this->enableAutomaticModeSwitching;
+  root["led_mode_wifi_config"] = LEDStatus::LEDModeToString(this->ledModeWifiConfig);
+  root["led_mode_wifi_failed"] = LEDStatus::LEDModeToString(this->ledModeWifiFailed);
+  root["led_mode_operating"] = LEDStatus::LEDModeToString(this->ledModeOperating);
+  root["led_mode_packet"] = LEDStatus::LEDModeToString(this->ledModePacket);
+  root["led_mode_packet_count"] = this->ledModePacketCount;
 
   if (this->deviceIds) {
     JsonArray& arr = jsonBuffer.createArray();

+ 17 - 1
lib/Settings/Settings.h

@@ -3,6 +3,7 @@
 #include <ArduinoJson.h>
 #include <GroupStateField.h>
 #include <Size.h>
+#include <LEDStatus.h>
 
 #ifndef _SETTINGS_H_INCLUDED
 #define _SETTINGS_H_INCLUDED
@@ -74,6 +75,7 @@ public:
     cePin(16),
     csnPin(15),
     resetPin(0),
+    ledPin(-2),
     radioInterfaceType(nRF24),
     deviceIds(NULL),
     gatewayConfigs(NULL),
@@ -90,7 +92,13 @@ public:
     packetRepeatThrottleSensitivity(0),
     packetRepeatMinimum(3),
     groupStateFields(NULL),
-    numGroupStateFields(0)
+    numGroupStateFields(0),
+    enableAutomaticModeSwitching(false),
+    ledModeWifiConfig(LEDStatus::LEDMode::FastToggle),
+    ledModeWifiFailed(LEDStatus::LEDMode::On),
+    ledModeOperating(LEDStatus::LEDMode::SlowBlip),
+    ledModePacket(LEDStatus::LEDMode::Flicker),
+    ledModePacketCount(3)
   {
     if (groupStateFields == NULL) {
       numGroupStateFields = size(DEFAULT_GROUP_STATE_FIELDS);
@@ -130,6 +138,7 @@ public:
   uint8_t cePin;
   uint8_t csnPin;
   uint8_t resetPin;
+  int8_t ledPin;
   RadioInterfaceType radioInterfaceType;
   uint16_t *deviceIds;
   GatewayConfig **gatewayConfigs;
@@ -152,6 +161,13 @@ public:
   size_t packetRepeatThrottleSensitivity;
   size_t packetRepeatThrottleThreshold;
   size_t packetRepeatMinimum;
+  bool enableAutomaticModeSwitching;
+  LEDStatus::LEDMode ledModeWifiConfig;
+  LEDStatus::LEDMode ledModeWifiFailed;
+  LEDStatus::LEDMode ledModeOperating;
+  LEDStatus::LEDMode ledModePacket;
+  size_t ledModePacketCount;
+
 
 protected:
   size_t _autoRestartPeriod;

+ 8 - 2
lib/Types/GroupStateField.h

@@ -15,7 +15,10 @@ static const char* STATE_NAMES[] = {
   "color_temp",
   "bulb_mode",
   "computed_color",
-  "effect"
+  "effect",
+  "device_id",
+  "group_id",
+  "device_type"
 };
 
 enum class GroupStateField {
@@ -32,7 +35,10 @@ enum class GroupStateField {
   COLOR_TEMP,
   BULB_MODE,
   COMPUTED_COLOR,
-  EFFECT
+  EFFECT,
+  DEVICE_ID,
+  GROUP_ID,
+  DEVICE_TYPE
 };
 
 class GroupStateFieldHelpers {

+ 80 - 69
lib/WebServer/MiLightHttpServer.cpp

@@ -11,74 +11,31 @@
 void MiLightHttpServer::begin() {
   applySettings(settings);
 
-  server.on("/", HTTP_GET, handleServe_P(index_html_gz, index_html_gz_len));
-  server.on("/settings", HTTP_GET, [this]() { serveSettings(); });
-  server.on("/settings", HTTP_PUT, [this]() { handleUpdateSettings(); });
-  server.on("/settings", HTTP_POST,
-    [this]() {
-      Settings::load(settings);
-      server.send_P(200, TEXT_PLAIN, PSTR("success."));
-    },
-    handleUpdateFile(SETTINGS_FILE)
-  );
-  server.on("/radio_configs", HTTP_GET, [this]() { handleGetRadioConfigs(); });
+  // set up HTTP end points to serve
+
+  _handleRootPage = handleServe_P(index_html_gz, index_html_gz_len);
+  server.onAuthenticated("/", HTTP_GET, [this]() { _handleRootPage(); });
+  server.onAuthenticated("/settings", HTTP_GET, [this]() { serveSettings(); });
+  server.onAuthenticated("/settings", HTTP_PUT, [this]() { handleUpdateSettings(); });
+  server.onAuthenticated("/settings", HTTP_POST, [this]() { handleUpdateSettingsPost(); }, handleUpdateFile(SETTINGS_FILE));
+  server.onAuthenticated("/radio_configs", HTTP_GET, [this]() { handleGetRadioConfigs(); });
 
-  server.on("/gateway_traffic", HTTP_GET, [this]() { handleListenGateway(NULL); });
-  server.onPattern("/gateway_traffic/:type", HTTP_GET, [this](const UrlTokenBindings* b) { handleListenGateway(b); });
+  server.onAuthenticated("/gateway_traffic", HTTP_GET, [this]() { handleListenGateway(NULL); });
+  server.onPatternAuthenticated("/gateway_traffic/:type", HTTP_GET, [this](const UrlTokenBindings* b) { handleListenGateway(b); });
 
   const char groupPattern[] = "/gateways/:device_id/:type/:group_id";
-  server.onPattern(groupPattern, HTTP_PUT, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
-  server.onPattern(groupPattern, HTTP_POST, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
-  server.onPattern(groupPattern, HTTP_GET, [this](const UrlTokenBindings* b) { handleGetGroup(b); });
+  server.onPatternAuthenticated(groupPattern, HTTP_PUT, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
+  server.onPatternAuthenticated(groupPattern, HTTP_POST, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
+  server.onPatternAuthenticated(groupPattern, HTTP_GET, [this](const UrlTokenBindings* b) { handleGetGroup(b); });
 
-  server.onPattern("/raw_commands/:type", HTTP_ANY, [this](const UrlTokenBindings* b) { handleSendRaw(b); });
-  server.on("/web", HTTP_POST, [this]() { server.send_P(200, TEXT_PLAIN, PSTR("success")); }, handleUpdateFile(WEB_INDEX_FILENAME));
+  server.onPatternAuthenticated("/raw_commands/:type", HTTP_ANY, [this](const UrlTokenBindings* b) { handleSendRaw(b); });
+  server.onAuthenticated("/web", HTTP_POST, [this]() { server.send_P(200, TEXT_PLAIN, PSTR("success")); }, handleUpdateFile(WEB_INDEX_FILENAME));
   server.on("/about", HTTP_GET, [this]() { handleAbout(); });
-  server.on("/system", HTTP_POST, [this]() { handleSystemPost(); });
-  server.on("/firmware", HTTP_POST,
-    [this](){
-      server.sendHeader("Connection", "close");
-      server.sendHeader("Access-Control-Allow-Origin", "*");
-
-      if (Update.hasError()) {
-        server.send_P(
-          500,
-          TEXT_PLAIN,
-          PSTR("Failed updating firmware. Check serial logs for more information. You may need to re-flash the device.")
-        );
-      } else {
-        server.send_P(
-          200,
-          TEXT_PLAIN,
-          PSTR("Success. Device will now reboot.")
-        );
-      }
+  server.onAuthenticated("/system", HTTP_POST, [this]() { handleSystemPost(); });
+  server.onAuthenticated("/firmware", HTTP_POST, [this]() { handleFirmwarePost(); }, [this]() { handleFirmwareUpload(); });
 
-      delay(1000);
 
-      ESP.restart();
-    },
-    [this](){
-      HTTPUpload& upload = server.upload();
-      if(upload.status == UPLOAD_FILE_START){
-        WiFiUDP::stopAll();
-        uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
-        if(!Update.begin(maxSketchSpace)){//start with max available size
-          Update.printError(Serial);
-        }
-      } else if(upload.status == UPLOAD_FILE_WRITE){
-        if(Update.write(upload.buf, upload.currentSize) != upload.currentSize){
-          Update.printError(Serial);
-        }
-      } else if(upload.status == UPLOAD_FILE_END){
-        if(Update.end(true)){ //true to set the size to the current progress
-        } else {
-          Update.printError(Serial);
-        }
-      }
-      yield();
-    }
-  );
+  // set up web socket server
   wsServer.onEvent(
     [this](uint8_t num, WStype_t type, uint8_t * payload, size_t length) {
       handleWsEvent(num, type, payload, length);
@@ -240,14 +197,69 @@ void MiLightHttpServer::handleUpdateSettings() {
     settings.save();
 
     this->applySettings(settings);
-    this->settingsSavedHandler();
+
+    if (this->settingsSavedHandler) {
+      this->settingsSavedHandler();
+    }
 
     server.send(200, APPLICATION_JSON, "true");
+    Serial.println(F("Settings successfully updated"));
   } else {
     server.send(400, APPLICATION_JSON, "\"Invalid JSON\"");
+    Serial.println(F("Settings failed to update; invalid JSON"));
+  }
+}
+
+void MiLightHttpServer::handleUpdateSettingsPost() {
+  Settings::load(settings);
+  server.send_P(200, TEXT_PLAIN, PSTR("success."));
+}
+
+void MiLightHttpServer::handleFirmwarePost() {
+  server.sendHeader("Connection", "close");
+  server.sendHeader("Access-Control-Allow-Origin", "*");
+
+  if (Update.hasError()) {
+    server.send_P(
+      500,
+      TEXT_PLAIN,
+      PSTR("Failed updating firmware. Check serial logs for more information. You may need to re-flash the device.")
+    );
+  } else {
+    server.send_P(
+      200,
+      TEXT_PLAIN,
+      PSTR("Success. Device will now reboot.")
+    );
   }
+
+  delay(1000);
+
+  ESP.restart();
 }
 
+void MiLightHttpServer::handleFirmwareUpload() {
+  HTTPUpload& upload = server.upload();
+  if(upload.status == UPLOAD_FILE_START){
+    WiFiUDP::stopAll();
+    uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
+    if(!Update.begin(maxSketchSpace)){//start with max available size
+      Update.printError(Serial);
+    }
+  } else if(upload.status == UPLOAD_FILE_WRITE){
+    if(Update.write(upload.buf, upload.currentSize) != upload.currentSize){
+      Update.printError(Serial);
+    }
+  } else if(upload.status == UPLOAD_FILE_END){
+    if(Update.end(true)){ //true to set the size to the current progress
+    } else {
+      Update.printError(Serial);
+    }
+  }
+  yield();
+}
+
+
 void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
   bool available = false;
   bool listenAll = bindings == NULL;
@@ -303,11 +315,11 @@ void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
   server.send(200, "text/plain", response);
 }
 
-void MiLightHttpServer::sendGroupState(GroupState &state) {
+void MiLightHttpServer::sendGroupState(BulbId& bulbId, GroupState &state) {
   String body;
   StaticJsonBuffer<200> jsonBuffer;
   JsonObject& obj = jsonBuffer.createObject();
-  state.applyState(obj, settings.groupStateFields, settings.numGroupStateFields);
+  state.applyState(obj, bulbId, settings.groupStateFields, settings.numGroupStateFields);
   obj.printTo(body);
 
   server.send(200, APPLICATION_JSON, body);
@@ -327,7 +339,7 @@ void MiLightHttpServer::handleGetGroup(const UrlTokenBindings* urlBindings) {
 
   BulbId bulbId(parseInt<uint16_t>(_deviceId), _groupId, _remoteType->type);
   GroupState& state = stateStore->get(bulbId);
-  sendGroupState(stateStore->get(bulbId));
+  sendGroupState(bulbId, stateStore->get(bulbId));
 }
 
 void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
@@ -388,7 +400,7 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
   }
 
   if (groupCount == 1) {
-    sendGroupState(stateStore->get(foundBulbId));
+    sendGroupState(foundBulbId, stateStore->get(foundBulbId));
   } else {
     server.send(200, APPLICATION_JSON, "true");
   }
@@ -459,7 +471,6 @@ void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRemoteCon
       packetLen,
       formattedPacket
     );
-
     wsServer.broadcastTXT(reinterpret_cast<uint8_t*>(responseBuffer));
   }
 }
@@ -467,11 +478,11 @@ void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRemoteCon
 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.sendContent("");
     server.client().stop();
   };
 }
+

+ 6 - 2
lib/WebServer/MiLightHttpServer.h

@@ -17,7 +17,7 @@ const char APPLICATION_JSON[] = "application/json";
 class MiLightHttpServer {
 public:
   MiLightHttpServer(Settings& settings, MiLightClient*& milightClient, GroupStateStore*& stateStore)
-    : server(WebServer(80)),
+    : server(80),
       wsServer(WebSocketsServer(81)),
       numWsClients(0),
       milightClient(milightClient),
@@ -45,12 +45,15 @@ protected:
   ESP8266WebServer::THandlerFunction handleUpdateFile(const char* filename);
   ESP8266WebServer::THandlerFunction handleServe_P(const char* data, size_t length);
   void applySettings(Settings& settings);
-  void sendGroupState(GroupState& state);
+  void sendGroupState(BulbId& bulbId, GroupState& state);
 
   void handleUpdateSettings();
+  void handleUpdateSettingsPost();
   void handleGetRadioConfigs();
   void handleAbout();
   void handleSystemPost();
+  void handleFirmwareUpload();
+  void handleFirmwarePost();
   void handleListenGateway(const UrlTokenBindings* urlBindings);
   void handleSendRaw(const UrlTokenBindings* urlBindings);
   void handleUpdateGroup(const UrlTokenBindings* urlBindings);
@@ -68,6 +71,7 @@ protected:
   GroupStateStore*& stateStore;
   SettingsSavedHandler settingsSavedHandler;
   size_t numWsClients;
+  ESP8266WebServer::THandlerFunction _handleRootPage;
 
 };
 

+ 48 - 65
lib/WebServer/WebServer.cpp

@@ -1,85 +1,68 @@
 #include <WebServer.h>
 #include <PatternHandler.h>
 
-void WebServer::onPattern(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn fn) {
-  addHandler(new PatternHandler(pattern, method, fn));
+void WebServer::onAuthenticated(const String &uri, THandlerFunction handler) {
+  THandlerFunction authHandler = [this, handler]() {
+    if (this->validateAuthentiation()) {
+      handler();
+    }
+  };
+
+  ESP8266WebServer::on(uri, authHandler);
 }
 
-void WebServer::requireAuthentication(const String& username, const String& password) {
-  this->username = String(username);
-  this->password = String(password);
-  this->authEnabled = true;
+void WebServer::onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction handler) {
+  THandlerFunction authHandler = [this, handler]() {
+    if (this->validateAuthentiation()) {
+      handler();
+    }
+  };
+
+  ESP8266WebServer::on(uri, method, authHandler);
 }
 
-void WebServer::disableAuthentication() {
-  this->authEnabled = false;
+void WebServer::onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction handler, THandlerFunction ufn) {
+  THandlerFunction authHandler = [this, handler]() {
+    if (this->validateAuthentiation()) {
+      handler();
+    }
+  };
+
+  ESP8266WebServer::on(uri, method, authHandler, ufn);
 }
 
-void WebServer::_handleRequest() {
-  if (this->authEnabled
-    && !this->authenticate(this->username.c_str(), this->password.c_str())) {
-    this->requestAuthentication();
-  } else {
-    ESP8266WebServer::_handleRequest();
-  }
+void WebServer::onPattern(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn handler) {
+  addHandler(new PatternHandler(pattern, method, handler));
 }
 
-void WebServer::handleClient() {
-  if (_currentStatus == HC_NONE) {
-    WiFiClient client = _server.available();
-    if (!client) {
-      return;
+void WebServer::onPatternAuthenticated(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn fn) {
+  PatternHandler::TPatternHandlerFn authHandler = [this, fn](UrlTokenBindings* bindings) {
+    if (this->validateAuthentiation()) {
+      fn(bindings);
     }
+  };
 
-    _currentClient = client;
-    _currentStatus = HC_WAIT_READ;
-    _statusChange = millis();
-  }
+  addHandler(new PatternHandler(pattern, method, authHandler));
+}
 
-  if (!_currentClient.connected()) {
-    _currentClient = WiFiClient();
-    _currentStatus = HC_NONE;
-    return;
-  }
 
-  // Wait for data from client to become available
-  if (_currentStatus == HC_WAIT_READ) {
-    if (!_currentClient.available()) {
-      if (millis() - _statusChange > HTTP_MAX_DATA_WAIT) {
-        _currentClient = WiFiClient();
-        _currentStatus = HC_NONE;
-      }
-      yield();
-      return;
-    }
 
-    if (!_parseRequest(_currentClient)) {
-      _currentClient = WiFiClient();
-      _currentStatus = HC_NONE;
-      return;
-    }
-    _currentClient.setTimeout(HTTP_MAX_SEND_WAIT);
-    _contentLength = CONTENT_LENGTH_NOT_SET;
-    _handleRequest();
+void WebServer::requireAuthentication(const String& username, const String& password) {
+  this->username = String(username);
+  this->password = String(password);
+  this->authEnabled = true;
+}
 
-    if (!_currentClient.connected()) {
-      _currentClient = WiFiClient();
-      _currentStatus = HC_NONE;
-      return;
-    } else {
-      _currentStatus = HC_WAIT_CLOSE;
-      _statusChange = millis();
-      return;
-    }
-  }
+void WebServer::disableAuthentication() {
+  this->authEnabled = false;
+}
 
-  if (_currentStatus == HC_WAIT_CLOSE) {
-    if (millis() - _statusChange > HTTP_MAX_CLOSE_WAIT) {
-      _currentClient = WiFiClient();
-      _currentStatus = HC_NONE;
-    } else {
-      yield();
-      return;
+bool WebServer::validateAuthentiation() {
+  if (this->authEnabled && 
+    !authenticate(this->username.c_str(), this->password.c_str())) {
+      requestAuthentication();
+      return false;
     }
-  }
+    return true;
 }
+

+ 7 - 9
lib/WebServer/WebServer.h

@@ -7,9 +7,6 @@
 #include <PatternHandler.h>
 
 #define HTTP_DOWNLOAD_UNIT_SIZE 1460
-#define HTTP_UPLOAD_BUFLEN 2048
-#define HTTP_MAX_DATA_WAIT 1000 //ms to wait for the client to send the request
-#define HTTP_MAX_POST_WAIT 1000 //ms to wait for POST data to arrive
 #define HTTP_MAX_SEND_WAIT 5000 //ms to wait for data chunk to be ACKed
 #define HTTP_MAX_CLOSE_WAIT 2000 //ms to wait for the client to close the connection
 
@@ -17,20 +14,20 @@ class WebServer : public ESP8266WebServer {
 public:
   WebServer(int port) : ESP8266WebServer(port) { }
 
-  bool matchesPattern(const String& pattern, const String& url);
+  void onAuthenticated(const String &uri, THandlerFunction handler);
+  void onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction fn);
+  void onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction fn, THandlerFunction ufn);
   void onPattern(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn fn);
+  void onPatternAuthenticated(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn handler);
+  bool matchesPattern(const String& pattern, const String& url);
   void requireAuthentication(const String& username, const String& password);
   void disableAuthentication();
+  bool validateAuthentiation();
 
   inline bool clientConnected() {
     return _currentClient && _currentClient.connected();
   }
 
-  // These are copied / patched from ESP8266WebServer because they aren't
-  // virtual. (*barf*)
-  void handleClient();
-  void _handleRequest();
-
   bool authenticationRequired() {
     return authEnabled;
   }
@@ -40,6 +37,7 @@ protected:
   bool authEnabled;
   String username;
   String password;
+
 };
 
 #endif

+ 5 - 3
platformio.ini

@@ -10,22 +10,24 @@
 
 [common]
 framework = arduino
-platform = https://github.com/platformio/platform-espressif8266.git#feature/stage
+platform = https://github.com/platformio/platform-espressif8266.git
 board_f_cpu = 160000000L
 lib_deps_builtin =
   SPI
 lib_deps_external =
   sidoh/RF24
-  WiFiManager
+;  WiFiManager
+  https://github.com/cmidgley/WiFiManager
   ArduinoJson
   PubSubClient
   https://github.com/ratkins/RGBConverter
   Hash
   WebSockets
   CircularBuffer
+  ESP8266WebServer
 extra_scripts =
   pre:.build_web.py
-build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -Idist -Ilib/DataStructures
+build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -DHTTP_UPLOAD_BUFLEN=128 -Idist -Ilib/DataStructures
 # -D DEBUG_PRINTF
 # -D MQTT_DEBUG
 # -D MILIGHT_UDP_DEBUG

+ 51 - 10
src/main.cpp

@@ -19,9 +19,12 @@
 #include <MiLightDiscoveryServer.h>
 #include <MiLightClient.h>
 #include <BulbStateUpdater.h>
+#include <LEDStatus.h>
 
 WiFiManager wifiManager;
 
+static LEDStatus *ledStatus;
+
 Settings settings;
 
 MiLightClient* milightClient = NULL;
@@ -84,7 +87,11 @@ void initMilightUdpServers() {
 void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) {
   StaticJsonBuffer<200> buffer;
   JsonObject& result = buffer.createObject();
-  BulbId bulbId = config.packetFormatter->parsePacket(packet, result, stateStore);
+  BulbId bulbId = config.packetFormatter->parsePacket(packet, result);
+
+
+  // set LED mode for a packet movement
+  ledStatus->oneshot(settings.ledModePacket, settings.ledModePacketCount);
 
   if (&bulbId == &DEFAULT_BULB_ID) {
     Serial.println(F("Skipping packet handler because packet was not decoded"));
@@ -94,6 +101,7 @@ void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) {
   const MiLightRemoteConfig& remoteConfig =
     *MiLightRemoteConfig::fromType(bulbId.deviceType);
 
+  // update state to reflect changes from this packet
   GroupState& groupState = stateStore->get(bulbId);
   groupState.patch(result);
   stateStore->set(bulbId, groupState);
@@ -141,6 +149,7 @@ void handleListen() {
         return;
       }
 
+      // update state to reflect this packet
       onPacketSentHandler(readPacket, *remoteConfig);
     }
   }
@@ -197,10 +206,8 @@ void applySettings() {
 
   milightClient = new MiLightClient(
     radioFactory,
-    *stateStore,
-    settings.packetRepeatThrottleThreshold,
-    settings.packetRepeatThrottleSensitivity,
-    settings.packetRepeatMinimum
+    stateStore,
+    &settings
   );
   milightClient->begin();
   milightClient->onPacketSent(onPacketSentHandler);
@@ -224,6 +231,12 @@ void applySettings() {
     discoveryServer = new MiLightDiscoveryServer(settings);
     discoveryServer->begin();
   }
+
+  // update LED pin and operating mode
+  if (ledStatus) {
+    ledStatus->changePin(settings.ledPin);
+    ledStatus->continuous(settings.ledModeOperating);
+  }
 }
 
 /**
@@ -237,20 +250,45 @@ bool shouldRestart() {
   return settings.getAutoRestartPeriod()*60*1000 < millis();
 }
 
+// give a bit of time to update the status LED
+void handleLED() {
+  ledStatus->handle();
+}
+
 void setup() {
   Serial.begin(9600);
   String ssid = "ESP" + String(ESP.getChipId());
-
-  wifiManager.setConfigPortalTimeout(180);
-  wifiManager.autoConnect(ssid.c_str(), "milightHub");
-
+  
+  // load up our persistent settings from the file system
   SPIFFS.begin();
   Settings::load(settings);
   applySettings();
 
+  // set up the LED status for wifi configuration
+  ledStatus = new LEDStatus(settings.ledPin);
+  ledStatus->continuous(settings.ledModeWifiConfig);
+
+  // start up the wifi manager
   if (! MDNS.begin("milight-hub")) {
     Serial.println(F("Error setting up MDNS responder"));
   }
+  // tell Wifi manager to call us during the setup.  Note that this "setSetupLoopCallback" is an addition
+  // made to Wifi manager in a private fork.  As of this writing, WifiManager has a new feature coming that
+  // allows the "autoConnect" method to be non-blocking which can implement this same functionality.  However,
+  // that change is only on the development branch so we are going to continue to use this fork until
+  // that is merged and ready.
+  wifiManager.setSetupLoopCallback(handleLED);
+  wifiManager.setConfigPortalTimeout(180);
+  if (wifiManager.autoConnect(ssid.c_str(), "milightHub")) {
+    // set LED mode for successful operation
+    ledStatus->continuous(settings.ledModeOperating);
+    Serial.println(F("Wifi connected succesfully\n"));
+  } else {
+    // set LED mode for Wifi failed
+    ledStatus->continuous(settings.ledModeWifiFailed);
+    Serial.println(F("Wifi failed.  Oh well.\n"));
+  }
+
 
   MDNS.addService("http", "tcp", 80);
 
@@ -267,7 +305,7 @@ void setup() {
   httpServer->on("/description.xml", HTTP_GET, []() { SSDP.schema(httpServer->client()); });
   httpServer->begin();
 
-  Serial.println(F("Setup complete"));
+  Serial.printf_P(PSTR("Setup complete (version %s)\n"), QUOTE(MILIGHT_HUB_VERSION));
 }
 
 void loop() {
@@ -292,6 +330,9 @@ void loop() {
 
   stateStore->limitedFlush();
 
+  // update LED with status
+  ledStatus->handle();
+
   if (shouldRestart()) {
     Serial.println(F("Auto-restart triggered. Restarting..."));
     ESP.restart();

+ 63 - 2
web/src/css/style.css

@@ -11,9 +11,13 @@ label:not(.error) .error-info { display: none; }
 .error-info:before { content: '('; }
 .error-info:after { content: ')'; }
 .header-btn { margin: 20px; }
-.dropdown { position: initial; overflow: auto; }
-.dropdown-menu li { display: block; }
+.gateway-add-btn { float: right; }
+#content .dropdown { position: initial; overflow: auto; }
+#content .dropdown-menu li { display: block; }
+#traffic-sniff { display: none; }
 #sniffed-traffic { max-height: 50em; overflow-y: auto; }
+.navbar { background-color: rgba(41,41,45,.9) !important; border-radius: 0 }
+.navbar-brand, .navbar-brand:hover { color: white; }
 .btn-secondary {
   background-color: #fff;
   border: 1px solid #ccc;
@@ -94,6 +98,63 @@ label:not(.error) .error-info { display: none; }
   -webkit-animation: spin2 1s infinite linear;
 }
 
+html {
+  position: relative;
+  min-height: 100%;
+}
+body {
+  margin-bottom: 200px;
+}
+footer {
+  height: 200px;
+}
+@media only screen and (min-width : 320px) {
+  body {
+    margin-bottom: 250px;
+  }
+}
+@media only screen and (min-width : 480px) {
+  body {
+    margin-bottom: 200px;
+  }
+}
+@media only screen and (min-width : 768px) {
+  body {
+    margin-bottom: 158px;
+  }
+}
+@media only screen and (min-width : 992px) {
+  body {
+    margin-bottom: 116px;
+  }
+}
+
+.modal-body .section-title {
+  border-bottom: 1px solid #eee;
+  padding-bottom: 0.2em;
+}
+
+#sniff .glyphicon-play {
+  color: #6f6;
+}
+
+#sniff .glyphicon-stop {
+  color: #f66;
+}
+
+ul.action-buttons {
+  list-style: none;
+  padding: 0;
+}
+
+.action-buttons li {
+  display: inline-block;
+}
+
+.action-buttons li:not(:first-child) {
+  margin-left: 0.5em;
+}
+
 @keyframes spin {
   from { transform: scale(1) rotate(0deg); }
   to { transform: scale(1) rotate(360deg); }

+ 221 - 190
web/src/index.html

@@ -25,129 +25,47 @@
   <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="//cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.12.4/js/bootstrap-select.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.12.4/js/bootstrap-select.min.js"></script>
 
   <script src="js/rgb2hsv.js" lang="text/javascript"></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>
+  <nav class="navbar navbar-inverse">
+    <div class="container">
+      <div class="navbar-header">
+        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
+          <span class="sr-only">Toggle navigation</span>
+          <span class="icon-bar"></span>
+          <span class="icon-bar"></span>
+          <span class="icon-bar"></span>
+        </button>
+        <a class="navbar-brand" href="/">MiLight Hub</a>
       </div>
-
-    </div>
-  </div>
-
-  <div class="container">
-    <div class="row header-row">
-      <div class="col-sm-12">
-        <h1>
-          Control Lights
-        </h1>
+      <div class="collapse navbar-collapse">
+        <ul class="nav navbar-nav">
+          <li><a href="#settings" data-toggle="modal" data-target="#settings-modal">Settings</a></li>
+          <li>
+            <a href="#sniff" id="sniff">
+              <i class="glyphicon glyphicon-play"></i>
+              <span>Start Sniffing</span>
+            </a>
+          </li>
+          <li class="dropdown">
+            <a href="#admin" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Admin <span class="caret"></span></a>
+            <ul class="dropdown-menu">
+              <li><a href="#admin-actions" data-toggle="modal" data-target="#admin-actions-modal">Actions</a></li>
+              <li role="separator" class="divider"></li>
+              <li><a href="#check-updates" id="updates-btn" data-toggle="modal" data-target="#updates-modal">Check for Updates</a></li>
+              <li><a href="#updates" data-toggle="modal" data-target="#update-firmware-modal">Update Firmware</a></li>
+              <li><a href="#backup" data-toggle="modal" data-target="#backup-settings-modal">Backup</a></li>
+            </ul>
+          </li>
+        </ul>
       </div>
     </div>
+  </nav>
 
-    <div>&nbsp;</div>
-
+  <div class="container" id="content">
     <div class="row">
       <div class="col-sm-3">
         <label for="deviceId" id="device-id-label">
@@ -306,6 +224,9 @@
             </li>
           </div>
           <li>
+            <button type="button" class="btn btn-info command-btn" data-command="night_mode">Night</button>
+          </li>
+          <li>
             <button type="button" class="btn btn-success command-btn" data-command="pair">
               <i class="glyphicon glyphicon-plus"></i>
               Pair
@@ -364,106 +285,216 @@
       </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>
+    <!-- The content of this modal is dynamically injected into the settings modal -->
+    <div id="gateway-servers-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">Gateway Servers</h2>
+          </div>
+          <div class="modal-body">
+
+            <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>
+                        <th>
+                          <a class="btn btn-success gateway-add-btn" id="add-server-btn">
+                            <i class="glyphicon glyphicon-plus"></i>
+                          </a>
+                        </th>
+                      </tr>
+                    </thead>
+                    <tbody id="gateway-server-configs">
+                    </tbody>
+                  </table>
+                </form>
+              </div>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
 
     <div>&nbsp;</div>
 
-    <div class="row header-row">
-      <div class="col-sm-12">
-        <h1>Settings</h1>
-      </div>
-    </div>
+    <div id="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">Settings</h2>
+          </div>
+          <div class="modal-body">
 
-    <div>&nbsp;</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 class="row">
+              <div class="col-sm-12">
+                <form action="#" id="settings">
+                  <input type="submit" class="btn btn-success" value="Submit" />
+                </form>
+              </div>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
 
-    <div class="row header-row">
-      <div class="col-sm-12">
-        <h1>Sniff Traffic</h1>
+    <div class="alert alert-warning alert-block" id="traffic-sniff">
+      <button type="button" class="close" id="traffic-sniff-close">&times;</button>
+      <h4>Traffic sniffed (sent and received)</h4>
+      <div id="sniffed-traffic"></div>
+    </div>
+  </div>
+
+  <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>&nbsp;</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>
 
-    <div class="row">
-      <div class="col-sm-12">
-        <button type="button" id="sniff" class="btn btn-primary">Start Sniffing</button>
+         <hr />
 
-        <div> &nbsp; </div>
+         <h4>Current Version</h4>
+         <div id="current-version"></div>
 
-        <div id="sniffed-traffic"></div>
-      </div>
-    </div>
+         <div id="latest-version">
+           <h4>Latest Version</h4>
 
-    <div class="row header-row">
-      <div class="col-sm-12">
-        <h1>Admin</h1>
-      </div>
-    </div>
+           <div class="status"></div>
+           <div id="latest-version-info">
+             <label>Version</label>
+             <div class="info-key" data-key="tag_name"></div>
 
-    <div>&nbsp;</div>
+             <label>Release Date</label>
+             <div class="info-key" data-key="published_at"></div>
 
-    <div class="row">
-      <div class="col-sm-12">
-        <button type="button" class="btn btn-danger system-btn" data-command="restart">
-          Restart
-        </button>
+             <label>Release Notes</label>
+             <pre class="info-key" data-key="body"></pre>
 
-        <button type="button" class="btn btn-danger system-btn" data-command="clear_wifi_config">
-          Clear Wifi Config
-        </button>
+             <div>
+               <a class="info-key" data-prop="href" data-key="html_url">View on GitHub</a>
+             </div>
 
-        <button type="button" id="updates-btn" class="btn btn-primary">
-          Check for Updates
-        </button>
+             <div>
+               <a class="info-key" id="firmware-link">Download Firmware</a>
+             </div>
+           </div>
+         </div>
+       </div>
+       <div class="modal-footer">
+         <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+       </div>
+     </div>
+   </div>
+  </div>
 
-        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#update-firmware-modal">
-          Update Firmware
-        </button>
+  <div id="backup-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>
+        </div>
+        <div class="modal-body">
+          <div>
+            <h3 class="section-title">Backup Settings</h3>
+            <a href="/settings" download="settings.json" class="btn btn-primary">Backup Settings</a>
+          </div>
+
+          <div>
+            <h3 class="section-title">Restore Settings</h3>
+            <form action="/settings" method="post" enctype="multipart/form-data">
+              <p>
+                <input type="file" name="file"/>
+              </p>
+              <p>
+                <input type="submit" name="submit" class="btn btn-success"/>
+              </p>
+            </form>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+        </div>
+      </div>
 
-        <a href="/settings" download="settings.json" class="btn btn-primary">Backup Settings</a>
+    </div>
+  </div>
 
-        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#restore-settings-modal">
-          Restore Settings
-        </button>
+  <div id="admin-actions-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">Admin Actions</h2>
+        </div>
+        <div class="modal-body">
+          <ul class="action-buttons">
+            <li>
+              <button class="btn btn-danger btn-sm system-btn" data-command="restart">Restart</button>
+            </li>
+            <li>
+              <button class="btn btn-danger btn-sm system-btn" data-command="clear_wifi_config">Clear Wifi Config</button>
+            </li>
+          </ul>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+        </div>
       </div>
+
     </div>
   </div>
 </body>

+ 411 - 127
web/src/js/script.js

@@ -4,13 +4,216 @@ var UNIT_PARAMS = {
   maxBrightness: 255
 };
 
-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_state_topic_pattern",
-  "mqtt_username", "mqtt_password", "radio_interface_type", "listen_repeats",
-  "state_flush_interval", "mqtt_state_rate_limit", "packet_repeat_throttle_threshold",
-  "packet_repeat_throttle_sensitivity", "packet_repeat_minimum", "group_state_fields"
+var UI_TABS = [ {
+    tag: "tab-wifi",
+    friendly: "Wifi",
+  }, {
+    tag: "tab-setup",
+    friendly: "Setup",
+  }, {
+    tag: "tab-led",
+    friendly: "LED",
+  }, {
+    tag: "tab-radio",
+    friendly: "Radio",
+  }, {
+    tag: "tab-mqtt",
+    friendly: "MQTT"
+  }
+];
+
+var UI_FIELDS = [ {
+    tag: "admin_username",
+    friendly: "Admin username",
+    help: "Username for logging into this webpage",
+    type: "string",
+    tab: "tab-wifi"
+  }, {
+    tag: "admin_password",
+    friendly: "Password",
+    help: "Password for logging into this webpage",
+    type: "string",
+    tab: "tab-wifi"
+  }, {
+    tag: "ce_pin",
+    friendly: "CE / PKT pin",
+    help: "Pin on ESP8266 used for 'CE' (for NRF24L01 interface) or 'PKT' (for 'PL1167/LT8900' interface)",
+    type: "string",
+    tab: "tab-setup"
+  }, {
+    tag: "csn_pin",
+    friendly: "CSN pin",
+    help: "Pin on ESP8266 used for 'CSN'",
+    type: "string",
+    tab: "tab-setup"
+  }, {
+    tag: "reset_pin",
+    friendly: "RESET pin",
+    help: "Pin on ESP8266 used for 'RESET'",
+    type: "string",
+    tab: "tab-setup"
+  }, {
+    tag: "led_pin",
+    friendly: "LED pin",
+    help: "Pin to use for LED status display (0=disabled); negative inverses signal (recommend -2 for on-board LED)",
+    type: "string",
+    tab: "tab-setup"
+  }, {
+    tag: "packet_repeats",
+    friendly: "Packet repeats",
+    help: "The number of times to repeat RF packets sent to bulbs",
+    type: "string",
+    tab: "tab-radio"
+  }, {
+    tag: "http_repeat_factor",
+    friendly: "HTTP repeat factor",
+    help: "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",
+    type: "string",
+    tab: "tab-wifi"
+  }, {
+    tag: "auto_restart_period",
+    friendly: "Auto-restart period",
+    help: "Automatically restart the device every number of minutes specified. Use 0 for disabled",
+    type: "string",
+    tab: "tab-setup"
+  }, {
+    tag: "discovery_port",
+    friendly: "Discovery port",
+    help: "UDP port to listen for discovery packets on. Defaults to the same port used by MiLight devices, 48899. Use 0 to disable",
+    type: "string",
+    tab: "tab-wifi"
+  }, {
+    tag: "mqtt_server",
+    friendly: "MQTT server",
+    help: "Domain or IP address of MQTT broker. Optionally specify a port with (example) myMQTTbroker.com:1884",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag: "mqtt_topic_pattern", 
+    friendly: "MQTT topic pattern",
+    help: "Pattern for MQTT topics to listen on. Example: lights/:device_id/:device_type/:group_id. See README for further details",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "mqtt_update_topic_pattern", 
+    friendly: "MQTT update topic pattern",
+    help: "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",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "mqtt_state_topic_pattern",
+    friendly: "MQTT state topic pattern",
+    help: "Pattern for MQTT topic to publish state to. When a group changes state, the full known state of the group will be published to this topic pattern",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "mqtt_username", 
+    friendly: "MQTT user name",
+    help: "User name to log in to MQTT server",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "mqtt_password", 
+    friendly: "MQTT password",
+    help: "Password to log into MQTT server",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "radio_interface_type", 
+    friendly: "Radio interface type",
+    help: "2.4 GHz radio model. Only change this if you know you're not using an NRF24L01!",
+    type: "radio_interface_type",
+    tab: "tab-radio"
+  }, {
+    tag:   "listen_repeats",
+    friendly: "Listen repeats",
+    help: "Increasing this increases the amount of time spent listening for " +
+    "packets. Set to 0 to disable listening. Default is 3.",
+    type: "string",
+    tab: "tab-wifi"
+  }, {
+    tag:   "state_flush_interval", 
+    friendly: "State flush interval",
+    help: "Minimum number of milliseconds between flushing state to flash. " +
+    "Set to 0 to disable delay and immediately persist state to flash",
+    type: "string",
+    tab: "tab-setup"
+  }, {
+    tag:   "mqtt_state_rate_limit", 
+    friendly: "MQTT state rate limit",
+    help: "Minimum number of milliseconds between MQTT updates of bulb state (defaults to 500)",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "packet_repeat_throttle_threshold",
+    friendly: "Packet repeat throttle threshold",
+    help: "Controls how packet repeats are throttled.  Packets sent " +
+    "with less time between them than this value (in milliseconds) will cause " +
+    "packet repeats to be throttled down.  More than this value will unthrottle " +
+    "up.  Defaults to 200ms",
+    type: "string",
+    tab: "tab-radio"
+  }, {
+    tag:   "packet_repeat_throttle_sensitivity", 
+    friendly: "Packet repeat throttle sensitivity",
+    help: "Controls how packet repeats are throttled. " +
+    "Higher values cause packets to be throttled up and down faster " +
+    "(defaults to 0, maximum value 1000, 0 disables)",
+    type: "string",
+    tab: "tab-radio"
+  }, {
+    tag:   "packet_repeat_minimum", 
+    friendly: "Packet repeat minimum",
+    help: "Controls how far throttling can decrease the number " +
+    "of repeated packets (defaults to 3)",
+    type: "string",
+    tab: "tab-radio"
+  }, {
+    tag:   "group_state_fields",
+    friendly: "Group state fields",
+    help: "Selects which fields should be included in MQTT state updates and REST responses for bulb state",
+    type: "group_state_fields",
+    tab: "tab-mqtt"
+  }, {
+    tag:   "enable_automatic_mode_switching", 
+    friendly: "Enable automatic mode switching",
+    help: "For RGBWW bulbs (using RGB+CCT or FUT089), enables automatic switching between modes in order to affect changes to " +
+    "temperature and saturation when otherwise it would not work",
+    type: "enable_automatic_mode_switching",
+    tab: "tab-radio"
+  }, {
+    tag:   "led_mode_wifi_config",
+    friendly: "LED mode during wifi config",
+    help: "LED mode when the device is in Access Point mode waiting to configure Wifi",
+    type: "led_mode",
+    tab: "tab-led"
+  }, {
+    tag:   "led_mode_wifi_failed",
+    friendly: "LED mode when wifi failed to connect",
+    help: "LED mode when the device has failed to connect to the wifi network",
+    type: "led_mode",
+    tab: "tab-led"
+  }, {
+    tag:   "led_mode_operating",
+    friendly: "LED mode when operating",
+    help: "LED mode when the device is in successfully running",
+    type: "led_mode",
+    tab: "tab-led"
+  }, {
+    tag:   "led_mode_packet",
+    friendly: "LED mode on packets",
+    help: "LED mode when the device is sending or receiving packets",
+    type: "led_mode",
+    tab: "tab-led"
+  }, {
+    tag:   "led_mode_packet_count",
+    friendly: "Flash count on packets",
+    help: "Number of times the LED will flash when packets are changing",
+    type: "string",
+    tab: "tab-led"    
+  }
 ];
 
 // TODO: sync this with GroupStateField.h
@@ -27,49 +230,21 @@ var GROUP_STATE_KEYS = [
   "color_temp",
   "bulb_mode",
   "computed_color",
-  "effect"
+  "effect",
+  "device_id",
+  "group_id",
+  "device_type"
 ];
 
-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.",
-  mqtt_state_topic_pattern : "Pattern for MQTT topic to publish state to. When a group " +
-    "changes state, the full known state of the group will be published to this topic " +
-    "pattern.",
-  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.",
-  state_flush_interval : "Minimum number of milliseconds between flushing state to flash. " +
-    "Set to 0 to disable delay and immediately persist state to flash.",
-  mqtt_state_rate_limit : "Minimum number of milliseconds between MQTT updates of bulb state. " +
-    "Defaults to 500.",
-  packet_repeat_throttle_threshold : "Controls how packet repeats are throttled.  Packets sent " +
-    "with less time between them than this value (in milliseconds) will cause " +
-    "packet repeats to be throttled down.  More than this value will unthrottle " +
-    "up.  Defaults to 200ms",
-  packet_repeat_throttle_sensitivity : "Controls how packet repeats are throttled. " +
-    "Higher values cause packets to be throttled up and down faster.  Set to 0 " +
-    "to disable throttling.  Defaults to 1.  Maximum value 1000.",
-  packet_repeat_minimum : "Controls how far throttling can decrease the number " +
-    "of repeated packets.  Defaults to 3.",
-  group_state_fields : "Selects which fields should be included in MQTT state updates and " +
-    "REST responses for bulb state."
-}
+var LED_MODES = [
+  "Off",
+  "Slow toggle",
+  "Fast toggle",
+  "Slow blip",
+  "Fast blip",
+  "Flicker",
+  "On"
+];
 
 var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
 var DEFAULT_UDP_PROTOCL_VERSION = 5;
@@ -77,11 +252,14 @@ var DEFAULT_UDP_PROTOCL_VERSION = 5;
 var selectize;
 var sniffing = false;
 
-var webSocket = new WebSocket("ws://" + location.hostname + ":81");
-webSocket.onmessage = function(e) {
-  if (sniffing) {
-    var message = e.data;
-    $('#sniffed-traffic').prepend('<pre>' + message + '</pre>');
+// don't attempt websocket if we are debugging locally
+if (location.hostname != "") {
+  var webSocket = new WebSocket("ws://" + location.hostname + ":81");
+  webSocket.onmessage = function(e) {
+    if (sniffing) {
+      var message = e.data;
+      $('#sniffed-traffic').prepend('<pre>' + message + '</pre>');
+    }
   }
 }
 
@@ -102,6 +280,10 @@ var activeUrl = function() {
     groupId = 0;
   }
 
+  if (typeof groupId === "undefined") {
+    throw "Must enter group ID";
+  }
+
   return "/gateways/" + deviceId + "/" + mode + "/" + groupId;
 }
 
@@ -118,7 +300,6 @@ var updateGroup = _.throttle(
         data: JSON.stringify(params),
         contentType: 'application/json',
         success: function(e) {
-          console.log(e);
           handleStateUpdate(e);
         }
       });
@@ -175,6 +356,11 @@ var gatewayServerRow = function(deviceId, port, version) {
 }
 
 var loadSettings = function() {
+  $('select.select-init').selectpicker();
+  if (location.hostname == "") {
+    // if deugging locally, don't try get settings
+    return;
+  }
   $.getJSON('/settings', function(val) {
     Object.keys(val).forEach(function(k) {
       var field = $('#settings input[name="' + k + '"]');
@@ -201,6 +387,26 @@ var loadSettings = function() {
       elmt.selectpicker('val', val.group_state_fields);
     }
 
+    if (val.led_mode_wifi_config) {
+      var elmt = $('select[name="led_mode_wifi_config"]');
+      elmt.selectpicker('val', val.led_mode_wifi_config);
+    }
+
+    if (val.led_mode_wifi_failed) {
+      var elmt = $('select[name="led_mode_wifi_failed"]');
+      elmt.selectpicker('val', val.led_mode_wifi_failed);
+    }
+
+    if (val.led_mode_operating) {
+      var elmt = $('select[name="led_mode_operating"]');
+      elmt.selectpicker('val', val.led_mode_operating);
+    }
+
+    if (val.led_mode_packet) {
+      var elmt = $('select[name="led_mode_packet"]');
+      elmt.selectpicker('val', val.led_mode_packet);
+    }
+
     var gatewayForm = $('#gateway-server-configs').html('');
     if (val.gateway_configs) {
       val.gateway_configs.forEach(function(v) {
@@ -211,7 +417,7 @@ var loadSettings = function() {
 };
 
 var saveGatewayConfigs = function() {
-  var form = $('#gateway-server-form')
+  var form = $('#tab-udp-gateways')
     , errors = false;
 
   $('input', form).removeClass('error');
@@ -276,7 +482,10 @@ var updateModeOptions = function() {
     if ($(this).data('for').split(',').includes(currentMode)) {
       $(this).show();
     } else {
-      $(this).hide();
+      $(this)
+        // De-select unselectable group
+        .removeClass('active')
+        .hide();
     }
   });
 };
@@ -352,7 +561,6 @@ var handleCheckForUpdates = function() {
   $('#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',
@@ -378,7 +586,6 @@ var handleCheckForUpdates = function() {
 };
 
 var handleStateUpdate = function(state) {
-  console.log(state);
   if (state.state) {
     // Set without firing an event
     $('input[name="status"]')
@@ -418,6 +625,27 @@ var updatePreviewColor = function(hue, saturation, lightness) {
   });
 };
 
+var stopSniffing = function() {
+  var elmt = $('#sniff');
+
+  sniffing = false;
+  $('i', elmt)
+    .removeClass('glyphicon-stop')
+    .addClass('glyphicon-play');
+  $('span', elmt).html('Start Sniffing');
+};
+
+var startSniffing = function() {
+  var elmt = $('#sniff');
+
+  sniffing = true;
+  $('i', elmt)
+    .removeClass('glyphicon-play')
+    .addClass('glyphicon-stop');
+  $('span', elmt).html('Stop Sniffing');
+  $("#traffic-sniff").show();
+};
+
 $(function() {
   $('.radio-option').click(function() {
     $(this).prev().prop('checked', true);
@@ -471,17 +699,24 @@ $(function() {
     sendCommand({command: $(this).data('command')});
   });
 
-  $('#sniff').click(function() {
+  $('#sniff').click(function(e) {
+    e.preventDefault();
+
     if (sniffing) {
-      sniffing = false;
-      $(this).html('Start Sniffing');
+      stopSniffing();
     } else {
-      sniffing = true;
-      $(this).html('Stop Sniffing');
+      startSniffing();
     }
   });
 
-  $('#add-server-btn').click(function() {
+  $('#traffic-sniff-close').click(function() {
+    stopSniffing();
+    $('#traffic-sniff').hide();
+  });
+
+  $('body').on('click', '#add-server-btn', function(e) {
+    e.preventDefault();
+    console.log('hi');
     $('#gateway-server-configs').append(gatewayServerRow('', ''));
   });
 
@@ -540,89 +775,138 @@ $(function() {
   });
   selectize = selectize[0].selectize;
 
-  var settings = "";
+  var settings = '<ul class="nav nav-tabs" id="setupTabs">';
+  var tabClass = 'active';
+  UI_TABS.forEach(function(t) {
+    settings += '<li class="' + tabClass + '"><a href="#' + t.tag + '" data-toggle="tab">' + t.friendly + '</a></li>';
+    tabClass = '';
+  });
+  settings += '<li><a href="#tab-udp-gateways" data-toggle="tab">UDP</a></li>';
+  settings += "</ul>";
 
-  FORM_SETTINGS.forEach(function(k) {
-    var elmt = '<div class="form-entry">';
-    elmt += '<div>';
-    elmt += '<label for="' + k + '">' + k + '</label>';
+  settings += '<div class="tab-content">';
 
-    if (FORM_SETTINGS_HELP[k]) {
-      elmt += '<div class="field-help" data-help-text="' + FORM_SETTINGS_HELP[k] + '"></div>';
-    }
+  tabClass = 'active in';
 
-    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 if (k == 'group_state_fields') {
-      elmt += '<select class="selectpicker" name="group_state_fields" multiple>';
-      GROUP_STATE_KEYS.forEach(function(stateKey) {
-        elmt += '<option>' + stateKey + '</option>';
-      });
-      elmt += '</select>';
-    } else {
-      elmt += '<input type="text" class="form-control" name="' + k + '"/>';
-      elmt += '</div>';
-    }
+  UI_TABS.forEach(function(t) {
+    settings += '<div class="tab-pane fade ' + tabClass + '" id="' + t.tag + '">';
+    tabClass = '';
+    UI_FIELDS.forEach(function(k) {
+      if (k.tab == t.tag) {
+        var elmt = '<div class="form-entry">';
+        elmt += '<div>';
+        elmt += '<label for="' + k.tag + '">' + k.friendly + '</label>';
 
-    settings += elmt;
+        if (k.help) {
+          elmt += '<div class="field-help" data-help-text="' + k.help + '"></div>';
+        }
+
+        elmt += '</div>';
+
+        if (k.type === "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 if (k.type == 'group_state_fields') {
+          elmt += '<select class="selectpicker select-init" name="group_state_fields" multiple>';
+          GROUP_STATE_KEYS.forEach(function(stateKey) {
+            elmt += '<option>' + stateKey + '</option>';
+          });
+          elmt += '</select>';
+        } else if (k.type == 'led_mode') {
+          elmt += '<select class="selectpicker select-init" name="' + k.tag + '">';
+          LED_MODES.forEach(function(stateKey) {
+            elmt += '<option>' + stateKey + '</option>';
+          });
+          elmt += '</select>';
+        } else if (k.type == 'enable_automatic_mode_switching') {
+          elmt += '<div class="btn-group" id="enable_automatic_mode_switching" data-toggle="buttons">' +
+            '<label class="btn btn-secondary active">' +
+              '<input type="radio" id="enable_mode_switching" name="enable_automatic_mode_switching" autocomplete="off" value="true" /> Enable' +
+            '</label>'+
+            '<label class="btn btn-secondary">' +
+              '<input type="radio" id="disable_mode_switching" name="enable_automatic_mode_switching" autocomplete="off" value="false" /> Disable' +
+            '</label>' +
+          '</div>';
+        } else {
+          elmt += '<input type="text" class="form-control" name="' + k.tag + '"/>';
+        }
+        elmt += '</div>';
+
+        settings += elmt;
+      }
+    });
+    settings += "</div>";
   });
+  
+  // UDP gateways tab
+  settings += '<div class="tab-pane fade ' + tabClass + '" id="tab-udp-gateways">';
+  settings += $('#gateway-servers-modal .modal-body').remove().html();
+  settings += '</div>';
+
+  settings += "</div>";
 
   $('#settings').prepend(settings);
+
   $('#settings').submit(function(e) {
     e.preventDefault();
 
-    var obj = $('#settings')
-      .serializeArray()
-      .reduce(function(a, x) {
-        var val = a[x.name];
-
-        if (! val) {
-          a[x.name] = x.value;
-        } else if (! Array.isArray(val)) {
-          a[x.name] = [val, x.value];
-        } else {
-          val.push(x.value);
+    // Save UDP settings separately from the rest of the stuff since input is handled differently
+    if ($('#tab-udp-gateways').hasClass('active')) {
+      saveGatewayConfigs();
+    } else {
+      var obj = $('#settings')
+        .serializeArray()
+        .reduce(function(a, x) {
+          var val = a[x.name];
+
+          if (! val) {
+            a[x.name] = x.value;
+          } else if (! Array.isArray(val)) {
+            a[x.name] = [val, x.value];
+          } else {
+            val.push(x.value);
+          }
+
+          return a;
+        }, {});
+
+      // pretty hacky. whatever.
+      obj.device_ids = _.map(
+        $('.selectize-control .option'),
+        function(x) {
+          return $(x).data('value')
         }
+      );
 
-        return a;
-      }, {});
-
-    // pretty hacky. whatever.
-    obj.device_ids = _.map(
-      $('.selectize-control .option'),
-      function(x) {
-        return $(x).data('value')
-      }
-    );
-
-    // Make sure we're submitting a value for group_state_fields (will be empty
-    // if no values were selected).
-    obj = $.extend({group_state_fields: []}, obj);
+      // Make sure we're submitting a value for group_state_fields (will be empty
+      // if no values were selected).
+      obj = $.extend({group_state_fields: []}, obj);
 
-    $.ajax(
-      "/settings",
-      {
-        method: 'put',
-        contentType: 'application/json',
-        data: JSON.stringify(obj)
-      }
-    );
+      $.ajax(
+        "/settings",
+        {
+          method: 'put',
+          contentType: 'application/json',
+          data: JSON.stringify(obj)
+        }
+      );
+    }
 
+    $('#settings-modal').modal('hide');
+    
     return false;
   });
 
   $('#gateway-server-form').submit(function(e) {
     saveGatewayConfigs();
     e.preventDefault();
+    $('#gateway-servers-modal').modal('hide');
     return false;
   });