소스 검색

Merge pull request #144 from sidoh/v1.6.0

v1.6.0 release
Chris Mullins 8 년 전
부모
커밋
4cde86aafd
79개의 변경된 파일3269개의 추가작업 그리고 1225개의 파일을 삭제
  1. 2 2
      .build_web.py
  2. 1 0
      .gitignore
  3. 2 2
      .travis.yml
  4. 0 51
      PROTOCOL.md
  5. 40 12
      README.md
  6. 2 2
      dist/index.html.gz.h
  7. 316 0
      lib/DataStructures/LinkedList.h
  8. 6 1
      lib/Helpers/Size.h
  9. 5 12
      lib/Helpers/Units.h
  10. 59 0
      lib/MQTT/BulbStateUpdater.cpp
  11. 34 0
      lib/MQTT/BulbStateUpdater.h
  12. 58 23
      lib/MQTT/MqttClient.cpp
  13. 18 1
      lib/MQTT/MqttClient.h
  14. 14 9
      lib/MiLight/CctPacketFormatter.cpp
  15. 3 1
      lib/MiLight/CctPacketFormatter.h
  16. 101 0
      lib/MiLight/FUT089PacketFormatter.cpp
  17. 45 0
      lib/MiLight/FUT089PacketFormatter.h
  18. 0 17
      lib/MiLight/MiLightButtons.h
  19. 121 80
      lib/MiLight/MiLightClient.cpp
  20. 55 13
      lib/MiLight/MiLightClient.h
  21. 0 32
      lib/MiLight/MiLightRadioConfig.cpp
  22. 0 66
      lib/MiLight/MiLightRadioConfig.h
  23. 108 0
      lib/MiLight/MiLightRemoteConfig.cpp
  24. 49 0
      lib/MiLight/MiLightRemoteConfig.h
  25. 0 415
      lib/MiLight/PL1167_nRF24.cpp
  26. 15 4
      lib/MiLight/PacketFormatter.cpp
  27. 11 6
      lib/MiLight/PacketFormatter.h
  28. 39 157
      lib/MiLight/RgbCctPacketFormatter.cpp
  29. 9 28
      lib/MiLight/RgbCctPacketFormatter.h
  30. 8 8
      lib/MiLight/RgbPacketFormatter.cpp
  31. 1 1
      lib/MiLight/RgbPacketFormatter.h
  32. 29 16
      lib/MiLight/RgbwPacketFormatter.cpp
  33. 14 1
      lib/MiLight/RgbwPacketFormatter.h
  34. 82 0
      lib/MiLight/V2PacketFormatter.cpp
  35. 34 0
      lib/MiLight/V2PacketFormatter.h
  36. 66 0
      lib/MiLight/V2RFEncoding.cpp
  37. 21 0
      lib/MiLight/V2RFEncoding.h
  38. 390 0
      lib/MiLightState/GroupState.cpp
  39. 133 0
      lib/MiLightState/GroupState.h
  40. 63 0
      lib/MiLightState/GroupStateCache.cpp
  41. 33 0
      lib/MiLightState/GroupStateCache.h
  42. 40 0
      lib/MiLightState/GroupStatePersistence.cpp
  43. 18 0
      lib/MiLightState/GroupStatePersistence.h
  44. 85 0
      lib/MiLightState/GroupStateStore.cpp
  45. 46 0
      lib/MiLightState/GroupStateStore.h
  46. 46 106
      lib/MiLight/LT8900MiLightRadio.cpp
  47. 1 2
      lib/MiLight/LT8900MiLightRadio.h
  48. 18 0
      lib/Radio/MiLightConstants.h
  49. 0 0
      lib/Radio/MiLightRadio.h
  50. 8 0
      lib/Radio/MiLightRadioConfig.cpp
  51. 40 0
      lib/Radio/MiLightRadioConfig.h
  52. 0 0
      lib/Radio/MiLightRadioFactory.cpp
  53. 1 1
      lib/MiLight/MiLightRadioFactory.h
  54. 1 1
      lib/MiLight/NRF24MiLightRadio.cpp
  55. 0 0
      lib/Radio/NRF24MiLightRadio.h
  56. 274 0
      lib/Radio/PL1167_nRF24.cpp
  57. 0 0
      lib/Radio/PL1167_nRF24.h
  58. 43 0
      lib/Settings/Settings.cpp
  59. 45 4
      lib/Settings/Settings.h
  60. 20 0
      lib/Types/GroupStateField.cpp
  61. 40 0
      lib/Types/GroupStateField.h
  62. 18 0
      lib/Types/MiLightConstants.h
  63. 29 18
      lib/Udp/V5MiLightUdpServer.cpp
  64. 5 0
      lib/Udp/V5MiLightUdpServer.h
  65. 1 1
      lib/Udp/V6CctCommandHandler.h
  66. 1 1
      lib/Udp/V6ComamndHandler.cpp
  67. 4 4
      lib/Udp/V6CommandHandler.h
  68. 6 6
      lib/Udp/V6MiLightUdpServer.cpp
  69. 1 1
      lib/Udp/V6RgbCctCommandHandler.h
  70. 1 1
      lib/Udp/V6RgbCommandHandler.h
  71. 1 1
      lib/Udp/V6RgbwCommandHandler.h
  72. 99 45
      lib/WebServer/MiLightHttpServer.cpp
  73. 9 3
      lib/WebServer/MiLightHttpServer.h
  74. 21 14
      platformio.ini
  75. 116 20
      src/main.cpp
  76. 3 1
      web/src/css/style.css
  77. 41 11
      web/src/index.html
  78. 48 0
      web/src/js/rgb2hsv.js
  79. 152 24
      web/src/js/script.js

+ 2 - 2
.build_web.py

@@ -15,7 +15,7 @@ def is_tool(name):
     except:
         return False;
 
-def pre_build(source, target, env):
+def build_web():
     if is_tool("npm"):
         os.chdir("web")
         print("Attempting to build webpage...")
@@ -31,4 +31,4 @@ def pre_build(source, target, env):
         finally:
             os.chdir("..");
 
-env.Execute(pre_build)
+build_web()

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@
 .gcc-flags.json
 /web/node_modules
 /web/build
+/dist/*.bin

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 2 - 2
.travis.yml


+ 0 - 51
PROTOCOL.md

@@ -1,51 +0,0 @@
-# Protocol
-
-Here are some sloppy notes on the 2.4GHz protocols. Borrows from [Henryk's work](https://hackaday.io/project/5888-reverse-engineering-the-milight-on-air-protocol).
-
-## Structure
-
-As far as I can tell, packets are:
-
-* Unidirectional. Bulbs never send packets.
-* Always 7 bytes.
-* Not fully byte-oriented. One field uses fractional bytes.
-
-The packets are structured as follows:
-
-
-| Field           | Length  | Notes                                                                                     |
-|-----------------|---------|-------------------------------------------------------------------------------------------|
-| Request Type    | 1 byte  | One of 0xB0 or 0xB8. Seems to be ignored.                                                                     |
-| Device ID       | 2 bytes |                                                                                           |
-| Color           | 1 byte  | Maps roughly to `((hue + 40)%360)*(255/359.0)`                                              |
-| Brightness      | 5 bits  | Values are [0,25] and aren't ordered intuitively. See extended notes for further details. |
-| Group ID        | 3 bits  | Should be in [1,4].                                                                       |
-| Button ID       | 1 byte  | Should be in [0x0,0x1A]. See extended notes for list.                                     |
-| Sequence Number | 1 byte  | Used by receiver to detect duplicates. Should be incremented each time packets are sent.  |
-
-## Extended Notes
-
-### Brightness
-
-Though the field is 5 bits, only values in the range [0,25] are used. Values from least bright to most bright are:
-
-```
-[16, 15, ..., 0, 31, ..., 23]
-```
-
-To map a value `x` in `[0,100]` to the protocol value, use:
-
-```c++
-  // Expect an input value in [0, 100]. Map it down to [0, 25].
-  const uint8_t adjustedBrightness = round(brightness * (25 / 100.0));
-  
-  // The actual protocol uses a bizarre range where min is 16, max is 23:
-  // [16, 15, ..., 0, 31, ..., 23]
-  const uint8_t packetBrightnessValue = (
-    ((31 - adjustedBrightness) + 17) % 32
-  );
-```
-
-### Button ID
-
-Basically determines what the request does. See a list of values in [the code](https://github.com/sidoh/esp8266_milight_hub/blob/master/lib/MiLight/MiLightClient.h#L16).

+ 40 - 12
README.md

@@ -20,7 +20,7 @@ Support has been added for the following [bulb types](http://futlight.com/produc
 1. RGBW bulbs: FUT014, FUT016, FUT103
 1. Dual-White (CCT) bulbs: FUT019
 1. RGB LED strips: FUT025
-1. RGB + Dual White (RGB+CCT) bulbs: FUT015
+1. RGB + Dual White (RGB+CCT) bulbs: FUT015, FUT105
 
 Other bulb types might work, but have not been tested. It is also relatively easy to add support for new bulb types.
 
@@ -57,6 +57,12 @@ 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
@@ -65,6 +71,8 @@ This project uses [WiFiManager](https://github.com/tzapu/WiFiManager) to avoid t
 
 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.
 
+The network password is "**milightHub**".
+
 #### Get IP Address
 
 Both mDNS and SSDP are supported.
@@ -90,6 +98,7 @@ The HTTP endpoints (shown below) will be fully functional at this point. You sho
 1. `GET /radio_configs`. Get a list of supported radio configs (aka `device_type`s).
 1. `GET /gateway_traffic(/:device_type)?`. Starts an HTTP long poll. Returns any Milight traffic it hears. Useful if you need to know what your Milight gateway/remote ID is. Since protocols for RGBW/CCT are different, specify one of `rgbw`, `cct`, or `rgb_cct` as `:device_type.  The path `/gateway_traffic` without a `:device_type` will sniff for all protocols simultaneously.
 1. `PUT /gateways/:device_id/:device_type/:group_id`. Controls or sends commands to `:group_id` from `:device_id`. Accepts a JSON blob. The schema is documented below in the _Bulb commands_ section.
+1. `GET /gateways/:device_id/:device_type/:group_id`. Returns a JSON blob describing the state of the the provided group.
 1. `POST /raw_commands/:device_type`. Sends a raw RF packet with radio configs associated with `:device_type`. Example body:
     ```
     {"packet": "01 02 03 04 05 06 07 08 09", "num_repeats": 10}
@@ -156,11 +165,15 @@ To configure your ESP to integrate with MQTT, fill out the following settings:
 
 #### More detail on `mqtt_topic_pattern`
 
-`mqtt_topic_pattern` leverages single-level wildcards (documented [here](https://mosquitto.org/man/mqtt-7.html)). For example, specifying `milight/:device_id/:device_type/:group_id` will cause the ESP to subscribe to the topic `milight/+/+/+`. It will then interpret the second, third, and fourth tokens in topics it receives messages on as `:device_id`, `:device_type`, and `:group_id`, respectively.
+`mqtt_topic_pattern` leverages single-level wildcards (documented [here](https://mosquitto.org/man/mqtt-7.html)). For example, specifying `milight/:device_id/:device_type/:group_id` will cause the ESP to subscribe to the topic `milight/+/+/+`. It will then interpret the second, third, and fourth tokens in topics it receives messages on as `:device_id`, `:device_type`, and `:group_id`, respectively.  The following tokens are available:
+
+1. `:device_id` - Device ID. Can be hexadecimal (e.g. `0x1234`) or decimal (e.g. `4660`).
+1. `:device_type` - Remote type.  `rgbw`, `fut089`, etc.
+1. `:group_id` - Group.  0-4 for most remotes.  The "All" group is group 0.
 
 Messages should be JSON objects using exactly the same schema that the REST gateway uses for the `/gateways/:device_id/:device_type/:group_id` endpoint. Documented above in the _Bulb commands_ section.
 
-##### Example:
+#### Example:
 
 If `mqtt_topic_pattern` is set to `milight/:device_id/:device_type/:group_id`, you could send the following message to it (the below example uses a ruby MQTT client):
 
@@ -175,26 +188,41 @@ This will instruct the ESP to send messages to RGB+CCT bulbs with device ID `0x1
 
 #### Updates
 
-To enable passive listening, make sure that `listen_repeats` is set to something larger than 0 (the default value of 3 is a good choice).
+ESPMH is capable of providing two types of updates:
 
-To publish data from intercepted packets to an MQTT topic, configure MQTT server settings, and set the `mqtt_update_topic_pattern` to something of your choice. As with `mqtt_topic_pattern`, the tokens `:device_id`, `:device_type`, and `:group_id` will be substituted with the values from the relevant packet.
+1. Delta: as packets are received, they are translated into the corresponding command (e.g., "set brightness to 50").  The translated command is sent as an update.
+2. State: When an update is received, the corresponding command is applied to known group state, and the whole state for the group is transmitted.
 
-The published message is a JSON blob containing the following keys:
+##### Delta updates
 
-* `device_id`
-* `device_type` (rgb_cct, rgbw, etc.)
-* `group_id`
-* Any number of: `status`, `level`, `hue`, `saturation`, `kelvin`
+To publish data from intercepted packets to an MQTT topic, configure MQTT server settings, and set the `mqtt_update_topic_pattern` to something of your choice. As with `mqtt_topic_pattern`, the tokens `:device_id`, `:device_type`, and `:group_id` will be substituted with the values from the relevant packet.  `:device_id` will always be substituted with the hexadecimal value of the ID.  You can also use `:hex_device_id`, or `:dec_device_id` if you prefer decimal.
 
-As an example, if `mqtt_update_topic_pattern` is set to `milight/updates/:device_id/:device_type/:group_id`, and the group 1 on button of a Milight remote is pressed, the following update will be dispatched:
+The published message is a JSON blob containing the state that was changed.
+
+As an example, if `mqtt_update_topic_pattern` is set to `milight/updates/:hex_device_id/:device_type/:group_id`, and the group 1 on button of a Milight remote is pressed, the following update will be dispatched:
 
 ```ruby
 irb(main):005:0> client.subscribe('milight/updates/+/+/+')
 => 27
 irb(main):006:0> puts client.get.inspect
-["lights/updates/0x1C8E/rgb_cct/1", "{\"device_id\":7310,\"group_id\":1,\"device_type\":\"rgb_cct\",\"status\":\"on\"}"]
+["lights/updates/0x1C8E/rgb_cct/1", "{\"status\":\"on\"}"]
 ```
 
+##### Full state updates
+
+For this mode, `mqtt_state_topic_pattern` should be set to something like `milight/states/:hex_device_id/:device_type/:group_id`.  As an example:
+
+```ruby
+irb(main):005:0> client.subscribe('milight/states/+/+/+')
+=> 27
+irb(main):006:0> puts client.get.inspect
+["lights/states/0x1C8E/rgb_cct/1", "{\"state\":\"ON\",\"brightness\":255,\"color_temp\":370,\"bulb_mode\":\"white\"}"]
+irb(main):007:0> puts client.get.inspect
+["lights/states/0x1C8E/rgb_cct/1", "{\"state\":\"ON\",\"brightness\":100,\"color_temp\":370,\"bulb_mode\":\"white\"}"]
+```
+
+**Make sure that `mqtt_topic_pattern`, `mqtt_state_topic_pattern`, and `matt_update_topic_pattern` are all different!**  If they are they same you can put your ESP in a loop where its own updates trigger an infinite command loop.
+
 ## UDP Gateways
 
 You can add an arbitrary number of UDP gateways through the REST API or through the web UI. Each gateway server listens on a port and responds to the standard set of commands supported by the Milight protocol. This should allow you to use one of these with standard Milight integrations (SmartThings, Home Assistant, OpenHAB, etc.).

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 2 - 2
dist/index.html.gz.h


+ 316 - 0
lib/DataStructures/LinkedList.h

@@ -0,0 +1,316 @@
+/*
+  ********* Adapted from: *********
+  https://github.com/ivanseidel/LinkedList
+  Created by Ivan Seidel Gomes, March, 2013.
+  Released into the public domain.
+  *********************************
+
+  Changes:
+    - public access to ListNode (allows for splicing for LRU)
+    - doubly-linked
+    - remove caching stuff in favor of standard linked list iterating
+    - remove sorting
+*/
+
+#ifndef LinkedList_h
+#define LinkedList_h
+
+#include <stddef.h>
+
+template<class T>
+struct ListNode {
+  T data;
+  ListNode<T> *next;
+  ListNode<T> *prev;
+};
+
+template <typename T>
+class LinkedList {
+
+protected:
+  int _size;
+  ListNode<T> *root;
+  ListNode<T>  *last;
+
+public:
+  LinkedList();
+  ~LinkedList();
+
+  /*
+    Returns current size of LinkedList
+  */
+  virtual int size() const;
+  /*
+    Adds a T object in the specified index;
+    Unlink and link the LinkedList correcly;
+    Increment _size
+  */
+  virtual bool add(int index, T);
+  /*
+    Adds a T object in the end of the LinkedList;
+    Increment _size;
+  */
+  virtual bool add(T);
+  /*
+    Adds a T object in the start of the LinkedList;
+    Increment _size;
+  */
+  virtual bool unshift(T);
+  /*
+    Set the object at index, with T;
+    Increment _size;
+  */
+  virtual bool set(int index, T);
+  /*
+    Remove object at index;
+    If index is not reachable, returns false;
+    else, decrement _size
+  */
+  virtual T remove(int index);
+  /*
+    Remove last object;
+  */
+  virtual T pop();
+  /*
+    Remove first object;
+  */
+  virtual T shift();
+  /*
+    Get the index'th element on the list;
+    Return Element if accessible,
+    else, return false;
+  */
+  virtual T get(int index);
+
+  /*
+    Clear the entire array
+  */
+  virtual void clear();
+
+  ListNode<T>* getNode(int index);
+  virtual void spliceToFront(ListNode<T>* node);
+  ListNode<T>* getHead() { return root; }
+  T getLast() const { return last == NULL ? T() : last->data; }
+
+};
+
+
+template<typename T>
+void LinkedList<T>::spliceToFront(ListNode<T>* node) {
+  // Node is already root
+  if (node->prev == NULL) {
+    return;
+  }
+
+  node->prev->next = node->next;
+  if (node->next != NULL) {
+    node->next->prev = node->prev;
+  } else {
+    last = node->prev;
+  }
+
+  root->prev = node;
+  node->next = root;
+  node->prev = NULL;
+  root = node;
+}
+
+// Initialize LinkedList with false values
+template<typename T>
+LinkedList<T>::LinkedList()
+{
+  root=NULL;
+  last=NULL;
+  _size=0;
+}
+
+// Clear Nodes and free Memory
+template<typename T>
+LinkedList<T>::~LinkedList()
+{
+  ListNode<T>* tmp;
+  while(root!=NULL)
+  {
+    tmp=root;
+    root=root->next;
+    delete tmp;
+  }
+  last = NULL;
+  _size=0;
+}
+
+/*
+  Actualy "logic" coding
+*/
+
+template<typename T>
+ListNode<T>* LinkedList<T>::getNode(int index){
+
+  int _pos = 0;
+  ListNode<T>* current = root;
+
+  while(_pos < index && current){
+    current = current->next;
+
+    _pos++;
+  }
+
+  return false;
+}
+
+template<typename T>
+int LinkedList<T>::size() const{
+  return _size;
+}
+
+template<typename T>
+bool LinkedList<T>::add(int index, T _t){
+
+  if(index >= _size)
+    return add(_t);
+
+  if(index == 0)
+    return unshift(_t);
+
+  ListNode<T> *tmp = new ListNode<T>(),
+         *_prev = getNode(index-1);
+  tmp->data = _t;
+  tmp->next = _prev->next;
+  _prev->next = tmp;
+
+  _size++;
+
+  return true;
+}
+
+template<typename T>
+bool LinkedList<T>::add(T _t){
+
+  ListNode<T> *tmp = new ListNode<T>();
+  tmp->data = _t;
+  tmp->next = NULL;
+
+  if(root){
+    // Already have elements inserted
+    last->next = tmp;
+    last = tmp;
+  }else{
+    // First element being inserted
+    root = tmp;
+    last = tmp;
+  }
+
+  _size++;
+
+  return true;
+}
+
+template<typename T>
+bool LinkedList<T>::unshift(T _t){
+
+  if(_size == 0)
+    return add(_t);
+
+  ListNode<T> *tmp = new ListNode<T>();
+  tmp->next = root;
+  root->prev = tmp;
+  tmp->data = _t;
+  root = tmp;
+
+  _size++;
+
+  return true;
+}
+
+template<typename T>
+bool LinkedList<T>::set(int index, T _t){
+  // Check if index position is in bounds
+  if(index < 0 || index >= _size)
+    return false;
+
+  getNode(index)->data = _t;
+  return true;
+}
+
+template<typename T>
+T LinkedList<T>::pop(){
+  if(_size <= 0)
+    return T();
+
+  if(_size >= 2){
+    ListNode<T> *tmp = last->prev;
+    T ret = tmp->next->data;
+    delete(tmp->next);
+    tmp->next = NULL;
+    last = tmp;
+    _size--;
+    return ret;
+  }else{
+    // Only one element left on the list
+    T ret = root->data;
+    delete(root);
+    root = NULL;
+    last = NULL;
+    _size = 0;
+    return ret;
+  }
+}
+
+template<typename T>
+T LinkedList<T>::shift(){
+  if(_size <= 0)
+    return T();
+
+  if(_size > 1){
+    ListNode<T> *_next = root->next;
+    T ret = root->data;
+    delete(root);
+    root = _next;
+    _size --;
+
+    return ret;
+  }else{
+    // Only one left, then pop()
+    return pop();
+  }
+
+}
+
+template<typename T>
+T LinkedList<T>::remove(int index){
+  if (index < 0 || index >= _size)
+  {
+    return T();
+  }
+
+  if(index == 0)
+    return shift();
+
+  if (index == _size-1)
+  {
+    return pop();
+  }
+
+  ListNode<T> *tmp = getNode(index - 1);
+  ListNode<T> *toDelete = tmp->next;
+  T ret = toDelete->data;
+  tmp->next = tmp->next->next;
+  delete(toDelete);
+  _size--;
+  return ret;
+}
+
+
+template<typename T>
+T LinkedList<T>::get(int index){
+  ListNode<T> *tmp = getNode(index);
+
+  return (tmp ? tmp->data : T());
+}
+
+template<typename T>
+void LinkedList<T>::clear(){
+  while(size() > 0)
+    shift();
+}
+#endif

+ 6 - 1
lib/Helpers/Size.h

@@ -1,6 +1,11 @@
 #include <Arduino.h>
 
+#ifndef _SIZE_H
+#define _SIZE_H
+
 template<typename T, size_t sz>
 size_t size(T(&)[sz]) {
     return sz;
-}
+}
+
+#endif

+ 5 - 12
lib/Helpers/Units.h

@@ -16,22 +16,15 @@ public:
   }
 
   static uint8_t miredsToWhiteVal(uint16_t mireds, uint8_t maxValue = 255) {
-      uint32_t tempMireds = constrain(mireds, COLOR_TEMP_MIN_MIREDS, COLOR_TEMP_MAX_MIREDS);
-
-      uint8_t scaledTemp = round(
-        maxValue*
-        (tempMireds - COLOR_TEMP_MIN_MIREDS)
-          /
-        static_cast<double>(COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS)
+      return rescale<uint16_t, uint16_t>(
+        constrain(mireds, COLOR_TEMP_MIN_MIREDS, COLOR_TEMP_MAX_MIREDS) - COLOR_TEMP_MIN_MIREDS,
+        maxValue,
+        (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS)
       );
-
-      return scaledTemp;
   }
 
   static uint16_t whiteValToMireds(uint8_t value, uint8_t maxValue = 255) {
-    uint8_t reverseValue = maxValue - value;
-    uint16_t scaled = rescale<uint16_t, uint16_t>(reverseValue, (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS), maxValue);
-
+    uint16_t scaled = rescale<uint16_t, uint16_t>(value, (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS), maxValue);
     return COLOR_TEMP_MIN_MIREDS + scaled;
   }
 };

+ 59 - 0
lib/MQTT/BulbStateUpdater.cpp

@@ -0,0 +1,59 @@
+#include <BulbStateUpdater.h>
+
+BulbStateUpdater::BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore)
+  : settings(settings),
+    mqttClient(mqttClient),
+    stateStore(stateStore),
+    lastFlush(0),
+    enabled(true)
+{ }
+
+void BulbStateUpdater::enable() {
+  this->enabled = true;
+}
+
+void BulbStateUpdater::disable() {
+  this->enabled = false;
+}
+
+void BulbStateUpdater::enqueueUpdate(BulbId bulbId, GroupState& groupState) {
+  // If can flush immediately, do so (avoids lookup of group state later).
+  if (canFlush()) {
+    flushGroup(bulbId, groupState);
+  } else {
+    staleGroups.push(bulbId);
+  }
+}
+
+void BulbStateUpdater::loop() {
+  while (canFlush() && staleGroups.size() > 0) {
+    BulbId bulbId = staleGroups.shift();
+    GroupState& groupState = stateStore.get(bulbId);
+
+    if (groupState.isMqttDirty()) {
+      flushGroup(bulbId, groupState);
+      groupState.clearMqttDirty();
+    }
+  }
+}
+
+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);
+  message.printTo(buffer);
+
+  mqttClient.sendState(
+    *MiLightRemoteConfig::fromType(bulbId.deviceType),
+    bulbId.deviceId,
+    bulbId.groupId,
+    buffer
+  );
+
+  lastFlush = millis();
+}
+
+inline bool BulbStateUpdater::canFlush() const {
+  return enabled && (millis() > (lastFlush + settings.mqttStateRateLimit));
+}

+ 34 - 0
lib/MQTT/BulbStateUpdater.h

@@ -0,0 +1,34 @@
+/**
+ * Enqueues updated bulb states and flushes them at the configured interval.
+ */
+
+#include <stddef.h>
+#include <MqttClient.h>
+#include <CircularBuffer.h>
+#include <Settings.h>
+
+#ifndef BULB_STATE_UPDATER
+#define BULB_STATE_UPDATER
+
+class BulbStateUpdater {
+public:
+  BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore);
+
+  void enqueueUpdate(BulbId bulbId, GroupState& groupState);
+  void loop();
+  void enable();
+  void disable();
+
+private:
+  Settings& settings;
+  MqttClient& mqttClient;
+  GroupStateStore& stateStore;
+  CircularBuffer<BulbId, MILIGHT_MAX_STALE_MQTT_GROUPS> staleGroups;
+  unsigned long lastFlush;
+  bool enabled;
+
+  inline void flushGroup(BulbId bulbId, GroupState& state);
+  inline bool canFlush() const;
+};
+
+#endif

+ 58 - 23
lib/MQTT/MqttClient.cpp

@@ -1,9 +1,11 @@
+#include <stddef.h>
 #include <MqttClient.h>
 #include <TokenIterator.h>
 #include <UrlTokenBindings.h>
 #include <IntParsing.h>
 #include <ArduinoJson.h>
 #include <WiFiClient.h>
+#include <MiLightRadioConfig.h>
 
 MqttClient::MqttClient(Settings& settings, MiLightClient*& milightClient)
   : milightClient(milightClient),
@@ -85,25 +87,12 @@ void MqttClient::handleClient() {
   mqttClient->loop();
 }
 
-void MqttClient::sendUpdate(MiLightRadioType type, uint16_t deviceId, uint16_t groupId, const char* update) {
-  String topic = settings.mqttUpdateTopicPattern;
-
-  if (topic.length() == 0) {
-    return;
-  }
-
-  String deviceIdStr = String(deviceId, 16);
-  deviceIdStr.toUpperCase();
-
-  topic.replace(":device_id", String("0x") + deviceIdStr);
-  topic.replace(":group_id", String(groupId));
-  topic.replace(":device_type", MiLightRadioConfig::fromType(type)->name);
-
-#ifdef MQTT_DEBUG
-  printf_P(PSTR("MqttClient - publishing update to %s: %s\n"), topic.c_str(), update);
-#endif
+void MqttClient::sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update) {
+  publish(settings.mqttUpdateTopicPattern, remoteConfig, deviceId, groupId, update);
+}
 
-  mqttClient->publish(topic.c_str(), update);
+void MqttClient::sendState(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update) {
+  publish(settings.mqttStateTopicPattern, remoteConfig, deviceId, groupId, update, true);
 }
 
 void MqttClient::subscribe() {
@@ -120,16 +109,38 @@ void MqttClient::subscribe() {
   mqttClient->subscribe(topic.c_str());
 }
 
+void MqttClient::publish(
+  const String& _topic,
+  const MiLightRemoteConfig &remoteConfig,
+  uint16_t deviceId,
+  uint16_t groupId,
+  const char* message,
+  const bool retain
+) {
+  if (_topic.length() == 0) {
+    return;
+  }
+
+  String topic = _topic;
+  MqttClient::bindTopicString(topic, remoteConfig, deviceId, groupId);
+
+#ifdef MQTT_DEBUG
+  printf("MqttClient - publishing update to %s\n", topic.c_str());
+#endif
+
+  mqttClient->publish(topic.c_str(), message, retain);
+}
+
 void MqttClient::publishCallback(char* topic, byte* payload, int length) {
   uint16_t deviceId = 0;
   uint8_t groupId = 0;
-  MiLightRadioConfig* config = &MilightRgbCctConfig;
+  const MiLightRemoteConfig* config = &FUT092Config;
   char cstrPayload[length + 1];
   cstrPayload[length] = 0;
   memcpy(cstrPayload, payload, sizeof(byte)*length);
 
 #ifdef MQTT_DEBUG
-  printf_P(PSTR("MqttClient - Got message on topic: %s\n%s\n"), topic, cstrPayload);
+  printf("MqttClient - Got message on topic: %s\n%s\n", topic, cstrPayload);
 #endif
 
   char topicPattern[settings.mqttTopicPattern.length()];
@@ -148,16 +159,40 @@ void MqttClient::publishCallback(char* topic, byte* payload, int length) {
   }
 
   if (tokenBindings.hasBinding("device_type")) {
-    config = MiLightRadioConfig::fromString(tokenBindings.get("device_type"));
+    config = MiLightRemoteConfig::fromType(tokenBindings.get("device_type"));
+
+    if (config == NULL) {
+      Serial.println(F("MqttClient - ERROR: could not extract device_type from topic"));
+      return;
+    }
+  } else {
+    Serial.println(F("MqttClient - WARNING: could not find device_type token.  Defaulting to FUT092.\n"));
   }
 
   StaticJsonBuffer<400> buffer;
   JsonObject& obj = buffer.parseObject(cstrPayload);
 
 #ifdef MQTT_DEBUG
-  printf_P(PSTR("MqttClient - device %04X, group %u\n"), deviceId, groupId);
+  printf("MqttClient - device %04X, group %u\n", deviceId, groupId);
 #endif
 
-  milightClient->prepare(*config, deviceId, groupId);
+  milightClient->prepare(config, deviceId, groupId);
   milightClient->update(obj);
 }
+
+inline void MqttClient::bindTopicString(
+  String& topicPattern,
+  const MiLightRemoteConfig& remoteConfig,
+  const uint16_t deviceId,
+  const uint16_t groupId
+) {
+  String deviceIdHex = String(deviceId, 16);
+  deviceIdHex.toUpperCase();
+  deviceIdHex = String("0x") + deviceIdHex;
+
+  topicPattern.replace(":device_id", deviceIdHex);
+  topicPattern.replace(":hex_device_id", deviceIdHex);
+  topicPattern.replace(":dec_device_id", String(deviceId));
+  topicPattern.replace(":group_id", String(groupId));
+  topicPattern.replace(":device_type", remoteConfig.name);
+}

+ 18 - 1
lib/MQTT/MqttClient.h

@@ -2,6 +2,7 @@
 #include <Settings.h>
 #include <PubSubClient.h>
 #include <WiFiClient.h>
+#include <MiLightRadioConfig.h>
 
 #ifndef MQTT_CONNECTION_ATTEMPT_FREQUENCY
 #define MQTT_CONNECTION_ATTEMPT_FREQUENCY 5000
@@ -18,7 +19,8 @@ public:
   void begin();
   void handleClient();
   void reconnect();
-  void sendUpdate(MiLightRadioType type, uint16_t deviceId, uint16_t groupId, const char* update);
+  void sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update);
+  void sendState(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update);
 
 private:
   WiFiClient tcpClient;
@@ -31,6 +33,21 @@ private:
   bool connect();
   void subscribe();
   void publishCallback(char* topic, byte* payload, int length);
+  void publish(
+    const String& topic,
+    const MiLightRemoteConfig& remoteConfig,
+    uint16_t deviceId,
+    uint16_t groupId,
+    const char* update,
+    const bool retain = false
+  );
+
+  inline static void bindTopicString(
+    String& topicPattern,
+    const MiLightRemoteConfig& remoteConfig,
+    const uint16_t deviceId,
+    const uint16_t groupId
+  );
 };
 
 #endif

+ 14 - 9
lib/MiLight/CctPacketFormatter.cpp

@@ -1,10 +1,15 @@
 #include <CctPacketFormatter.h>
-#include <MiLightButtons.h>
+
+static const uint8_t CCT_PROTOCOL_ID = 0x5A;
+
+bool CctPacketFormatter::canHandle(const uint8_t *packet, const size_t len) {
+  return len == packetLength && packet[0] == CCT_PROTOCOL_ID;
+}
 
 void CctPacketFormatter::initializePacket(uint8_t* packet) {
   size_t packetPtr = 0;
 
-  packet[packetPtr++] = CCT;
+  packet[packetPtr++] = CCT_PROTOCOL_ID;
   packet[packetPtr++] = deviceId >> 8;
   packet[packetPtr++] = deviceId & 0xFF;
   packet[packetPtr++] = groupId;
@@ -146,12 +151,14 @@ MiLightStatus CctPacketFormatter::cctCommandToStatus(uint8_t command) {
   }
 }
 
-void CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
+BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore) {
   uint8_t command = packet[CCT_COMMAND_INDEX] & 0x7F;
 
-  result["device_id"] = (packet[1] << 8) | packet[2];
-  result["device_type"] = "cct";
-  result["group_id"] = packet[3];
+  BulbId bulbId(
+    (packet[1] << 8) | packet[2],
+    packet[3],
+    REMOTE_TYPE_CCT
+  );
 
   uint8_t onOffGroupId = cctCommandIdToGroup(command);
   if (onOffGroupId < 255) {
@@ -168,9 +175,7 @@ void CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result)
     result["button_id"] = command;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
+  return bulbId;
 }
 
 void CctPacketFormatter::format(uint8_t const* packet, char* buffer) {

+ 3 - 1
lib/MiLight/CctPacketFormatter.h

@@ -29,6 +29,8 @@ public:
     : PacketFormatter(7, 20)
   { }
 
+  virtual bool canHandle(const uint8_t* packet, const size_t len);
+
   virtual void updateStatus(MiLightStatus status, uint8_t groupId);
   virtual void command(uint8_t command, uint8_t arg);
 
@@ -43,7 +45,7 @@ public:
 
   virtual void format(uint8_t const* packet, char* buffer);
   virtual void initializePacket(uint8_t* packet);
-  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
 
   static uint8_t getCctStatusButton(uint8_t groupId, MiLightStatus status);
   static uint8_t cctCommandIdToGroup(uint8_t command);

+ 101 - 0
lib/MiLight/FUT089PacketFormatter.cpp

@@ -0,0 +1,101 @@
+#include <FUT089PacketFormatter.h>
+#include <V2RFEncoding.h>
+#include <Units.h>
+
+void FUT089PacketFormatter::modeSpeedDown() {
+  command(FUT089_ON, FUT089_MODE_SPEED_DOWN);
+}
+
+void FUT089PacketFormatter::modeSpeedUp() {
+  command(FUT089_ON, FUT089_MODE_SPEED_UP);
+}
+
+void FUT089PacketFormatter::updateMode(uint8_t mode) {
+  command(FUT089_MODE, mode);
+}
+
+void FUT089PacketFormatter::updateBrightness(uint8_t brightness) {
+  command(FUT089_BRIGHTNESS, brightness);
+}
+
+void FUT089PacketFormatter::updateHue(uint16_t value) {
+  uint8_t remapped = Units::rescale(value, 255, 360);
+  updateColorRaw(remapped);
+}
+
+void FUT089PacketFormatter::updateColorRaw(uint8_t value) {
+  command(FUT089_COLOR, FUT089_COLOR_OFFSET + value);
+}
+
+void FUT089PacketFormatter::updateTemperature(uint8_t value) {
+  updateColorWhite();
+  command(FUT089_KELVIN, 100 - value);
+}
+
+void FUT089PacketFormatter::updateSaturation(uint8_t value) {
+  command(FUT089_SATURATION, 100 - value);
+}
+
+void FUT089PacketFormatter::updateColorWhite() {
+  command(FUT089_ON, FUT089_WHITE_MODE);
+}
+
+void FUT089PacketFormatter::enableNightMode() {
+  uint8_t arg = groupCommandArg(OFF, groupId);
+  command(FUT089_ON | 0x80, arg);
+}
+
+BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result, GroupStateStore* stateStore) {
+  uint8_t packetCopy[V2_PACKET_LEN];
+  memcpy(packetCopy, packet, V2_PACKET_LEN);
+  V2RFEncoding::decodeV2Packet(packetCopy);
+
+  BulbId bulbId(
+    (packetCopy[2] << 8) | packetCopy[3],
+    packetCopy[7],
+    REMOTE_TYPE_FUT089
+  );
+
+  uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F);
+  uint8_t arg = packetCopy[V2_ARGUMENT_INDEX];
+
+  if (command == FUT089_ON) {
+    if (arg == FUT089_MODE_SPEED_DOWN) {
+      result["command"] = "mode_speed_down";
+    } else if (arg == FUT089_MODE_SPEED_UP) {
+      result["command"] = "mode_speed_up";
+    } else if (arg == FUT089_WHITE_MODE) {
+      result["command"] = "white_mode";
+    } else if (arg <= 8) { // Group is not reliably encoded in group byte. Extract from arg byte
+      result["state"] = "ON";
+      bulbId.groupId = arg;
+    } else if (arg >= 9 && arg <= 17) {
+      result["state"] = "OFF";
+      bulbId.groupId = arg-9;
+    }
+  } else if (command == FUT089_COLOR) {
+    uint8_t rescaledColor = (arg - FUT089_COLOR_OFFSET) % 0x100;
+    uint16_t hue = Units::rescale<uint16_t, uint16_t>(rescaledColor, 360, 255.0);
+    result["hue"] = hue;
+  } else if (command == FUT089_BRIGHTNESS) {
+    uint8_t level = constrain(arg, 0, 100);
+    result["brightness"] = Units::rescale<uint8_t, uint8_t>(level, 255, 100);
+  // saturation == kelvin. arg ranges are the same, so can't distinguish
+  // without using state
+  } else if (command == FUT089_SATURATION) {
+    GroupState& state = stateStore->get(bulbId);
+
+    if (state.getBulbMode() == BULB_MODE_COLOR) {
+      result["saturation"] = 100 - constrain(arg, 0, 100);
+    } else {
+      result["color_temp"] = Units::whiteValToMireds(100 - arg, 100);
+    }
+  } else if (command == FUT089_MODE) {
+    result["mode"] = arg;
+  } else {
+    result["button_id"] = command;
+    result["argument"] = arg;
+  }
+
+  return bulbId;
+}

+ 45 - 0
lib/MiLight/FUT089PacketFormatter.h

@@ -0,0 +1,45 @@
+#include <V2PacketFormatter.h>
+
+#ifndef _FUT089_PACKET_FORMATTER_H
+#define _FUT089_PACKET_FORMATTER_H
+
+#define FUT089_COLOR_OFFSET 0
+
+enum MiLightFUT089Command {
+  FUT089_ON = 0x01,
+  FUT089_OFF = 0x01,
+  FUT089_COLOR = 0x02,
+  FUT089_BRIGHTNESS = 0x05,
+  FUT089_MODE = 0x06,
+  FUT089_KELVIN = 0x07,
+  FUT089_SATURATION = 0x07
+};
+
+enum MiLightFUT089Arguments {
+  FUT089_MODE_SPEED_UP   = 0x12,
+  FUT089_MODE_SPEED_DOWN = 0x13,
+  FUT089_WHITE_MODE = 0x14
+};
+
+class FUT089PacketFormatter : public V2PacketFormatter {
+public:
+  FUT089PacketFormatter()
+    : V2PacketFormatter(0x25, 8)
+  { }
+
+  virtual void updateBrightness(uint8_t value);
+  virtual void updateHue(uint16_t value);
+  virtual void updateColorRaw(uint8_t value);
+  virtual void updateColorWhite();
+  virtual void updateTemperature(uint8_t value);
+  virtual void updateSaturation(uint8_t value);
+  virtual void enableNightMode();
+
+  virtual void modeSpeedDown();
+  virtual void modeSpeedUp();
+  virtual void updateMode(uint8_t mode);
+
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
+};
+
+#endif

+ 0 - 17
lib/MiLight/MiLightButtons.h

@@ -1,17 +0,0 @@
-#ifndef _MILIGHT_BUTTONS
-#define _MILIGHT_BUTTONS 
-
-enum MiLightRadioType {
-  UNKNOWN = 0,
-  RGBW  = 0xB0,
-  CCT   = 0x5A,
-  RGB_CCT = 0x20,
-  RGB = 0xA4
-};
-
-enum MiLightStatus { 
-  ON = 0, 
-  OFF = 1 
-};
-
-#endif

+ 121 - 80
lib/MiLight/MiLightClient.cpp

@@ -4,16 +4,30 @@
 #include <RGBConverter.h>
 #include <Units.h>
 
-MiLightClient::MiLightClient(MiLightRadioFactory* radioFactory)
-  : resendCount(MILIGHT_DEFAULT_RESEND_COUNT),
+MiLightClient::MiLightClient(
+  MiLightRadioFactory* radioFactory,
+  GroupStateStore& stateStore,
+  size_t throttleThreshold,
+  size_t throttleSensitivity,
+  size_t packetRepeatMinimum
+)
+  : baseResendCount(MILIGHT_DEFAULT_RESEND_COUNT),
     currentRadio(NULL),
+    currentRemote(NULL),
     numRadios(MiLightRadioConfig::NUM_CONFIGS),
-    packetSentHandler(NULL)
+    packetSentHandler(NULL),
+    updateBeginHandler(NULL),
+    updateEndHandler(NULL),
+    stateStore(stateStore),
+    lastSend(0),
+    throttleThreshold(throttleThreshold),
+    throttleSensitivity(throttleSensitivity),
+    packetRepeatMinimum(packetRepeatMinimum)
 {
   radios = new MiLightRadio*[numRadios];
 
   for (size_t i = 0; i < numRadios; i++) {
-    radios[i] = radioFactory->create(*MiLightRadioConfig::ALL_CONFIGS[i]);
+    radios[i] = radioFactory->create(MiLightRadioConfig::ALL_CONFIGS[i]);
   }
 }
 
@@ -22,64 +36,68 @@ void MiLightClient::begin() {
     radios[i]->begin();
   }
 
-  this->currentRadio = radios[0];
-  this->currentRadio->configure();
+  switchRadio(static_cast<size_t>(0));
 }
 
 void MiLightClient::setHeld(bool held) {
-  formatter->setHeld(held);
+  currentRemote->packetFormatter->setHeld(held);
 }
 
-MiLightRadio* MiLightClient::switchRadio(const MiLightRadioType type) {
-  MiLightRadio* radio = NULL;
+size_t MiLightClient::getNumRadios() const {
+  return numRadios;
+}
 
-  for (int i = 0; i < numRadios; i++) {
-    if (this->radios[i]->config().type == type) {
-      radio = radios[i];
-      break;
-    }
+MiLightRadio* MiLightClient::switchRadio(size_t radioIx) {
+  if (radioIx >= getNumRadios()) {
+    return NULL;
   }
 
-  if (radio != NULL) {
-    if (currentRadio != radio) {
-      radio->configure();
-    }
-
-    this->currentRadio = radio;
-    this->formatter = radio->config().packetFormatter;
-
-    return radio;
-  } else {
-    Serial.print(F("MiLightClient - tried to get radio for unknown type: "));
-    Serial.println(type);
+  if (this->currentRadio != radios[radioIx]) {
+    this->currentRadio = radios[radioIx];
+    this->currentRadio->configure();
   }
 
-  return NULL;
+  return this->currentRadio;
 }
 
+MiLightRadio* MiLightClient::switchRadio(const MiLightRemoteConfig* remoteConfig) {
+  MiLightRadio* radio;
 
-void MiLightClient::prepare(MiLightRadioConfig& config,
-  const uint16_t deviceId,
-  const uint8_t groupId) {
+  for (int i = 0; i < numRadios; i++) {
+    if (&this->radios[i]->config() == &remoteConfig->radioConfig) {
+      radio = switchRadio(i);
+      break;
+    }
+  }
 
-  prepare(config.type, deviceId, groupId);
+  return radio;
 }
 
-void MiLightClient::prepare(MiLightRadioType type,
+void MiLightClient::prepare(const MiLightRemoteConfig* config,
   const uint16_t deviceId,
-  const uint8_t groupId) {
+  const uint8_t groupId
+) {
+  switchRadio(config);
 
-  switchRadio(type);
+  this->currentRemote = config;
 
   if (deviceId >= 0 && groupId >= 0) {
-    formatter->prepare(deviceId, groupId);
+    currentRemote->packetFormatter->prepare(deviceId, groupId);
   }
 }
 
-void MiLightClient::setResendCount(const unsigned int resendCount) {
-  this->resendCount = resendCount;
+void MiLightClient::prepare(const MiLightRemoteType type,
+  const uint16_t deviceId,
+  const uint8_t groupId
+) {
+  prepare(MiLightRemoteConfig::fromType(type));
 }
 
+void MiLightClient::setResendCount(const unsigned int resendCount) {
+  this->baseResendCount = resendCount;
+  this->currentResendCount = resendCount;
+  this->throttleMultiplier = ceil((throttleSensitivity / 1000.0) * this->baseResendCount);
+}
 
 bool MiLightClient::available() {
   if (currentRadio == NULL) {
@@ -88,14 +106,16 @@ bool MiLightClient::available() {
 
   return currentRadio->available();
 }
-void MiLightClient::read(uint8_t packet[]) {
+
+size_t MiLightClient::read(uint8_t packet[]) {
   if (currentRadio == NULL) {
-    return;
+    return 0;
   }
 
-  size_t length = currentRadio->config().getPacketLength();
-
+  size_t length;
   currentRadio->read(packet, length);
+
+  return length;
 }
 
 void MiLightClient::write(uint8_t packet[]) {
@@ -104,20 +124,20 @@ void MiLightClient::write(uint8_t packet[]) {
   }
 
 #ifdef DEBUG_PRINTF
-  printf("Sending packet: ");
-  for (int i = 0; i < currentRadio->config().getPacketLength(); i++) {
-    printf("%02X", packet[i]);
+  Serial.printf("Sending packet (%d repeats): \n", this->currentResendCount);
+  for (int i = 0; i < currentRemote->packetFormatter->getPacketLength(); i++) {
+    Serial.printf("%02X ", packet[i]);
   }
-  printf("\n");
+  Serial.println();
   int iStart = millis();
 #endif
 
-  for (int i = 0; i < this->resendCount; i++) {
-    currentRadio->write(packet, currentRadio->config().getPacketLength());
+  for (int i = 0; i < this->currentResendCount; i++) {
+    currentRadio->write(packet, currentRemote->packetFormatter->getPacketLength());
   }
 
   if (this->packetSentHandler) {
-    this->packetSentHandler(packet, currentRadio->config());
+    this->packetSentHandler(packet, *currentRemote);
   }
 
 #ifdef DEBUG_PRINTF
@@ -128,110 +148,114 @@ void MiLightClient::write(uint8_t packet[]) {
 }
 
 void MiLightClient::updateColorRaw(const uint8_t color) {
-  formatter->updateColorRaw(color);
+  currentRemote->packetFormatter->updateColorRaw(color);
   flushPacket();
 }
 
 void MiLightClient::updateHue(const uint16_t hue) {
-  formatter->updateHue(hue);
+  currentRemote->packetFormatter->updateHue(hue);
   flushPacket();
 }
 
 void MiLightClient::updateBrightness(const uint8_t brightness) {
-  formatter->updateBrightness(brightness);
+  currentRemote->packetFormatter->updateBrightness(brightness);
   flushPacket();
 }
 
 void MiLightClient::updateMode(uint8_t mode) {
-  formatter->updateMode(mode);
+  currentRemote->packetFormatter->updateMode(mode);
   flushPacket();
 }
 
 void MiLightClient::nextMode() {
-  formatter->nextMode();
+  currentRemote->packetFormatter->nextMode();
   flushPacket();
 }
 
 void MiLightClient::previousMode() {
-  formatter->previousMode();
+  currentRemote->packetFormatter->previousMode();
   flushPacket();
 }
 
 void MiLightClient::modeSpeedDown() {
-  formatter->modeSpeedDown();
+  currentRemote->packetFormatter->modeSpeedDown();
   flushPacket();
 }
 void MiLightClient::modeSpeedUp() {
-  formatter->modeSpeedUp();
+  currentRemote->packetFormatter->modeSpeedUp();
   flushPacket();
 }
 
 void MiLightClient::updateStatus(MiLightStatus status, uint8_t groupId) {
-  formatter->updateStatus(status, groupId);
+  currentRemote->packetFormatter->updateStatus(status, groupId);
   flushPacket();
 }
 
 void MiLightClient::updateStatus(MiLightStatus status) {
-  formatter->updateStatus(status);
+  currentRemote->packetFormatter->updateStatus(status);
   flushPacket();
 }
 
 void MiLightClient::updateSaturation(const uint8_t value) {
-  formatter->updateSaturation(value);
+  currentRemote->packetFormatter->updateSaturation(value);
   flushPacket();
 }
 
 void MiLightClient::updateColorWhite() {
-  formatter->updateColorWhite();
+  currentRemote->packetFormatter->updateColorWhite();
   flushPacket();
 }
 
 void MiLightClient::enableNightMode() {
-  formatter->enableNightMode();
+  currentRemote->packetFormatter->enableNightMode();
   flushPacket();
 }
 
 void MiLightClient::pair() {
-  formatter->pair();
+  currentRemote->packetFormatter->pair();
   flushPacket();
 }
 
 void MiLightClient::unpair() {
-  formatter->unpair();
+  currentRemote->packetFormatter->unpair();
   flushPacket();
 }
 
 void MiLightClient::increaseBrightness() {
-  formatter->increaseBrightness();
+  currentRemote->packetFormatter->increaseBrightness();
   flushPacket();
 }
 
 void MiLightClient::decreaseBrightness() {
-  formatter->decreaseBrightness();
+  currentRemote->packetFormatter->decreaseBrightness();
   flushPacket();
 }
 
 void MiLightClient::increaseTemperature() {
-  formatter->increaseTemperature();
+  currentRemote->packetFormatter->increaseTemperature();
   flushPacket();
 }
 
 void MiLightClient::decreaseTemperature() {
-  formatter->decreaseTemperature();
+  currentRemote->packetFormatter->decreaseTemperature();
   flushPacket();
 }
 
 void MiLightClient::updateTemperature(const uint8_t temperature) {
-  formatter->updateTemperature(temperature);
+  currentRemote->packetFormatter->updateTemperature(temperature);
   flushPacket();
 }
 
 void MiLightClient::command(uint8_t command, uint8_t arg) {
-  formatter->command(command, arg);
+  currentRemote->packetFormatter->command(command, arg);
   flushPacket();
 }
 
 void MiLightClient::update(const JsonObject& request) {
+  if (this->updateBeginHandler) {
+    this->updateBeginHandler();
+  }
+
   const uint8_t parsedStatus = this->parseStatus(request);
 
   // Always turn on first
@@ -311,10 +335,19 @@ void MiLightClient::update(const JsonObject& request) {
     this->updateMode(request["mode"]);
   }
 
+  // Raw packet command/args
+  if (request.containsKey("button_id") && request.containsKey("argument")) {
+    this->command(request["button_id"], request["argument"]);
+  }
+
   // Always turn off last
   if (parsedStatus == OFF) {
     this->updateStatus(OFF);
   }
+
+  if (this->updateEndHandler) {
+    this->updateEndHandler();
+  }
 }
 
 void MiLightClient::handleCommand(const String& command) {
@@ -367,18 +400,19 @@ uint8_t MiLightClient::parseStatus(const JsonObject& object) {
   return (strStatus.equalsIgnoreCase("on") || strStatus.equalsIgnoreCase("true")) ? ON : OFF;
 }
 
-void MiLightClient::formatPacket(uint8_t* packet, char* buffer) {
-  formatter->format(packet, buffer);
+void MiLightClient::updateResendCount() {
+  unsigned long now = millis();
+  long millisSinceLastSend = now - lastSend;
+  long x = (millisSinceLastSend - throttleThreshold);
+  long delta = x * throttleMultiplier;
+
+  this->currentResendCount = constrain(this->currentResendCount + delta, packetRepeatMinimum, this->baseResendCount);
+  this->lastSend = now;
 }
 
 void MiLightClient::flushPacket() {
-  PacketStream& stream = formatter->buildPackets();
-  const size_t prevNumRepeats = this->resendCount;
-
-  // When sending multiple packets, normalize the number of repeats
-  if (stream.numPackets > 1) {
-    setResendCount(MILIGHT_DEFAULT_RESEND_COUNT);
-  }
+  PacketStream& stream = currentRemote->packetFormatter->buildPackets();
+  updateResendCount();
 
   while (stream.hasNext()) {
     write(stream.next());
@@ -388,10 +422,17 @@ void MiLightClient::flushPacket() {
     }
   }
 
-  setResendCount(prevNumRepeats);
-  formatter->reset();
+  currentRemote->packetFormatter->reset();
 }
 
 void MiLightClient::onPacketSent(PacketSentHandler handler) {
   this->packetSentHandler = handler;
 }
+
+void MiLightClient::onUpdateBegin(EventHandler handler) {
+  this->updateBeginHandler = handler;
+}
+
+void MiLightClient::onUpdateEnd(EventHandler handler) {
+  this->updateEndHandler = handler;
+}

+ 55 - 13
lib/MiLight/MiLightClient.h

@@ -2,8 +2,9 @@
 #include <Arduino.h>
 #include <MiLightRadio.h>
 #include <MiLightRadioFactory.h>
-#include <MiLightButtons.h>
+#include <MiLightRemoteConfig.h>
 #include <Settings.h>
+#include <GroupStateStore.h>
 
 #ifndef _MILIGHTCLIENT_H
 #define _MILIGHTCLIENT_H
@@ -16,21 +17,28 @@
 
 class MiLightClient {
 public:
-  MiLightClient(MiLightRadioFactory* radioFactory);
+  MiLightClient(
+    MiLightRadioFactory* radioFactory,
+    GroupStateStore& stateStore,
+    size_t throttleThreshold,
+    size_t throttleSensitivity,
+    size_t packetRepeatMinimum
+  );
 
   ~MiLightClient() {
     delete[] radios;
   }
 
-  typedef std::function<void(uint8_t* packet, const MiLightRadioConfig& config)> PacketSentHandler;
+  typedef std::function<void(uint8_t* packet, const MiLightRemoteConfig& config)> PacketSentHandler;
+  typedef std::function<void(void)> EventHandler;
 
   void begin();
-  void prepare(MiLightRadioConfig& config, const uint16_t deviceId = -1, const uint8_t groupId = -1);
-  void prepare(MiLightRadioType config, const uint16_t deviceId = -1, const uint8_t groupId = -1);
+  void prepare(const MiLightRemoteConfig* remoteConfig, const uint16_t deviceId = -1, const uint8_t groupId = -1);
+  void prepare(const MiLightRemoteType type, const uint16_t deviceId = -1, const uint8_t groupId = -1);
 
   void setResendCount(const unsigned int resendCount);
   bool available();
-  void read(uint8_t packet[]);
+  size_t read(uint8_t packet[]);
   void write(uint8_t packet[]);
 
   void setHeld(bool held);
@@ -63,24 +71,58 @@ public:
 
   void updateSaturation(const uint8_t saturation);
 
-  void formatPacket(uint8_t* packet, char* buffer);
-
   void update(const JsonObject& object);
   void handleCommand(const String& command);
   void handleEffect(const String& effect);
-  
+
   void onPacketSent(PacketSentHandler handler);
+  void onUpdateBegin(EventHandler handler);
+  void onUpdateEnd(EventHandler handler);
+
+  size_t getNumRadios() const;
+  MiLightRadio* switchRadio(size_t radioIx);
+  MiLightRemoteConfig& currentRemoteConfig() const;
 
 protected:
 
   MiLightRadio** radios;
   MiLightRadio* currentRadio;
-  PacketFormatter* formatter;
+  const MiLightRemoteConfig* currentRemote;
   const size_t numRadios;
-  unsigned int resendCount;
-  PacketSentHandler packetSentHandler;
+  GroupStateStore& stateStore;
 
-  MiLightRadio* switchRadio(const MiLightRadioType type);
+  PacketSentHandler packetSentHandler;
+  EventHandler updateBeginHandler;
+  EventHandler updateEndHandler;
+
+  // Used to track auto repeat limiting
+  unsigned long lastSend;
+  int currentResendCount;
+  unsigned int baseResendCount;
+  int packetRepeatMinimum;
+  size_t throttleThreshold;
+  size_t throttleSensitivity;
+
+  // This will be pre-computed, but is simply:
+  //
+  //    (sensitivity / 1000.0) * R
+  //
+  // Where R is the base number of repeats.
+  size_t throttleMultiplier;
+
+  /*
+   * Calculates the number of resend packets based on when the last packet
+   * was sent using this function:
+   *
+   *    lastRepeatsValue + (millisSinceLastSend - THRESHOLD) * throttleMultiplier
+   *
+   * When the last send was more recent than THRESHOLD, the number of repeats
+   * will be decreased to a minimum of zero.  When less recent, it will be
+   * increased up to a maximum of the default resend count.
+   */
+  void updateResendCount();
+
+  MiLightRadio* switchRadio(const MiLightRemoteConfig* remoteConfig);
   uint8_t parseStatus(const JsonObject& object);
 
   void flushPacket();

+ 0 - 32
lib/MiLight/MiLightRadioConfig.cpp

@@ -1,32 +0,0 @@
-#include <MiLightRadioConfig.h>
-
-MiLightRadioConfig* MiLightRadioConfig::ALL_CONFIGS[] = {
-  &MilightRgbwConfig,
-  &MilightCctConfig,
-  &MilightRgbCctConfig,
-  &MilightRgbConfig
-};
-
-MiLightRadioConfig* MiLightRadioConfig::fromString(const String& s) {
-  for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) {
-    MiLightRadioConfig* config = MiLightRadioConfig::ALL_CONFIGS[i];
-    if (s.equalsIgnoreCase(config->name)) {
-      return config;
-    }
-  }
-  return NULL;
-}
-
-MiLightRadioConfig* MiLightRadioConfig::fromType(MiLightRadioType type) {
-  for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) {
-    MiLightRadioConfig* config = MiLightRadioConfig::ALL_CONFIGS[i];
-    if (config->type == type) {
-      return config;
-    }
-  }
-  return NULL;
-}
-
-size_t MiLightRadioConfig::getPacketLength() const {
-  return packetFormatter->getPacketLength();
-}

+ 0 - 66
lib/MiLight/MiLightRadioConfig.h

@@ -1,66 +0,0 @@
-#include <Arduino.h>
-#include <PacketFormatter.h>
-#include <RgbCctPacketFormatter.h>
-#include <RgbwPacketFormatter.h>
-#include <CctPacketFormatter.h>
-#include <RgbPacketFormatter.h>
-#include <MiLightButtons.h>
-
-#ifndef _MILIGHT_RADIO_CONFIG
-#define _MILIGHT_RADIO_CONFIG
-
-class MiLightRadioConfig {
-public:
-  static const size_t NUM_CHANNELS = 3;
-
-  MiLightRadioConfig(const uint16_t syncword0,
-  const uint16_t syncword3,
-  PacketFormatter* packetFormatter,
-  const MiLightRadioType type,
-  const char* name,
-  const uint8_t channel0,
-  const uint8_t channel1,
-  const uint8_t channel2)
-    : syncword0(syncword0),
-      syncword3(syncword3),
-      packetFormatter(packetFormatter),
-      type(type),
-      name(name)
-  {
-    channels[0] = channel0;
-    channels[1] = channel1;
-    channels[2] = channel2;
-  }
-
-  const uint16_t syncword0;
-  const uint16_t syncword3;
-  uint8_t channels[3];
-  PacketFormatter* packetFormatter;
-  const MiLightRadioType type;
-  const char* name;
-
-  static const size_t NUM_CONFIGS = 4;
-  static MiLightRadioConfig* ALL_CONFIGS[NUM_CONFIGS];
-
-  static MiLightRadioConfig* fromString(const String& s);
-  static MiLightRadioConfig* fromType(MiLightRadioType type);
-  size_t getPacketLength() const;
-};
-
-static MiLightRadioConfig MilightRgbwConfig(
-  0x147A, 0x258B, new RgbwPacketFormatter(), RGBW, "rgbw", 9, 40, 71
-);
-
-static MiLightRadioConfig MilightCctConfig(
-  0x050A, 0x55AA, new CctPacketFormatter(), CCT, "cct", 4, 39, 74
-);
-
-static MiLightRadioConfig MilightRgbCctConfig(
-  0x7236, 0x1809, new RgbCctPacketFormatter(), RGB_CCT, "rgb_cct", 8, 39, 70
-);
-
-static MiLightRadioConfig MilightRgbConfig(
-  0x9AAB, 0xBCCD, new RgbPacketFormatter(), RGB, "rgb", 3, 38, 73
-);
-
-#endif

+ 108 - 0
lib/MiLight/MiLightRemoteConfig.cpp

@@ -0,0 +1,108 @@
+#include <MiLightRemoteConfig.h>
+
+/**
+ * IMPORTANT NOTE: These should be in the same order as MiLightRemoteType.
+ */
+const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = {
+  &FUT096Config, // rgbw
+  &FUT091Config, // cct
+  &FUT092Config, // rgb+cct
+  &FUT098Config, // rgb
+  &FUT089Config  // 8-group rgb+cct (b8, fut089)
+};
+
+const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
+  if (type.equalsIgnoreCase("rgbw") || type.equalsIgnoreCase("fut096")) {
+    return &FUT096Config;
+  }
+
+  if (type.equalsIgnoreCase("cct") || type.equalsIgnoreCase("fut091")) {
+    return &FUT091Config;
+  }
+
+  if (type.equalsIgnoreCase("rgb_cct") || type.equalsIgnoreCase("fut092")) {
+    return &FUT092Config;
+  }
+
+  if (type.equalsIgnoreCase("fut089")) {
+    return &FUT089Config;
+  }
+
+  if (type.equalsIgnoreCase("rgb") || type.equalsIgnoreCase("fut098")) {
+    return &FUT098Config;
+  }
+
+  Serial.println(F("ERROR - tried to fetch remote config for 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"));
+    return NULL;
+  }
+
+  return ALL_REMOTES[type];
+}
+
+const MiLightRemoteConfig* MiLightRemoteConfig::fromReceivedPacket(
+  const MiLightRadioConfig& radioConfig,
+  const uint8_t* packet,
+  const size_t len
+) {
+  for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) {
+    const MiLightRemoteConfig* config = MiLightRemoteConfig::ALL_REMOTES[i];
+    if (&config->radioConfig == &radioConfig
+      && config->packetFormatter->canHandle(packet, len)) {
+      return config;
+    }
+  }
+
+  // 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"));
+#endif
+
+  return NULL;
+}
+
+const MiLightRemoteConfig FUT096Config( //rgbw
+  new RgbwPacketFormatter(),
+  MiLightRadioConfig::ALL_CONFIGS[0],
+  REMOTE_TYPE_RGBW,
+  "rgbw",
+  4
+);
+
+const MiLightRemoteConfig FUT091Config( //cct
+  new CctPacketFormatter(),
+  MiLightRadioConfig::ALL_CONFIGS[1],
+  REMOTE_TYPE_CCT,
+  "cct",
+  4
+);
+
+const MiLightRemoteConfig FUT092Config( //rgb+cct
+  new RgbCctPacketFormatter(),
+  MiLightRadioConfig::ALL_CONFIGS[2],
+  REMOTE_TYPE_RGB_CCT,
+  "rgb_cct",
+  4
+);
+
+const MiLightRemoteConfig FUT089Config( //rgb+cct B8 / FUT089
+  new FUT089PacketFormatter(),
+  MiLightRadioConfig::ALL_CONFIGS[2],
+  REMOTE_TYPE_FUT089,
+  "fut089",
+  8
+);
+
+const MiLightRemoteConfig FUT098Config( //rgb
+  new RgbPacketFormatter(),
+  MiLightRadioConfig::ALL_CONFIGS[3],
+  REMOTE_TYPE_RGB,
+  "rgb",
+  0
+);

+ 49 - 0
lib/MiLight/MiLightRemoteConfig.h

@@ -0,0 +1,49 @@
+#include <MiLightRadioConfig.h>
+#include <PacketFormatter.h>
+
+#include <RgbwPacketFormatter.h>
+#include <RgbPacketFormatter.h>
+#include <RgbCctPacketFormatter.h>
+#include <CctPacketFormatter.h>
+#include <FUT089PacketFormatter.h>
+#include <PacketFormatter.h>
+
+#ifndef _MILIGHT_REMOTE_CONFIG_H
+#define _MILIGHT_REMOTE_CONFIG_H
+
+class MiLightRemoteConfig {
+public:
+  MiLightRemoteConfig(
+    PacketFormatter* packetFormatter,
+    MiLightRadioConfig& radioConfig,
+    const MiLightRemoteType type,
+    const String name,
+    const size_t numGroups
+  ) : packetFormatter(packetFormatter),
+      radioConfig(radioConfig),
+      type(type),
+      name(name),
+      numGroups(numGroups)
+  { }
+
+  PacketFormatter* const packetFormatter;
+  const MiLightRadioConfig& radioConfig;
+  const MiLightRemoteType type;
+  const String name;
+  const size_t numGroups;
+
+  static const MiLightRemoteConfig* fromType(MiLightRemoteType type);
+  static const MiLightRemoteConfig* fromType(const String& type);
+  static const MiLightRemoteConfig* fromReceivedPacket(const MiLightRadioConfig& radioConfig, const uint8_t* packet, const size_t len);
+
+  static const size_t NUM_REMOTES = 5;
+  static const MiLightRemoteConfig* ALL_REMOTES[NUM_REMOTES];
+};
+
+extern const MiLightRemoteConfig FUT096Config; //rgbw
+extern const MiLightRemoteConfig FUT091Config; //cct
+extern const MiLightRemoteConfig FUT092Config; //rgb+cct
+extern const MiLightRemoteConfig FUT089Config; //rgb+cct B8 / FUT089
+extern const MiLightRemoteConfig FUT098Config; //rgb
+
+#endif

+ 0 - 415
lib/MiLight/PL1167_nRF24.cpp

@@ -1,415 +0,0 @@
-/*
- * PL1167_nRF24.cpp
- *
- *  Created on: 29 May 2015
- *      Author: henryk
- */
-
-#include "PL1167_nRF24.h"
-
-static uint16_t calc_crc(uint8_t *data, size_t data_length);
-static uint8_t reverse_bits(uint8_t data);
-static void demangle_packet(uint8_t *in, uint8_t *out) ;
-
-PL1167_nRF24::PL1167_nRF24(RF24 &radio)
-  : _radio(radio)
-{ }
-
-static const uint8_t pipe[] = {0xd1, 0x28, 0x5e, 0x55, 0x55};
-
-int PL1167_nRF24::open()
-{
-  _radio.begin();
-  return recalc_parameters();
-}
-
-int PL1167_nRF24::recalc_parameters()
-{
-  int nrf_address_length = _preambleLength - 1 + _syncwordLength;
-  int address_overflow = 0;
-  if (nrf_address_length > 5) {
-    address_overflow = nrf_address_length - 5;
-    nrf_address_length = 5;
-  }
-  int packet_length = address_overflow + ( (_trailerLength + 7) / 8) + _maxPacketLength;
-  if (_crc) {
-    packet_length += 2;
-  }
-
-  if (packet_length > sizeof(_packet) || nrf_address_length < 3) {
-    return -1;
-  }
-
-  uint8_t preamble = 0;
-  if (_syncword0 & 0x01) {
-    preamble = 0x55;
-  } else {
-    preamble = 0xAA;
-  }
-
-  int nrf_address_pos = nrf_address_length;
-  for (int i = 0; i < _preambleLength - 1; i++) {
-    _nrf_pipe[ --nrf_address_pos ] = reverse_bits(preamble);
-  }
-
-  if (nrf_address_pos) {
-    _nrf_pipe[ --nrf_address_pos ] = reverse_bits(_syncword0 & 0xff);
-  }
-  if (nrf_address_pos) {
-    _nrf_pipe[ --nrf_address_pos ] = reverse_bits( (_syncword0 >> 8) & 0xff);
-  }
-
-  if (_syncwordLength == 4) {
-    if (nrf_address_pos) {
-      _nrf_pipe[ --nrf_address_pos ] = reverse_bits(_syncword3 & 0xff);
-    }
-    if (nrf_address_pos) {
-      _nrf_pipe[ --nrf_address_pos ] = reverse_bits( (_syncword3 >> 8) & 0xff);
-    }
-  }
-
-  _receive_length = packet_length;
-  _preamble = preamble;
-
-  _nrf_pipe_length = nrf_address_length;
-  _radio.setAddressWidth(_nrf_pipe_length);
-  _radio.openWritingPipe(_nrf_pipe);
-  _radio.openReadingPipe(1, _nrf_pipe);
-
-  _radio.setChannel(2 + _channel);
-
-
-  _radio.setPayloadSize( packet_length );
-  _radio.setAutoAck(false);
-  _radio.setPALevel(RF24_PA_MAX);
-  _radio.setDataRate(RF24_1MBPS);
-  _radio.disableCRC();
-
-  return 0;
-}
-
-
-int PL1167_nRF24::setPreambleLength(uint8_t preambleLength)
-{
-  if (preambleLength > 8) {
-    return -1;
-  }
-  _preambleLength = preambleLength;
-  return recalc_parameters();
-}
-
-
-int PL1167_nRF24::setSyncword(uint16_t syncword0, uint16_t syncword3)
-{
-  _syncwordLength = 4;
-  _syncword0 = syncword0;
-  _syncword3 = syncword3;
-  return recalc_parameters();
-}
-
-int PL1167_nRF24::setTrailerLength(uint8_t trailerLength)
-{
-  if (trailerLength < 4) {
-    return -1;
-  }
-  if (trailerLength > 18) {
-    return -1;
-  }
-  if (trailerLength & 0x01) {
-    return -1;
-  }
-  _trailerLength = trailerLength;
-  return recalc_parameters();
-}
-
-int PL1167_nRF24::setCRC(bool crc)
-{
-  _crc = crc;
-  return recalc_parameters();
-}
-
-int PL1167_nRF24::setMaxPacketLength(uint8_t maxPacketLength)
-{
-  _maxPacketLength = maxPacketLength;
-  return recalc_parameters();
-}
-
-int PL1167_nRF24::receive(uint8_t channel)
-{
-  if (channel != _channel) {
-    _channel = channel;
-    int retval = recalc_parameters();
-    if (retval < 0) {
-      return retval;
-    }
-  }
-
-  _radio.startListening();
-  if (_radio.available()) {
-#ifdef DEBUG_PRINTF
-  printf("Radio is available\n");
-#endif
-    internal_receive();
-  }
-
-  if(_received) {
-#ifdef DEBUG_PRINTF
-  if (_packet_length > 0) {
-    printf("Received packet (len = %d)!\n", _packet_length);
-  }
-#endif
-    return _packet_length;
-  } else {
-    return 0;
-  }
-}
-
-int PL1167_nRF24::readFIFO(uint8_t data[], size_t &data_length)
-{
-  if (data_length > _packet_length) {
-    data_length = _packet_length;
-  }
-  memcpy(data, _packet, data_length);
-  _packet_length -= data_length;
-  if (_packet_length) {
-    memmove(_packet, _packet + data_length, _packet_length);
-  }
-  return _packet_length;
-}
-
-int PL1167_nRF24::writeFIFO(const uint8_t data[], size_t data_length)
-{
-  if (data_length > sizeof(_packet)) {
-    data_length = sizeof(_packet);
-  }
-  memcpy(_packet, data, data_length);
-  _packet_length = data_length;
-  _received = false;
-
-  return data_length;
-}
-
-int PL1167_nRF24::transmit(uint8_t channel)
-{
-  if (channel != _channel) {
-    _channel = channel;
-    int retval = recalc_parameters();
-    if (retval < 0) {
-      return retval;
-    }
-    yield();
-  }
-
-  _radio.stopListening();
-  uint8_t tmp[sizeof(_packet)];
-
-  uint8_t trailer = (_packet[0] & 1) ? 0x55 : 0xAA;  // NOTE: This is a guess, it might also be based upon the last
-  // syncword bit, or fixed
-  int outp = 0;
-
-  for (; outp < _receive_length; outp++) {
-    uint8_t outbyte = 0;
-
-    if (outp + 1 + _nrf_pipe_length < _preambleLength) {
-      outbyte = _preamble;
-    } else if (outp + 1 + _nrf_pipe_length < _preambleLength + _syncwordLength) {
-      int syncp = outp - _preambleLength + 1 + _nrf_pipe_length;
-      switch (syncp) {
-        case 0:
-          outbyte = _syncword0 & 0xFF;
-          break;
-        case 1:
-          outbyte = (_syncword0 >> 8) & 0xFF;
-          break;
-        case 2:
-          outbyte = _syncword3 & 0xFF;
-          break;
-        case 3:
-          outbyte = (_syncword3 >> 8) & 0xFF;
-          break;
-      }
-    } else if (outp + 1 + _nrf_pipe_length < _preambleLength + _syncwordLength + (_trailerLength / 8) ) {
-      outbyte = trailer;
-    } else {
-      break;
-    }
-
-    tmp[outp] = reverse_bits(outbyte);
-  }
-
-  int buffer_fill;
-  bool last_round = false;
-  uint16_t buffer = 0;
-  uint16_t crc;
-  if (_crc) {
-    crc = calc_crc(_packet, _packet_length);
-  }
-
-  buffer = trailer >> (8 - (_trailerLength % 8));
-  buffer_fill = _trailerLength % 8;
-  for (int inp = 0; inp < _packet_length + (_crc ? 2 : 0) + 1; inp++) {
-    if (inp < _packet_length) {
-      buffer |= _packet[inp] << buffer_fill;
-      buffer_fill += 8;
-    } else if (_crc && inp < _packet_length + 2) {
-      buffer |= ((crc >>  ( (inp - _packet_length) * 8)) & 0xff) << buffer_fill;
-      buffer_fill += 8;
-    } else {
-      last_round = true;
-    }
-
-    while (buffer_fill > (last_round ? 0 : 8)) {
-      if (outp >= sizeof(tmp)) {
-        return -1;
-      }
-      tmp[outp++] = reverse_bits(buffer & 0xff);
-      buffer >>= 8;
-      buffer_fill -= 8;
-    }
-  }
-
-  yield();
-
-  _radio.write(tmp, outp);
-  return 0;
-}
-
-
-int PL1167_nRF24::internal_receive()
-{
-  uint8_t tmp[sizeof(_packet)];
-  int outp = 0;
-
-  _radio.read(tmp, _receive_length);
-
-  // HACK HACK HACK: Reset radio
-  open();
-
-  uint8_t shift_amount = _trailerLength % 8;
-  uint16_t buffer = 0;
-
-#ifdef DEBUG_PRINTF
-  printf("Packet received: ");
-  for (int i = 0; i < _receive_length; i++) {
-    printf("%02X", tmp[i]);
-  }
-  printf("\n");
-#endif
-
-  for (int inp = 0; inp < _receive_length; inp++) {
-    uint8_t inbyte = reverse_bits(tmp[inp]);
-    buffer = (buffer >> 8) | (inbyte << 8);
-
-    if (inp + 1 + _nrf_pipe_length < _preambleLength) {
-      if (inbyte != _preamble) {
-#ifdef DEBUG_PRINTF
-        printf("Preamble fail (%i: %02X)\n", inp, inbyte);
-#endif
-        return 0;
-      }
-    } else if (inp + 1 + _nrf_pipe_length < _preambleLength + _syncwordLength) {
-      int syncp = inp - _preambleLength + 1 + _nrf_pipe_length;
-      switch (syncp) {
-        case 0:
-          if (inbyte != _syncword0 & 0xFF) {
-#ifdef DEBUG_PRINTF
-            printf("Sync 0l fail (%i: %02X)\n", inp, inbyte);
-#endif
-            return 0;
-          } break;
-        case 1:
-          if (inbyte != (_syncword0 >> 8) & 0xFF) {
-#ifdef DEBUG_PRINTF
-            printf("Sync 0h fail (%i: %02X)\n", inp, inbyte);
-#endif
-            return 0;
-          } break;
-        case 2:
-          if ((_syncwordLength == 4) && (inbyte != _syncword3 & 0xFF)) {
-#ifdef DEBUG_PRINTF
-            printf("Sync 3l fail (%i: %02X)\n", inp, inbyte);
-#endif
-            return 0;
-          } break;
-        case 3:
-          if ((_syncwordLength == 4) && (inbyte != (_syncword3 >> 8) & 0xFF)) {
-#ifdef DEBUG_PRINTF
-            printf("Sync 3h fail (%i: %02X)\n", inp, inbyte);
-#endif
-            return 0;
-          } break;
-      }
-    } else if (inp + 1 + _nrf_pipe_length < _preambleLength + _syncwordLength + ((_trailerLength + 7) / 8) ) {
-
-    } else {
-      tmp[outp++] = buffer >> shift_amount;
-    }
-  }
-
-
-#ifdef DEBUG_PRINTF
-  printf("Packet transformed: ");
-  for (int i = 0; i < outp; i++) {
-    printf("%02X", tmp[i]);
-  }
-  printf("\n");
-#endif
-
-
-  if (_crc) {
-    if (outp < 2) {
-#ifdef DEBUG_PRINTF
-  printf("Failed CRC: outp < 2\n");
-#endif
-      return 0;
-    }
-    uint16_t crc = calc_crc(tmp, outp - 2);
-    if ( ((crc & 0xff) != tmp[outp - 2]) || (((crc >> 8) & 0xff) != tmp[outp - 1]) ) {
-#ifdef DEBUG_PRINTF
-  uint16_t recv_crc = ((tmp[outp - 2] & 0xFF) << 8) | (tmp[outp - 1] & 0xFF);
-  printf("Failed CRC: expected %d, got %d\n", crc, recv_crc);
-#endif
-      return 0;
-    }
-    outp -= 2;
-  }
-
-  memcpy(_packet, tmp, outp);
-
-  _packet_length = outp;
-  _received = true;
-
-#ifdef DEBUG_PRINTF
-  printf("Successfully parsed packet of length %d\n", _packet_length);
-#endif
-
-  return outp;
-}
-
-#define CRC_POLY 0x8408
-
-static uint16_t calc_crc(uint8_t *data, size_t data_length) {
-  uint16_t state = 0;
-  for (size_t i = 0; i < data_length; i++) {
-    uint8_t byte = data[i];
-    for (int j = 0; j < 8; j++) {
-      if ((byte ^ state) & 0x01) {
-        state = (state >> 1) ^ CRC_POLY;
-      } else {
-        state = state >> 1;
-      }
-      byte = byte >> 1;
-    }
-  }
-  return state;
-}
-
-static uint8_t reverse_bits(uint8_t data) {
-  uint8_t result = 0;
-  for (int i = 0; i < 8; i++) {
-    result <<= 1;
-    result |= data & 1;
-    data >>= 1;
-  }
-  return result;
-}

+ 15 - 4
lib/MiLight/PacketFormatter.cpp

@@ -1,9 +1,9 @@
 #include <PacketFormatter.h>
 
-uint8_t* PacketFormatter::PACKET_BUFFER = new uint8_t[PACKET_FORMATTER_BUFFER_SIZE];
+static uint8_t* PACKET_BUFFER = new uint8_t[PACKET_FORMATTER_BUFFER_SIZE];
 
 PacketStream::PacketStream()
-    : packetStream(NULL),
+    : packetStream(PACKET_BUFFER),
       numPackets(0),
       packetLength(0),
       currentPacket(0)
@@ -26,7 +26,10 @@ PacketFormatter::PacketFormatter(const size_t packetLength, const size_t maxPack
     held(false)
 {
   packetStream.packetLength = packetLength;
-  packetStream.packetStream = PACKET_BUFFER;
+}
+
+bool PacketFormatter::canHandle(const uint8_t *packet, const size_t len) {
+  return len == packetLength;
 }
 
 void PacketFormatter::finalizePacket(uint8_t* packet) { }
@@ -61,7 +64,9 @@ void PacketFormatter::enableNightMode() { }
 void PacketFormatter::updateTemperature(uint8_t value) { }
 void PacketFormatter::updateSaturation(uint8_t value) { }
 
-void PacketFormatter::parsePacket(const uint8_t *packet, JsonObject &result) { }
+BulbId PacketFormatter::parsePacket(const uint8_t *packet, JsonObject &result, GroupStateStore* stateStore) {
+  return DEFAULT_BULB_ID;
+}
 
 void PacketFormatter::pair() {
   for (size_t i = 0; i < 5; i++) {
@@ -111,6 +116,12 @@ void PacketFormatter::pushPacket() {
     finalizePacket(currentPacket);
   }
 
+  // Make sure there's enough buffer to add another packet.
+  if ((currentPacket + packetLength) >= PACKET_BUFFER + PACKET_FORMATTER_BUFFER_SIZE) {
+    Serial.println(F("ERROR: packet buffer full!  Cannot buffer a new packet.  THIS IS A BUG!"));
+    return;
+  }
+
   currentPacket = PACKET_BUFFER + (numPackets * packetLength);
   numPackets++;
   initializePacket(currentPacket);

+ 11 - 6
lib/MiLight/PacketFormatter.h

@@ -1,14 +1,19 @@
 #include <Arduino.h>
 #include <inttypes.h>
 #include <functional>
-#include <MiLightButtons.h>
+#include <MiLightConstants.h>
 #include <ArduinoJson.h>
-
-#define PACKET_FORMATTER_BUFFER_SIZE 48
+#include <GroupState.h>
+#include <GroupStateStore.h>
 
 #ifndef _PACKET_FORMATTER_H
 #define _PACKET_FORMATTER_H
 
+// Most packets sent is for CCT bulbs, which always includes 10 down commands
+// and can include up to 10 up commands.  CCT packets are 7 bytes.
+//   (10 * 7) + (10 * 7) = 140
+#define PACKET_FORMATTER_BUFFER_SIZE 140
+
 struct PacketStream {
   PacketStream();
 
@@ -27,6 +32,8 @@ public:
 
   typedef void (PacketFormatter::*StepFunction)();
 
+  virtual bool canHandle(const uint8_t* packet, const size_t len);
+
   void updateStatus(MiLightStatus status);
   virtual void updateStatus(MiLightStatus status, uint8_t groupId);
   virtual void command(uint8_t command, uint8_t arg);
@@ -67,15 +74,13 @@ public:
   virtual void prepare(uint16_t deviceId, uint8_t groupId);
   virtual void format(uint8_t const* packet, char* buffer);
 
-  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
 
   static void formatV1Packet(uint8_t const* packet, char* buffer);
 
   size_t getPacketLength() const;
 
 protected:
-  static uint8_t* PACKET_BUFFER;
-
   uint8_t* currentPacket;
   size_t packetLength;
   uint16_t deviceId;

+ 39 - 157
lib/MiLight/RgbCctPacketFormatter.cpp

@@ -1,60 +1,7 @@
 #include <RgbCctPacketFormatter.h>
+#include <V2RFEncoding.h>
 #include <Units.h>
 
-#define V2_OFFSET(byte, key, jumpStart) ( \
-  pgm_read_byte(&V2_OFFSETS[byte-1][key%4]) \
-    + \
-  ((jumpStart > 0 && key >= jumpStart && key <= jumpStart+0x80) ? 0x80 : 0) \
-)
-
-#define GROUP_COMMAND_ARG(status, groupId) ( groupId + (status == OFF ? 5 : 0) )
-
-uint8_t const RgbCctPacketFormatter::V2_OFFSETS[][4] = {
-  { 0x45, 0x1F, 0x14, 0x5C }, // request type
-  { 0x2B, 0xC9, 0xE3, 0x11 }, // id 1
-  { 0x6D, 0x5F, 0x8A, 0x2B }, // id 2
-  { 0xAF, 0x03, 0x1D, 0xF3 }, // command
-  { 0x1A, 0xE2, 0xF0, 0xD1 }, // argument
-  { 0x04, 0xD8, 0x71, 0x42 }, // sequence
-  { 0xAF, 0x04, 0xDD, 0x07 }, // group
-  { 0x61, 0x13, 0x38, 0x64 }  // checksum
-};
-
-void RgbCctPacketFormatter::initializePacket(uint8_t* packet) {
-  size_t packetPtr = 0;
-
-  // Always encode with 0x00 key. No utility in varying it.
-  packet[packetPtr++] = 0x00;
-
-  packet[packetPtr++] = 0x20;
-  packet[packetPtr++] = deviceId >> 8;
-  packet[packetPtr++] = deviceId & 0xFF;
-  packet[packetPtr++] = 0;
-  packet[packetPtr++] = 0;
-  packet[packetPtr++] = sequenceNum++;
-  packet[packetPtr++] = groupId;
-  packet[packetPtr++] = 0;
-}
-
-void RgbCctPacketFormatter::unpair() {
-  for (size_t i = 0; i < 5; i++) {
-    updateStatus(ON, 0);
-  }
-}
-
-void RgbCctPacketFormatter::command(uint8_t command, uint8_t arg) {
-  pushPacket();
-  if (held) {
-    command |= 0x80;
-  }
-  currentPacket[RGB_CCT_COMMAND_INDEX] = command;
-  currentPacket[RGB_CCT_ARGUMENT_INDEX] = arg;
-}
-
-void RgbCctPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) {
-  command(RGB_CCT_ON, GROUP_COMMAND_ARG(status, groupId));
-}
-
 void RgbCctPacketFormatter::modeSpeedDown() {
   command(RGB_CCT_ON, RGB_CCT_MODE_SPEED_DOWN);
 }
@@ -90,7 +37,16 @@ void RgbCctPacketFormatter::updateColorRaw(uint8_t value) {
 }
 
 void RgbCctPacketFormatter::updateTemperature(uint8_t value) {
-  command(RGB_CCT_KELVIN, RGB_CCT_KELVIN_OFFSET - (value*2));
+  // Packet scale is [0x94, 0x92, .. 0, .., 0xCE, 0xCC]. Increments of 2.
+  // From coolest to warmest.
+  // To convert from [0, 100] scale:
+  //   * Multiply by 2
+  //   * Reverse direction (increasing values should be cool -> warm)
+  //   * Start scale at 0xCC
+
+  value = ((100 - value) * 2) + RGB_CCT_KELVIN_REMOTE_END;
+
+  command(RGB_CCT_KELVIN, value);
 }
 
 void RgbCctPacketFormatter::updateSaturation(uint8_t value) {
@@ -103,25 +59,23 @@ void RgbCctPacketFormatter::updateColorWhite() {
 }
 
 void RgbCctPacketFormatter::enableNightMode() {
-  uint8_t arg = GROUP_COMMAND_ARG(OFF, groupId);
+  uint8_t arg = groupCommandArg(OFF, groupId);
   command(RGB_CCT_ON | 0x80, arg);
 }
 
-void RgbCctPacketFormatter::finalizePacket(uint8_t* packet) {
-  encodeV2Packet(packet);
-}
-
-void RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
-  uint8_t packetCopy[RGB_CCT_PACKET_LEN];
-  memcpy(packetCopy, packet, RGB_CCT_PACKET_LEN);
-  decodeV2Packet(packetCopy);
+BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result, GroupStateStore* stateStore) {
+  uint8_t packetCopy[V2_PACKET_LEN];
+  memcpy(packetCopy, packet, V2_PACKET_LEN);
+  V2RFEncoding::decodeV2Packet(packetCopy);
 
-  result["device_id"] = (packetCopy[2] << 8) | packetCopy[3];
-  result["group_id"] = packetCopy[7];
-  result["device_type"] = "rgb_cct";
+  BulbId bulbId(
+    (packetCopy[2] << 8) | packetCopy[3],
+    packetCopy[7],
+    REMOTE_TYPE_RGB_CCT
+  );
 
-  uint8_t command = (packetCopy[RGB_CCT_COMMAND_INDEX] & 0x7F);
-  uint8_t arg = packetCopy[RGB_CCT_ARGUMENT_INDEX];
+  uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F);
+  uint8_t arg = packetCopy[V2_ARGUMENT_INDEX];
 
   if (command == RGB_CCT_ON) {
     if (arg == RGB_CCT_MODE_SPEED_DOWN) {
@@ -130,29 +84,29 @@ void RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& resul
       result["command"] = "mode_speed_up";
     } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte
       result["state"] = "ON";
-      result["group_id"] = arg;
+      bulbId.groupId = arg;
     } else {
       result["state"] = "OFF";
-      result["group_id"] = arg-5;
+      bulbId.groupId = arg-5;
     }
   } else if (command == RGB_CCT_COLOR) {
     uint8_t rescaledColor = (arg - RGB_CCT_COLOR_OFFSET) % 0x100;
     uint16_t hue = Units::rescale<uint16_t, uint16_t>(rescaledColor, 360, 255.0);
     result["hue"] = hue;
   } else if (command == RGB_CCT_KELVIN) {
-    uint8_t temperature =
-        static_cast<uint8_t>(
-          // Range in packets is 180 - 220 or something like that. Shift to
-          // 0..224. Then strip out values out of range [0..24), and (224..255]
-          constrain(
-            static_cast<uint8_t>(arg + RGB_CCT_KELVIN_REMOTE_OFFSET),
-            24,
-            224
-          )
-            +
-          // Shift 24 down to 0
-          RGB_CCT_KELVIN_REMOTE_START
-        )/2; // values are in increments of 2
+    // Packet range is [0x94, 0x92, ..., 0xCC]. Remote sends values outside this
+    // range, so normalize.
+    uint8_t temperature = arg;
+    if (arg < 0xCC && arg >= 0xB0) {
+      temperature = 0xCC;
+    } else if (arg > 0x94 && arg <= 0xAF) {
+      temperature = 0x94;
+    }
+
+    temperature = (temperature + (0x100 - RGB_CCT_KELVIN_REMOTE_END)) % 0x100;
+    temperature /= 2;
+    temperature = (100 - temperature);
+    temperature = constrain(temperature, 0, 100);
 
     result["color_temp"] = Units::whiteValToMireds(temperature, 100);
   // brightness == saturation
@@ -168,77 +122,5 @@ void RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& resul
     result["argument"] = arg;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
-}
-
-uint8_t RgbCctPacketFormatter::xorKey(uint8_t key) {
-  // Generate most significant nibble
-  const uint8_t shift = (key & 0x0F) < 0x04 ? 0 : 1;
-  const uint8_t x = (((key & 0xF0) >> 4) + shift + 6) % 8;
-  const uint8_t msn = (((4 + x) ^ 1) & 0x0F) << 4;
-
-  // Generate least significant nibble
-  const uint8_t lsn = ((((key & 0xF) + 4)^2) & 0x0F);
-
-  return ( msn | lsn );
-}
-
-uint8_t RgbCctPacketFormatter::decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) {
-  uint8_t value = byte - s2;
-  value = value ^ xorKey;
-  value = value - s1;
-
-  return value;
-}
-
-uint8_t RgbCctPacketFormatter::encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) {
-  uint8_t value = byte + s1;
-  value = value ^ xorKey;
-  value = value + s2;
-
-  return value;
-}
-
-void RgbCctPacketFormatter::decodeV2Packet(uint8_t *packet) {
-  uint8_t key = xorKey(packet[0]);
-
-  for (size_t i = 1; i <= 8; i++) {
-    packet[i] = decodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START));
-  }
-}
-
-void RgbCctPacketFormatter::encodeV2Packet(uint8_t *packet) {
-  uint8_t key = xorKey(packet[0]);
-  uint8_t sum = key;
-
-  for (size_t i = 1; i <= 7; i++) {
-    sum += packet[i];
-    packet[i] = encodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START));
-  }
-
-  packet[8] = encodeByte(sum, 2, key, V2_OFFSET(8, packet[0], 0));
-}
-
-void RgbCctPacketFormatter::format(uint8_t const* packet, char* buffer) {
-  buffer += sprintf_P(buffer, PSTR("Raw packet: "));
-  for (int i = 0; i < packetLength; i++) {
-    buffer += sprintf_P(buffer, PSTR("%02X "), packet[i]);
-  }
-
-  uint8_t decodedPacket[packetLength];
-  memcpy(decodedPacket, packet, packetLength);
-
-  decodeV2Packet(decodedPacket);
-
-  buffer += sprintf_P(buffer, PSTR("\n\nDecoded:\n"));
-  buffer += sprintf_P(buffer, PSTR("Key      : %02X\n"), decodedPacket[0]);
-  buffer += sprintf_P(buffer, PSTR("b1       : %02X\n"), decodedPacket[1]);
-  buffer += sprintf_P(buffer, PSTR("ID       : %02X%02X\n"), decodedPacket[2], decodedPacket[3]);
-  buffer += sprintf_P(buffer, PSTR("Command  : %02X\n"), decodedPacket[4]);
-  buffer += sprintf_P(buffer, PSTR("Argument : %02X\n"), decodedPacket[5]);
-  buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), decodedPacket[6]);
-  buffer += sprintf_P(buffer, PSTR("Group    : %02X\n"), decodedPacket[7]);
-  buffer += sprintf_P(buffer, PSTR("Checksum : %02X"), decodedPacket[8]);
+  return bulbId;
 }

+ 9 - 28
lib/MiLight/RgbCctPacketFormatter.h

@@ -1,10 +1,9 @@
-#include <PacketFormatter.h>
+#include <V2PacketFormatter.h>
+
+#ifndef _RGB_CCT_PACKET_FORMATTER_H
+#define _RGB_CCT_PACKET_FORMATTER_H
 
-#define RGB_CCT_COMMAND_INDEX 4
-#define RGB_CCT_ARGUMENT_INDEX 5
 #define RGB_CCT_NUM_MODES 9
-#define V2_OFFSET_JUMP_START 0x54
-#define RGB_CCT_PACKET_LEN 9
 
 #define RGB_CCT_COLOR_OFFSET 0x5F
 #define RGB_CCT_BRIGHTNESS_OFFSET 0x8F
@@ -12,11 +11,8 @@
 #define RGB_CCT_KELVIN_OFFSET 0x94
 
 // Remotes have a larger range
-#define RGB_CCT_KELVIN_REMOTE_OFFSET 0x4C
-#define RGB_CCT_KELVIN_REMOTE_START  0xE8
-
-#ifndef _RGB_CCT_PACKET_FORMATTER_H
-#define _RGB_CCT_PACKET_FORMATTER_H
+#define RGB_CCT_KELVIN_REMOTE_START  0x94
+#define RGB_CCT_KELVIN_REMOTE_END    0xCC
 
 enum MiLightRgbCctCommand {
   RGB_CCT_ON = 0x01,
@@ -33,27 +29,19 @@ enum MiLightRgbCctArguments {
   RGB_CCT_MODE_SPEED_DOWN = 0x0B
 };
 
-class RgbCctPacketFormatter : public PacketFormatter {
+class RgbCctPacketFormatter : public V2PacketFormatter {
 public:
-  static uint8_t const V2_OFFSETS[][4];
-
   RgbCctPacketFormatter()
-    : PacketFormatter(RGB_CCT_PACKET_LEN),
+    : V2PacketFormatter(0x20, 4),
       lastMode(0)
   { }
 
-  virtual void initializePacket(uint8_t* packet);
-
-  virtual void updateStatus(MiLightStatus status, uint8_t group);
   virtual void updateBrightness(uint8_t value);
-  virtual void command(uint8_t command, uint8_t arg);
   virtual void updateHue(uint16_t value);
   virtual void updateColorRaw(uint8_t value);
   virtual void updateColorWhite();
   virtual void updateTemperature(uint8_t value);
   virtual void updateSaturation(uint8_t value);
-  virtual void format(uint8_t const* packet, char* buffer);
-  virtual void unpair();
   virtual void enableNightMode();
 
   virtual void modeSpeedDown();
@@ -62,14 +50,7 @@ public:
   virtual void nextMode();
   virtual void previousMode();
 
-  virtual void finalizePacket(uint8_t* packet);
-  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
-
-  static void encodeV2Packet(uint8_t* packet);
-  static void decodeV2Packet(uint8_t* packet);
-  static uint8_t xorKey(uint8_t key);
-  static uint8_t encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2);
-  static uint8_t decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
 
 protected:
 

+ 8 - 8
lib/MiLight/RgbPacketFormatter.cpp

@@ -4,7 +4,7 @@
 void RgbPacketFormatter::initializePacket(uint8_t *packet) {
   size_t packetPtr = 0;
 
-  packet[packetPtr++] = RGB;
+  packet[packetPtr++] = 0xA4;
   packet[packetPtr++] = deviceId >> 8;
   packet[packetPtr++] = deviceId & 0xFF;
   packet[packetPtr++] = 0;
@@ -79,12 +79,14 @@ void RgbPacketFormatter::previousMode() {
   command(RGB_MODE_DOWN, 0);
 }
 
-void RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
+BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore) {
   uint8_t command = packet[RGB_COMMAND_INDEX] & 0x7F;
 
-  result["group_id"] = 0;
-  result["device_id"] = (packet[1] << 8) | packet[2];
-  result["device_type"] = "rgb";
+  BulbId bulbId(
+    (packet[1] << 8) | packet[2],
+    0,
+    REMOTE_TYPE_RGB
+  );
 
   if (command == RGB_ON) {
     result["state"] = "ON";
@@ -106,9 +108,7 @@ void RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result)
     result["button_id"] = command;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
+  return bulbId;
 }
 
 void RgbPacketFormatter::format(uint8_t const* packet, char* buffer) {

+ 1 - 1
lib/MiLight/RgbPacketFormatter.h

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

+ 29 - 16
lib/MiLight/RgbwPacketFormatter.cpp

@@ -1,14 +1,18 @@
 #include <RgbwPacketFormatter.h>
-#include <MiLightButtons.h>
 #include <Units.h>
 
-#define STATUS_COMMAND(status, groupId) ( RGBW_GROUP_1_ON + ((groupId - 1)*2) + status )
-#define GROUP_FOR_STATUS_COMMAND(buttonId) ( (buttonId - 1) / 2 )
+#define STATUS_COMMAND(status, groupId) ( RGBW_GROUP_1_ON + (((groupId) - 1)*2) + (status) )
+#define GROUP_FOR_STATUS_COMMAND(buttonId) ( ((buttonId) - 1) / 2 )
+#define STATUS_FOR_COMMAND(buttonId) ( ((buttonId) % 2) == 0 ? OFF : ON )
+
+bool RgbwPacketFormatter::canHandle(const uint8_t *packet, const size_t len) {
+  return len == packetLength && (packet[0] & 0xF0) == RGBW_PROTOCOL_ID_BYTE;
+}
 
 void RgbwPacketFormatter::initializePacket(uint8_t* packet) {
   size_t packetPtr = 0;
 
-  packet[packetPtr++] = RGBW;
+  packet[packetPtr++] = RGBW_PROTOCOL_ID_BYTE;
   packet[packetPtr++] = deviceId >> 8;
   packet[packetPtr++] = deviceId & 0xFF;
   packet[packetPtr++] = 0;
@@ -42,7 +46,7 @@ void RgbwPacketFormatter::previousMode() {
 
 void RgbwPacketFormatter::updateMode(uint8_t mode) {
   command(RGBW_DISCO_MODE, 0);
-  currentPacket[0] = RGBW | mode;
+  currentPacket[0] = RGBW_PROTOCOL_ID_BYTE | mode;
 }
 
 void RgbwPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) {
@@ -87,25 +91,36 @@ void RgbwPacketFormatter::updateColorWhite() {
 }
 
 void RgbwPacketFormatter::enableNightMode() {
-  uint8_t button = STATUS_COMMAND(ON, groupId);
+  uint8_t button = STATUS_COMMAND(OFF, groupId);
 
   command(button, 0);
   command(button | 0x10, 0);
 }
 
-void RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
+BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore) {
   uint8_t command = packet[RGBW_COMMAND_INDEX] & 0x7F;
 
-  result["device_id"] = (packet[1] << 8) | packet[2];
-  result["device_type"] = "rgbw";
-  result["group_id"] = packet[RGBW_BRIGHTNESS_GROUP_INDEX] & 0x7;
+  BulbId bulbId(
+    (packet[1] << 8) | packet[2],
+    packet[RGBW_BRIGHTNESS_GROUP_INDEX] & 0x7,
+    REMOTE_TYPE_RGBW
+  );
 
   if (command >= RGBW_ALL_ON && command <= RGBW_GROUP_4_OFF) {
-    result["state"] = (command % 2) ? "ON" : "OFF";
+    result["state"] = (STATUS_FOR_COMMAND(command) == ON) ? "ON" : "OFF";
+
     // Determine group ID from button ID for on/off. The remote's state is from
     // the last packet sent, not the current one, and that can be wrong for
     // on/off commands.
-    result["group_id"] = GROUP_FOR_STATUS_COMMAND(command);
+    bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command);
+  } else if (command >= RGBW_ALL_MAX_LEVEL && command <= RGBW_GROUP_4_MIN_LEVEL) {
+    if ((command % 2) == 0) {
+      result["state"] = "ON";
+      result["command"] = "night_mode";
+    } else {
+      result["command"] = "white_mode";
+    }
+    bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command & 0xF);
   } else if (command == RGBW_BRIGHTNESS) {
     uint8_t brightness = 31;
     brightness -= packet[RGBW_BRIGHTNESS_GROUP_INDEX] >> 3;
@@ -121,14 +136,12 @@ void RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result)
   } else if (command == RGBW_SPEED_UP) {
     result["command"] = "mode_speed_up";
   } else if (command == RGBW_DISCO_MODE) {
-    result["mode"] = packet[0] & ~RGBW;
+    result["mode"] = packet[0] & ~RGBW_PROTOCOL_ID_BYTE;
   } else {
     result["button_id"] = command;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
+  return bulbId;
 }
 
 void RgbwPacketFormatter::format(uint8_t const* packet, char* buffer) {

+ 14 - 1
lib/MiLight/RgbwPacketFormatter.h

@@ -3,6 +3,8 @@
 #ifndef _RGBW_PACKET_FORMATTER_H
 #define _RGBW_PACKET_FORMATTER_H
 
+#define RGBW_PROTOCOL_ID_BYTE 0xB0
+
 enum MiLightRgbwButton {
   RGBW_ALL_ON            = 0x01,
   RGBW_ALL_OFF           = 0x02,
@@ -32,6 +34,14 @@ enum MiLightRgbwButton {
   RGBW_GROUP_3_MIN_LEVEL = 0x18,
   RGBW_GROUP_4_MAX_LEVEL = 0x19,
   RGBW_GROUP_4_MIN_LEVEL = 0x1A,
+
+  // Button codes for night mode. A long press on the corresponding OFF button
+  // Not actually needed/used.
+  RGBW_ALL_NIGHT = 0x12,
+  RGBW_GROUP_1_NIGHT = 0x14,
+  RGBW_GROUP_2_NIGHT = 0x16,
+  RGBW_GROUP_3_NIGHT = 0x18,
+  RGBW_GROUP_4_NIGHT = 0x1A,
 };
 
 #define RGBW_COMMAND_INDEX 5
@@ -46,6 +56,7 @@ public:
       lastMode(0)
   { }
 
+  virtual bool canHandle(const uint8_t* packet, const size_t len);
   virtual void updateStatus(MiLightStatus status, uint8_t groupId);
   virtual void updateBrightness(uint8_t value);
   virtual void command(uint8_t command, uint8_t arg);
@@ -60,12 +71,14 @@ public:
   virtual void previousMode();
   virtual void updateMode(uint8_t mode);
   virtual void enableNightMode();
-  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
 
   virtual void initializePacket(uint8_t* packet);
 
 protected:
   uint8_t lastMode;
+
+  static bool isStatusCommand(const uint8_t command);
 };
 
 #endif

+ 82 - 0
lib/MiLight/V2PacketFormatter.cpp

@@ -0,0 +1,82 @@
+#include <V2PacketFormatter.h>
+#include <V2RFEncoding.h>
+
+#define GROUP_COMMAND_ARG(status, groupId, numGroups) ( groupId + (status == OFF ? (numGroups + 1) : 0) )
+
+V2PacketFormatter::V2PacketFormatter(uint8_t protocolId, uint8_t numGroups)
+  : PacketFormatter(9),
+    protocolId(protocolId),
+    numGroups(numGroups)
+{ }
+
+bool V2PacketFormatter::canHandle(const uint8_t *packet, const size_t packetLen) {
+  uint8_t packetCopy[V2_PACKET_LEN];
+  memcpy(packetCopy, packet, V2_PACKET_LEN);
+  V2RFEncoding::decodeV2Packet(packetCopy);
+  return packetCopy[V2_PROTOCOL_ID_INDEX] == protocolId;
+}
+
+void V2PacketFormatter::initializePacket(uint8_t* packet) {
+  size_t packetPtr = 0;
+
+  // Always encode with 0x00 key. No utility in varying it.
+  packet[packetPtr++] = 0x00;
+
+  packet[packetPtr++] = protocolId;
+  packet[packetPtr++] = deviceId >> 8;
+  packet[packetPtr++] = deviceId & 0xFF;
+  packet[packetPtr++] = 0;
+  packet[packetPtr++] = 0;
+  packet[packetPtr++] = sequenceNum++;
+  packet[packetPtr++] = groupId;
+  packet[packetPtr++] = 0;
+}
+
+void V2PacketFormatter::command(uint8_t command, uint8_t arg) {
+  pushPacket();
+  if (held) {
+    command |= 0x80;
+  }
+  currentPacket[V2_COMMAND_INDEX] = command;
+  currentPacket[V2_ARGUMENT_INDEX] = arg;
+}
+
+void V2PacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) {
+  command(0x01, GROUP_COMMAND_ARG(status, groupId, numGroups));
+}
+
+void V2PacketFormatter::unpair() {
+  for (size_t i = 0; i < 5; i++) {
+    updateStatus(ON, 0);
+  }
+}
+
+void V2PacketFormatter::finalizePacket(uint8_t* packet) {
+  V2RFEncoding::encodeV2Packet(packet);
+}
+
+void V2PacketFormatter::format(uint8_t const* packet, char* buffer) {
+  buffer += sprintf_P(buffer, PSTR("Raw packet: "));
+  for (int i = 0; i < packetLength; i++) {
+    buffer += sprintf_P(buffer, PSTR("%02X "), packet[i]);
+  }
+
+  uint8_t decodedPacket[packetLength];
+  memcpy(decodedPacket, packet, packetLength);
+
+  V2RFEncoding::decodeV2Packet(decodedPacket);
+
+  buffer += sprintf_P(buffer, PSTR("\n\nDecoded:\n"));
+  buffer += sprintf_P(buffer, PSTR("Key      : %02X\n"), decodedPacket[0]);
+  buffer += sprintf_P(buffer, PSTR("b1       : %02X\n"), decodedPacket[1]);
+  buffer += sprintf_P(buffer, PSTR("ID       : %02X%02X\n"), decodedPacket[2], decodedPacket[3]);
+  buffer += sprintf_P(buffer, PSTR("Command  : %02X\n"), decodedPacket[4]);
+  buffer += sprintf_P(buffer, PSTR("Argument : %02X\n"), decodedPacket[5]);
+  buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), decodedPacket[6]);
+  buffer += sprintf_P(buffer, PSTR("Group    : %02X\n"), decodedPacket[7]);
+  buffer += sprintf_P(buffer, PSTR("Checksum : %02X"), decodedPacket[8]);
+}
+
+uint8_t V2PacketFormatter::groupCommandArg(MiLightStatus status, uint8_t groupId) {
+  return GROUP_COMMAND_ARG(status, groupId, numGroups);
+}

+ 34 - 0
lib/MiLight/V2PacketFormatter.h

@@ -0,0 +1,34 @@
+#include <inttypes.h>
+#include <PacketFormatter.h>
+
+#ifndef _V2_PACKET_FORMATTER
+#define _V2_PACKET_FORMATTER
+
+#define V2_PACKET_LEN 9
+
+#define V2_PROTOCOL_ID_INDEX 1
+#define V2_COMMAND_INDEX 4
+#define V2_ARGUMENT_INDEX 5
+
+class V2PacketFormatter : public PacketFormatter {
+public:
+  V2PacketFormatter(uint8_t protocolId, uint8_t numGroups);
+
+  virtual bool canHandle(const uint8_t* packet, const size_t packetLen);
+  virtual void initializePacket(uint8_t* packet);
+
+  virtual void updateStatus(MiLightStatus status, uint8_t group);
+  virtual void command(uint8_t command, uint8_t arg);
+  virtual void format(uint8_t const* packet, char* buffer);
+  virtual void unpair();
+
+  virtual void finalizePacket(uint8_t* packet);
+
+  uint8_t groupCommandArg(MiLightStatus status, uint8_t groupId);
+
+protected:
+  const uint8_t protocolId;
+  const uint8_t numGroups;
+};
+
+#endif

+ 66 - 0
lib/MiLight/V2RFEncoding.cpp

@@ -0,0 +1,66 @@
+#include <V2RFEncoding.h>
+
+#define V2_OFFSET(byte, key, jumpStart) ( \
+  V2_OFFSETS[byte-1][key%4] \
+    + \
+  ((jumpStart > 0 && key >= jumpStart && key < jumpStart+0x80) ? 0x80 : 0) \
+)
+
+uint8_t const V2RFEncoding::V2_OFFSETS[][4] = {
+  { 0x45, 0x1F, 0x14, 0x5C }, // request type
+  { 0x2B, 0xC9, 0xE3, 0x11 }, // id 1
+  { 0x6D, 0x5F, 0x8A, 0x2B }, // id 2
+  { 0xAF, 0x03, 0x1D, 0xF3 }, // command
+  { 0x1A, 0xE2, 0xF0, 0xD1 }, // argument
+  { 0x04, 0xD8, 0x71, 0x42 }, // sequence
+  { 0xAF, 0x04, 0xDD, 0x07 }, // group
+  { 0x61, 0x13, 0x38, 0x64 }  // checksum
+};
+
+uint8_t V2RFEncoding::xorKey(uint8_t key) {
+  // Generate most significant nibble
+  const uint8_t shift = (key & 0x0F) < 0x04 ? 0 : 1;
+  const uint8_t x = (((key & 0xF0) >> 4) + shift + 6) % 8;
+  const uint8_t msn = (((4 + x) ^ 1) & 0x0F) << 4;
+
+  // Generate least significant nibble
+  const uint8_t lsn = ((((key & 0xF) + 4)^2) & 0x0F);
+
+  return ( msn | lsn );
+}
+
+uint8_t V2RFEncoding::decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) {
+  uint8_t value = byte - s2;
+  value = value ^ xorKey;
+  value = value - s1;
+
+  return value;
+}
+
+uint8_t V2RFEncoding::encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) {
+  uint8_t value = byte + s1;
+  value = value ^ xorKey;
+  value = value + s2;
+
+  return value;
+}
+
+void V2RFEncoding::decodeV2Packet(uint8_t *packet) {
+  uint8_t key = xorKey(packet[0]);
+
+  for (size_t i = 1; i <= 8; i++) {
+    packet[i] = decodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START));
+  }
+}
+
+void V2RFEncoding::encodeV2Packet(uint8_t *packet) {
+  uint8_t key = xorKey(packet[0]);
+  uint8_t sum = key;
+
+  for (size_t i = 1; i <= 7; i++) {
+    sum += packet[i];
+    packet[i] = encodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START));
+  }
+
+  packet[8] = encodeByte(sum, 2, key, V2_OFFSET(8, packet[0], 0));
+}

+ 21 - 0
lib/MiLight/V2RFEncoding.h

@@ -0,0 +1,21 @@
+#include <Arduino.h>
+#include <inttypes.h>
+
+#ifndef _V2_RF_ENCODING_H
+#define _V2_RF_ENCODING_H
+
+#define V2_OFFSET_JUMP_START 0x54
+
+class V2RFEncoding {
+public:
+  static void encodeV2Packet(uint8_t* packet);
+  static void decodeV2Packet(uint8_t* packet);
+  static uint8_t xorKey(uint8_t key);
+  static uint8_t encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2);
+  static uint8_t decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2);
+
+private:
+  static uint8_t const V2_OFFSETS[][4];
+};
+
+#endif

+ 390 - 0
lib/MiLightState/GroupState.cpp

@@ -0,0 +1,390 @@
+#include <GroupState.h>
+#include <Units.h>
+#include <MiLightRemoteConfig.h>
+#include <RGBConverter.h>
+
+const BulbId DEFAULT_BULB_ID;
+
+const GroupState& GroupState::defaultState(MiLightRemoteType remoteType) {
+  static GroupState instances[MiLightRemoteConfig::NUM_REMOTES];
+  GroupState& state = instances[remoteType];
+
+  switch (remoteType) {
+    case REMOTE_TYPE_RGB:
+      state.setBulbMode(BULB_MODE_COLOR);
+      break;
+    case REMOTE_TYPE_CCT:
+      state.setBulbMode(BULB_MODE_WHITE);
+      break;
+  }
+
+  return state;
+}
+
+BulbId::BulbId()
+  : deviceId(0),
+    groupId(0),
+    deviceType(REMOTE_TYPE_UNKNOWN)
+{ }
+
+BulbId::BulbId(const BulbId &other)
+  : deviceId(other.deviceId),
+    groupId(other.groupId),
+    deviceType(other.deviceType)
+{ }
+
+BulbId::BulbId(
+  const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType
+)
+  : deviceId(deviceId),
+    groupId(groupId),
+    deviceType(deviceType)
+{ }
+
+void BulbId::operator=(const BulbId &other) {
+  deviceId = other.deviceId;
+  groupId = other.groupId;
+  deviceType = other.deviceType;
+}
+
+bool BulbId::operator==(const BulbId &other) {
+  return deviceId == other.deviceId
+    && groupId == other.groupId
+    && deviceType == other.deviceType;
+}
+
+GroupState::GroupState() {
+  state.fields._state                = 0;
+  state.fields._brightness           = 0;
+  state.fields._brightnessColor      = 0;
+  state.fields._brightnessMode       = 0;
+  state.fields._hue                  = 0;
+  state.fields._saturation           = 0;
+  state.fields._mode                 = 0;
+  state.fields._bulbMode             = 0;
+  state.fields._kelvin               = 0;
+  state.fields._isSetState           = 0;
+  state.fields._isSetHue             = 0;
+  state.fields._isSetBrightness      = 0;
+  state.fields._isSetBrightnessColor = 0;
+  state.fields._isSetBrightnessMode  = 0;
+  state.fields._isSetSaturation      = 0;
+  state.fields._isSetMode            = 0;
+  state.fields._isSetKelvin          = 0;
+  state.fields._isSetBulbMode        = 0;
+  state.fields._dirty                = 1;
+  state.fields._mqttDirty            = 0;
+}
+
+bool GroupState::isSetField(GroupStateField field) const {
+  switch (field) {
+    case GroupStateField::STATE:
+    case GroupStateField::STATUS:
+      return isSetState();
+    case GroupStateField::BRIGHTNESS:
+    case GroupStateField::LEVEL:
+      return isSetBrightness();
+    case GroupStateField::COLOR:
+    case GroupStateField::HUE:
+      return isSetHue();
+    case GroupStateField::SATURATION:
+      return isSetSaturation();
+    case GroupStateField::MODE:
+      return isSetMode();
+    case GroupStateField::KELVIN:
+    case GroupStateField::COLOR_TEMP:
+      return isSetKelvin();
+    case GroupStateField::BULB_MODE:
+      return isSetBulbMode();
+  }
+
+  return false;
+}
+
+bool GroupState::isSetState() const { return state.fields._isSetState; }
+MiLightStatus GroupState::getState() const { return state.fields._state ? ON : OFF; }
+bool GroupState::setState(const MiLightStatus status) {
+  if (isSetState() && getState() == status) {
+    return false;
+  }
+
+  setDirty();
+  state.fields._isSetState = 1;
+  state.fields._state = status == ON ? 1 : 0;
+
+  return true;
+}
+
+bool GroupState::isSetBrightness() const {
+  if (! state.fields._isSetBulbMode) {
+    return state.fields._isSetBrightness;
+  }
+
+  switch (state.fields._bulbMode) {
+    case BULB_MODE_WHITE:
+      return state.fields._isSetBrightness;
+    case BULB_MODE_COLOR:
+      return state.fields._isSetBrightnessColor;
+    case BULB_MODE_SCENE:
+      return state.fields._isSetBrightnessMode;
+  }
+
+  return false;
+}
+uint8_t GroupState::getBrightness() const {
+  switch (state.fields._bulbMode) {
+    case BULB_MODE_WHITE:
+      return state.fields._brightness;
+    case BULB_MODE_COLOR:
+      return state.fields._brightnessColor;
+    case BULB_MODE_SCENE:
+      return state.fields._brightnessMode;
+  }
+
+  return 0;
+}
+bool GroupState::setBrightness(uint8_t brightness) {
+  if (isSetBrightness() && getBrightness() == brightness) {
+    return false;
+  }
+
+  setDirty();
+
+  uint8_t bulbMode = state.fields._bulbMode;
+  if (! state.fields._isSetBulbMode) {
+    bulbMode = BULB_MODE_WHITE;
+  }
+
+  switch (bulbMode) {
+    case BULB_MODE_WHITE:
+      state.fields._isSetBrightness = 1;
+      state.fields._brightness = brightness;
+      break;
+    case BULB_MODE_COLOR:
+      state.fields._isSetBrightnessColor = 1;
+      state.fields._brightnessColor = brightness;
+      break;
+    case BULB_MODE_SCENE:
+      state.fields._isSetBrightnessMode = 1;
+      state.fields._brightnessMode = brightness;
+    default:
+      return false;
+  }
+
+  return true;
+}
+
+bool GroupState::isSetHue() const { return state.fields._isSetHue; }
+uint16_t GroupState::getHue() const {
+  return Units::rescale<uint16_t, uint16_t>(state.fields._hue, 360, 255);
+}
+bool GroupState::setHue(uint16_t hue) {
+  if (isSetHue() && getHue() == hue) {
+    return false;
+  }
+
+  setDirty();
+  state.fields._isSetHue = 1;
+  state.fields._hue = Units::rescale<uint16_t, uint16_t>(hue, 255, 360);
+
+  return true;
+}
+
+bool GroupState::isSetSaturation() const { return state.fields._isSetSaturation; }
+uint8_t GroupState::getSaturation() const { return state.fields._saturation; }
+bool GroupState::setSaturation(uint8_t saturation) {
+  if (isSetSaturation() && getSaturation() == saturation) {
+    return false;
+  }
+
+  setDirty();
+  state.fields._isSetSaturation = 1;
+  state.fields._saturation = saturation;
+
+  return true;
+}
+
+bool GroupState::isSetMode() const { return state.fields._isSetMode; }
+uint8_t GroupState::getMode() const { return state.fields._mode; }
+bool GroupState::setMode(uint8_t mode) {
+  if (isSetMode() && getMode() == mode) {
+    return false;
+  }
+
+  setDirty();
+  state.fields._isSetMode = 1;
+  state.fields._mode = mode;
+
+  return true;
+}
+
+bool GroupState::isSetKelvin() const { return state.fields._isSetKelvin; }
+uint8_t GroupState::getKelvin() const { return state.fields._kelvin; }
+uint16_t GroupState::getMireds() const {
+  return Units::whiteValToMireds(getKelvin(), 100);
+}
+bool GroupState::setKelvin(uint8_t kelvin) {
+  if (isSetKelvin() && getKelvin() == kelvin) {
+    return false;
+  }
+
+  setDirty();
+  state.fields._isSetKelvin = 1;
+  state.fields._kelvin = kelvin;
+
+  return true;
+}
+bool GroupState::setMireds(uint16_t mireds) {
+  return setKelvin(Units::miredsToWhiteVal(mireds, 100));
+}
+
+bool GroupState::isSetBulbMode() const { return state.fields._isSetBulbMode; }
+BulbMode GroupState::getBulbMode() const { return static_cast<BulbMode>(state.fields._bulbMode); }
+bool GroupState::setBulbMode(BulbMode bulbMode) {
+  if (isSetBulbMode() && getBulbMode() == bulbMode) {
+    return false;
+  }
+
+  setDirty();
+  state.fields._isSetBulbMode = 1;
+  state.fields._bulbMode = bulbMode;
+
+  return true;
+}
+
+bool GroupState::isDirty() const { return state.fields._dirty; }
+inline bool GroupState::setDirty() {
+  state.fields._dirty = 1;
+  state.fields._mqttDirty = 1;
+}
+bool GroupState::clearDirty() { state.fields._dirty = 0; }
+
+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);
+  }
+  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);
+  }
+}
+
+bool GroupState::patch(const JsonObject& state) {
+  bool changes = false;
+
+  if (state.containsKey("state")) {
+    changes |= setState(state["state"] == "ON" ? ON : OFF);
+  }
+  if (state.containsKey("brightness")) {
+    changes |= setBrightness(Units::rescale(state.get<uint8_t>("brightness"), 100, 255));
+  }
+  if (state.containsKey("hue")) {
+    changes |= setHue(state["hue"]);
+    changes |= setBulbMode(BULB_MODE_COLOR);
+  }
+  if (state.containsKey("saturation")) {
+    changes |= setSaturation(state["saturation"]);
+  }
+  if (state.containsKey("mode")) {
+    changes |= setMode(state["mode"]);
+    changes |= setBulbMode(BULB_MODE_SCENE);
+  }
+  if (state.containsKey("color_temp")) {
+    changes |= setMireds(state["color_temp"]);
+    changes |= setBulbMode(BULB_MODE_WHITE);
+  }
+  if (state.containsKey("command")) {
+    const String& command = state["command"];
+
+    if (command == "white_mode") {
+      changes |= setBulbMode(BULB_MODE_WHITE);
+    } else if (command == "night_mode") {
+      changes |= setBulbMode(BULB_MODE_NIGHT);
+    }
+  }
+
+  return changes;
+}
+
+void GroupState::applyField(JsonObject& partialState, GroupStateField field) {
+  if (isSetField(field)) {
+    switch (field) {
+      case GroupStateField::STATE:
+      case GroupStateField::STATUS:
+        partialState[GroupStateFieldHelpers::getFieldName(field)] = getState() == ON ? "ON" : "OFF";
+        break;
+
+      case GroupStateField::BRIGHTNESS:
+        partialState["brightness"] = Units::rescale(getBrightness(), 255, 100);
+        break;
+
+      case GroupStateField::LEVEL:
+        partialState["level"] = getBrightness();
+        break;
+
+      case GroupStateField::BULB_MODE:
+        partialState["bulb_mode"] = BULB_MODE_NAMES[getBulbMode()];
+        break;
+
+      case GroupStateField::COLOR:
+        if (getBulbMode() == BULB_MODE_COLOR) {
+          uint8_t rgb[3];
+          RGBConverter converter;
+          converter.hsvToRgb(
+            getHue()/360.0,
+            // Default to fully saturated
+            (isSetSaturation() ? getSaturation() : 100)/100.0,
+            1,
+            rgb
+          );
+          JsonObject& color = partialState.createNestedObject("color");
+          color["r"] = rgb[0];
+          color["g"] = rgb[1];
+          color["b"] = rgb[2];
+        }
+        break;
+
+      case GroupStateField::HUE:
+        if (getBulbMode() == BULB_MODE_COLOR) {
+          partialState["hue"] = getHue();
+        }
+        break;
+
+      case GroupStateField::SATURATION:
+        if (getBulbMode() == BULB_MODE_COLOR) {
+          partialState["saturation"] = getSaturation();
+        }
+        break;
+
+      case GroupStateField::MODE:
+        if (getBulbMode() == BULB_MODE_SCENE) {
+          partialState["mode"] = getMode();
+        }
+        break;
+
+      case GroupStateField::COLOR_TEMP:
+        if (getBulbMode() == BULB_MODE_WHITE) {
+          partialState["color_temp"] = getMireds();
+        }
+        break;
+
+      case GroupStateField::KELVIN:
+        if (getBulbMode() == BULB_MODE_WHITE) {
+          partialState["kelvin"] = getKelvin();
+        }
+        break;
+    }
+  }
+}
+
+void GroupState::applyState(JsonObject& partialState, GroupStateField* fields, size_t numFields) {
+  for (size_t i = 0; i < numFields; i++) {
+    applyField(partialState, fields[i]);
+  }
+}

+ 133 - 0
lib/MiLightState/GroupState.h

@@ -0,0 +1,133 @@
+#include <stddef.h>
+#include <inttypes.h>
+#include <MiLightConstants.h>
+#include <MiLightRadioConfig.h>
+#include <GroupStateField.h>
+#include <ArduinoJson.h>
+
+#ifndef _GROUP_STATE_H
+#define _GROUP_STATE_H
+
+struct BulbId {
+  uint16_t deviceId;
+  uint8_t groupId;
+  MiLightRemoteType deviceType;
+
+  BulbId();
+  BulbId(const BulbId& other);
+  BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType);
+  bool operator==(const BulbId& other);
+  void operator=(const BulbId& other);
+};
+
+enum BulbMode {
+  BULB_MODE_WHITE,
+  BULB_MODE_COLOR,
+  BULB_MODE_SCENE,
+  BULB_MODE_NIGHT
+};
+static const char* BULB_MODE_NAMES[] = {
+  "white",
+  "color",
+  "scene",
+  "night"
+};
+
+class GroupState {
+public:
+
+  GroupState();
+
+  bool isSetField(GroupStateField field) const;
+
+  // 1 bit
+  bool isSetState() const;
+  MiLightStatus getState() const;
+  bool setState(const MiLightStatus on);
+
+  // 7 bits
+  bool isSetBrightness() const;
+  uint8_t getBrightness() const;
+  bool setBrightness(uint8_t brightness);
+
+  // 8 bits
+  bool isSetHue() const;
+  uint16_t getHue() const;
+  bool setHue(uint16_t hue);
+
+  // 7 bits
+  bool isSetSaturation() const;
+  uint8_t getSaturation() const;
+  bool setSaturation(uint8_t saturation);
+
+  // 5 bits
+  bool isSetMode() const;
+  uint8_t getMode() const;
+  bool setMode(uint8_t mode);
+
+  // 7 bits
+  bool isSetKelvin() const;
+  uint8_t getKelvin() const;
+  uint16_t getMireds() const;
+  bool setKelvin(uint8_t kelvin);
+  bool setMireds(uint16_t mireds);
+
+  // 3 bits
+  bool isSetBulbMode() const;
+  BulbMode getBulbMode() const;
+  bool setBulbMode(BulbMode mode);
+
+  bool isDirty() const;
+  inline bool setDirty();
+  bool clearDirty();
+
+  bool isMqttDirty() const;
+  inline bool setMqttDirty();
+  bool clearMqttDirty();
+
+  bool patch(const JsonObject& state);
+  void applyField(JsonObject& state, GroupStateField field);
+  void applyState(JsonObject& state, GroupStateField* fields, size_t numFields);
+
+  void load(Stream& stream);
+  void dump(Stream& stream) const;
+
+  static const GroupState& defaultState(MiLightRemoteType remoteType);
+
+private:
+  static const size_t DATA_BYTES = 2;
+  union Data {
+    uint32_t data[DATA_BYTES];
+    struct Fields {
+      uint32_t
+        _state                : 1,
+        _brightness           : 7,
+        _hue                  : 8,
+        _saturation           : 7,
+        _mode                 : 4,
+        _bulbMode             : 3,
+        _isSetState           : 1,
+        _isSetHue             : 1;
+      uint32_t
+        _kelvin               : 7,
+        _isSetBrightness      : 1,
+        _isSetSaturation      : 1,
+        _isSetMode            : 1,
+        _isSetKelvin          : 1,
+        _isSetBulbMode        : 1,
+        _brightnessColor      : 7,
+        _brightnessMode       : 7,
+        _isSetBrightnessColor : 1,
+        _isSetBrightnessMode  : 1,
+        _dirty                : 1,
+        _mqttDirty            : 1,
+                              : 4;
+    } fields;
+  };
+
+  Data state;
+};
+
+extern const BulbId DEFAULT_BULB_ID;
+
+#endif

+ 63 - 0
lib/MiLightState/GroupStateCache.cpp

@@ -0,0 +1,63 @@
+#include <GroupStateCache.h>
+
+GroupStateCache::GroupStateCache(const size_t maxSize)
+  : maxSize(maxSize)
+{ }
+
+GroupState* GroupStateCache::get(const BulbId& id) {
+  return getInternal(id);
+}
+
+GroupState* GroupStateCache::set(const BulbId& id, const GroupState& state) {
+  GroupCacheNode* pushedNode = NULL;
+  if (cache.size() >= maxSize) {
+    pushedNode = cache.pop();
+  }
+
+  GroupState* cachedState = getInternal(id);
+
+  if (cachedState == NULL) {
+    if (pushedNode == NULL) {
+      GroupCacheNode* newNode = new GroupCacheNode(id, state);
+      cachedState = &newNode->state;
+      cache.unshift(newNode);
+    } else {
+      pushedNode->id = id;
+      pushedNode->state = state;
+      cachedState = &pushedNode->state;
+      cache.unshift(pushedNode);
+    }
+  } else {
+    *cachedState = state;
+  }
+
+  return cachedState;
+}
+
+BulbId GroupStateCache::getLru() {
+  GroupCacheNode* node = cache.getLast();
+  return node->id;
+}
+
+bool GroupStateCache::isFull() const {
+  return cache.size() >= maxSize;
+}
+
+ListNode<GroupCacheNode*>* GroupStateCache::getHead() {
+  return cache.getHead();
+}
+
+GroupState* GroupStateCache::getInternal(const BulbId& id) {
+  ListNode<GroupCacheNode*>* cur = cache.getHead();
+
+  while (cur != NULL) {
+    if (cur->data->id == id) {
+      GroupState* result = &cur->data->state;
+      cache.spliceToFront(cur);
+      return result;
+    }
+    cur = cur->next;
+  }
+
+  return NULL;
+}

+ 33 - 0
lib/MiLightState/GroupStateCache.h

@@ -0,0 +1,33 @@
+#include <GroupState.h>
+#include <LinkedList.h>
+
+#ifndef _GROUP_STATE_CACHE_H
+#define _GROUP_STATE_CACHE_H
+
+struct GroupCacheNode {
+  GroupCacheNode() {}
+  GroupCacheNode(const BulbId& id, const GroupState& state)
+    : id(id), state(state) { }
+
+  BulbId id;
+  GroupState state;
+};
+
+class GroupStateCache {
+public:
+  GroupStateCache(const size_t maxSize);
+
+  GroupState* get(const BulbId& id);
+  GroupState* set(const BulbId& id, const GroupState& state);
+  BulbId getLru();
+  bool isFull() const;
+  ListNode<GroupCacheNode*>* getHead();
+
+private:
+  LinkedList<GroupCacheNode*> cache;
+  const size_t maxSize;
+
+  GroupState* getInternal(const BulbId& id);
+};
+
+#endif

+ 40 - 0
lib/MiLightState/GroupStatePersistence.cpp

@@ -0,0 +1,40 @@
+#include <GroupStatePersistence.h>
+#include <FS.h>
+
+static const char FILE_PREFIX[] = "group_states/";
+
+void GroupStatePersistence::get(const BulbId &id, GroupState& state) {
+  char path[30];
+  memset(path, 0, 30);
+  buildFilename(id, path);
+
+  if (SPIFFS.exists(path)) {
+    File f = SPIFFS.open(path, "r");
+    state.load(f);
+    f.close();
+  }
+}
+
+void GroupStatePersistence::set(const BulbId &id, const GroupState& state) {
+  char path[30];
+  memset(path, 0, 30);
+  buildFilename(id, path);
+
+  File f = SPIFFS.open(path, "w");
+  state.dump(f);
+  f.close();
+}
+
+void GroupStatePersistence::clear(const BulbId &id) {
+  char path[30];
+  buildFilename(id, path);
+
+  if (SPIFFS.exists(path)) {
+    SPIFFS.remove(path);
+  }
+}
+
+char* GroupStatePersistence::buildFilename(const BulbId &id, char *buffer) {
+  uint32_t compactId = (id.deviceId << 24) | (id.deviceType << 8) | id.groupId;
+  return buffer + sprintf(buffer, "%s%x", FILE_PREFIX, compactId);
+}

+ 18 - 0
lib/MiLightState/GroupStatePersistence.h

@@ -0,0 +1,18 @@
+#include <GroupState.h>
+
+#ifndef _GROUP_STATE_PERSISTENCE_H
+#define _GROUP_STATE_PERSISTENCE_H
+
+class GroupStatePersistence {
+public:
+  void get(const BulbId& id, GroupState& state);
+  void set(const BulbId& id, const GroupState& state);
+
+  void clear(const BulbId& id);
+
+private:
+
+  static char* buildFilename(const BulbId& id, char* buffer);
+};
+
+#endif

+ 85 - 0
lib/MiLightState/GroupStateStore.cpp

@@ -0,0 +1,85 @@
+#include <GroupStateStore.h>
+#include <MiLightRemoteConfig.h>
+
+GroupStateStore::GroupStateStore(const size_t maxSize, const size_t flushRate)
+  : cache(GroupStateCache(maxSize)),
+    flushRate(flushRate),
+    lastFlush(0)
+{ }
+
+GroupState& GroupStateStore::get(const BulbId& id) {
+  GroupState* state = cache.get(id);
+
+  if (state == NULL) {
+    trackEviction();
+    GroupState loadedState = GroupState::defaultState(id.deviceType);
+    persistence.get(id, loadedState);
+
+    state = cache.set(id, loadedState);
+  }
+
+  return *state;
+}
+
+GroupState& GroupStateStore::set(const BulbId &id, const GroupState& state) {
+  GroupState& storedState = get(id);
+  storedState = state;
+
+  if (id.groupId == 0) {
+    const MiLightRemoteConfig* remote = MiLightRemoteConfig::fromType(id.deviceType);
+    BulbId individualBulb(id);
+
+    for (size_t i = 1; i <= remote->numGroups; i++) {
+      individualBulb.groupId = i;
+      set(individualBulb, state);
+    }
+  }
+
+  return storedState;
+}
+
+void GroupStateStore::trackEviction() {
+  if (cache.isFull()) {
+    evictedIds.add(cache.getLru());
+  }
+}
+
+bool GroupStateStore::flush() {
+  ListNode<GroupCacheNode*>* curr = cache.getHead();
+  bool anythingFlushed = false;
+
+  while (curr != NULL && curr->data->state.isDirty() && !anythingFlushed) {
+    persistence.set(curr->data->id, curr->data->state);
+    curr->data->state.clearDirty();
+
+#ifdef STATE_DEBUG
+    BulbId bulbId = curr->data->id;
+    printf(
+      "Flushing dirty state for 0x%04X / %d / %s\n",
+      bulbId.deviceId,
+      bulbId.groupId,
+      MiLightRemoteConfig::fromType(bulbId.deviceType)->name.c_str()
+    );
+#endif
+
+    curr = curr->next;
+    anythingFlushed = true;
+  }
+
+  while (evictedIds.size() > 0 && !anythingFlushed) {
+    persistence.clear(evictedIds.shift());
+    anythingFlushed = true;
+  }
+
+  return anythingFlushed;
+}
+
+void GroupStateStore::limitedFlush() {
+  unsigned long now = millis();
+
+  if ((lastFlush + flushRate) < now) {
+    if (flush()) {
+      lastFlush = now;
+    }
+  }
+}

+ 46 - 0
lib/MiLightState/GroupStateStore.h

@@ -0,0 +1,46 @@
+#include <GroupState.h>
+#include <GroupStateCache.h>
+#include <GroupStatePersistence.h>
+
+#ifndef _GROUP_STATE_STORE_H
+#define _GROUP_STATE_STORE_H
+
+class GroupStateStore {
+public:
+  GroupStateStore(const size_t maxSize, const size_t flushRate);
+
+  /*
+   * Returns the state for the given BulbId.  If no state exists, a suitable
+   * default state will be returned.
+   */
+  GroupState& get(const BulbId& id);
+
+  /*
+   * 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);
+
+  /*
+   * Flushes all states to persistent storage.  Returns true iff anything was
+   * flushed.
+   */
+  bool flush();
+
+  /*
+   * Flushes at most one dirty state to persistent storage.  Rate limit
+   * specified by Settings.
+   */
+  void limitedFlush();
+
+private:
+  GroupStateCache cache;
+  GroupStatePersistence persistence;
+  LinkedList<BulbId> evictedIds;
+  const size_t flushRate;
+  unsigned long lastFlush;
+
+  void trackEviction();
+};
+
+#endif

+ 46 - 106
lib/MiLight/LT8900MiLightRadio.cpp

@@ -47,7 +47,7 @@ LT8900MiLightRadio::LT8900MiLightRadio(byte byCSPin, byte byResetPin, byte byPkt
   SPI.setBitOrder(MSBFIRST);
 
   //Initialize transceiver with correct settings
-  vInitRadioModule(config.type);
+  vInitRadioModule();
   delay(50);
 
   // Check if HW is connected
@@ -90,108 +90,48 @@ bool LT8900MiLightRadio::bCheckRadioConnection(void)
 /**************************************************************************/
 // Initialize radio module
 /**************************************************************************/
-void LT8900MiLightRadio::vInitRadioModule(MiLightRadioType type) {
-	if (type == RGB_CCT) {
-		bool bWriteDefaultDefault = true;  // Is it okay to use the default power up values, without setting them
-
-		regWrite16(0x00, 0x6F, 0xE0, 7);  // Recommended value by PMmicro
-		regWrite16(0x02, 0x66, 0x17, 7);  // Recommended value by PMmicro
-		regWrite16(0x04, 0x9C, 0xC9, 7);  // Recommended value by PMmicro
-
-		regWrite16(0x05, 0x66, 0x37, 7);  // Recommended value by PMmicro
-		regWrite16(0x07, 0x00, 0x4C, 7);  // PL1167's TX/RX Enable and Channel Register, Default channel 76
-		regWrite16(0x08, 0x6C, 0x90, 7);  // Recommended value by PMmicro
-		regWrite16(0x09, 0x48, 0x00, 7);  // PA Control register
-
-		regWrite16(0x0B, 0x00, 0x08, 7);  // Recommended value by PMmicro
-		regWrite16(0x0D, 0x48, 0xBD, 7);  // Recommended value by PMmicro
-		regWrite16(0x16, 0x00, 0xFF, 7);  // Recommended value by PMmicro
-		regWrite16(0x18, 0x00, 0x67, 7);  // Recommended value by PMmicro
-
-		regWrite16(0x1A, 0x19, 0xE0, 7);  // Recommended value by PMmicro
-		regWrite16(0x1B, 0x13, 0x00, 7);  // Recommended value by PMmicro
-
-		regWrite16(0x20, 0x48, 0x00, 7);  // Recommended value by PMmicro
-		regWrite16(0x21, 0x3F, 0xC7, 7);  // Recommended value by PMmicro
-		regWrite16(0x22, 0x20, 0x00, 7);  // Recommended value by PMmicro
-		regWrite16(0x23, 0x03, 0x00, 7);  // Recommended value by PMmicro
-
-		regWrite16(0x24, 0x72, 0x36, 7);  // Sync R0
-		regWrite16(0x27, 0x18, 0x09, 7);  // Sync R3
-		regWrite16(0x28, 0x44, 0x02, 7);  // Recommended value by PMmicro
-		regWrite16(0x29, 0xB0, 0x00, 7);  // Recommended value by PMmicro
-		regWrite16(0x2A, 0xFD, 0xB0, 7);  // Recommended value by PMmicro
-
-		if (bWriteDefaultDefault == true) {
-			regWrite16(0x01, 0x56, 0x81, 7);  // Recommended value by PMmicro
-			regWrite16(0x0A, 0x7F, 0xFD, 7);  // Recommended value by PMmicro
-			regWrite16(0x0C, 0x00, 0x00, 7);  // Recommended value by PMmicro
-			regWrite16(0x17, 0x80, 0x05, 7);  // Recommended value by PMmicro
-			regWrite16(0x19, 0x16, 0x59, 7);  // Recommended value by PMmicro
-			regWrite16(0x1C, 0x18, 0x00, 7);  // Recommended value by PMmicro
-
-			regWrite16(0x25, 0x00, 0x00, 7);  // Recommended value by PMmicro
-			regWrite16(0x26, 0x00, 0x00, 7);  // Recommended value by PMmicro
-			regWrite16(0x2B, 0x00, 0x0F, 7);  // Recommended value by PMmicro
-		}
-	} else if( (type == RGBW) || (type == CCT) || (type == RGB) ) {
-		regWrite16(0x00, 0x6F, 0xE0, 7);  // Recommended value by PMmicro
-		regWrite16(0x01, 0x56, 0x81, 7);   // Recommended value by PMmicro
-		regWrite16(0x02, 0x66, 0x17, 7);   // Recommended value by PMmicro
-		regWrite16(0x04, 0x9C, 0xC9, 7);  // Recommended value by PMmicro
-		regWrite16(0x05, 0x66, 0x37, 7);   // Recommended value by PMmicro
-		regWrite16(0x07, 0x00, 0x4C, 7);     // PL1167's TX/RX Enable and Channel Register
-		regWrite16(0x08, 0x6C, 0x90, 7);  // Recommended value by PMmicro
-		regWrite16(0x09, 0x48, 0x00, 7);     // PL1167's PA Control Register
-		regWrite16(0x0A, 0x7F, 0xFD, 7); // Recommended value by PMmicro
-		regWrite16(0x0B, 0x00, 0x08, 7);     // PL1167's RSSI OFF Control Register -- ???
-		regWrite16(0x0C, 0x00, 0x00, 7);     // Recommended value by PMmicro
-		regWrite16(0x0D, 0x48, 0xBD, 7);  // Recommended value by PMmicro
-		regWrite16(0x16, 0x00, 0xFF, 7);   // Recommended value by PMmicro
-		regWrite16(0x17, 0x80, 0x05, 7);   // PL1167's VCO Calibration Enable Register
-		regWrite16(0x18, 0x00, 0x67, 7);   // Recommended value by PMmicro
-		regWrite16(0x19, 0x16, 0x59, 7);   // Recommended value by PMmicro
-		regWrite16(0x1A, 0x19, 0xE0, 7);  // Recommended value by PMmicro
-		regWrite16(0x1B, 0x13, 0x00, 7);    // Recommended value by PMmicro
-		regWrite16(0x1C, 0x18, 0x00, 7);    // Recommended value by PMmicro
-		regWrite16(0x20, 0x48, 0x00, 7);    // PL1167's Data Configure Register: LEN_PREAMBLE = 010 -> (0xAAAAAA) 3 bytes, LEN_SYNCWORD = 01 -> 32 bits, LEN_TRAILER = 000 -> (0x05) 4 bits, TYPE_PKT_DAT = 00 -> NRZ law data, TYPE_FEC = 00 -> No FEC
-		regWrite16(0x21, 0x3F, 0xC7, 7);  // PL1167's Delay Time Control Register 0
-		regWrite16(0x22, 0x20, 0x00, 7);    // PL1167's Delay Time Control Register 1
-		regWrite16(0x23, 0x03, 0x00, 7);     // PL1167's Power Management and Miscellaneous Register
-
-		regWrite16(0x28, 0x44, 0x02, 7);    // PL1167's FIFO and SYNCWORD Threshold Register
-		regWrite16(0x29, 0xB0, 0x00, 7);   // PL1167's Miscellaneous Register: CRC_ON = 1 -> ON, SCR_ON = 0 -> OFF, EN_PACK_LEN = 1 -> ON, FW_TERM_TX = 1 -> ON, AUTO_ACK = 0 -> OFF, PKT_LEVEL = 0 -> PKT active high, CRC_INIT_DAT = 0
-		regWrite16(0x2A, 0xFD, 0xB0, 7); // PL1167's SCAN RSSI Register 0
-		regWrite16(0x2B, 0x00, 0x0F, 7);    // PL1167's SCAN RSSI Register 1
-		delay(200);
-		regWrite16(0x80, 0x00, 0x00, 7);
-		regWrite16(0x81, 0xFF, 0xFF, 7);
-		regWrite16(0x82, 0x00, 0x00, 7);
-		regWrite16(0x84, 0x00, 0x00, 7);
-		regWrite16(0x85, 0xFF, 0xFF, 7);
-		regWrite16(0x87, 0xFF, 0xFF, 7);
-		regWrite16(0x88, 0x00, 0x00, 7);
-		regWrite16(0x89, 0xFF, 0xFF, 7);
-		regWrite16(0x8A, 0x00, 0x00, 7);
-		regWrite16(0x8B, 0xFF, 0xFF, 7);
-		regWrite16(0x8C, 0x00, 0x00, 7);
-		regWrite16(0x8D, 0xFF, 0xFF, 7);
-		regWrite16(0x96, 0x00, 0x00, 7);
-		regWrite16(0x97, 0xFF, 0xFF, 7);
-		regWrite16(0x98, 0x00, 0x00, 7);
-		regWrite16(0x99, 0xFF, 0xFF, 7);
-		regWrite16(0x9A, 0x00, 0x00, 7);
-		regWrite16(0x9B, 0xFF, 0xFF, 7);
-		regWrite16(0x9C, 0x00, 0x00, 7);
-		regWrite16(0xA0, 0x00, 0x00, 7);
-		regWrite16(0xA1, 0xFF, 0xFF, 7);
-		regWrite16(0xA2, 0x00, 0x00, 7);
-		regWrite16(0xA3, 0xFF, 0xFF, 7);
-		regWrite16(0xA8, 0x00, 0x00, 7);
-		regWrite16(0xA9, 0xFF, 0xFF, 7);
-		regWrite16(0xAA, 0x00, 0x00, 7);
-		regWrite16(0xAB, 0xFF, 0xFF, 7);
-		regWrite16(0x07, 0x00, 0x00, 7);       // Disable TX/RX and set radio channel to 0
+void LT8900MiLightRadio::vInitRadioModule() {
+	bool bWriteDefaultDefault = true;  // Is it okay to use the default power up values, without setting them
+
+	regWrite16(0x00, 0x6F, 0xE0, 7);  // Recommended value by PMmicro
+	regWrite16(0x02, 0x66, 0x17, 7);  // Recommended value by PMmicro
+	regWrite16(0x04, 0x9C, 0xC9, 7);  // Recommended value by PMmicro
+
+	regWrite16(0x05, 0x66, 0x37, 7);  // Recommended value by PMmicro
+	regWrite16(0x07, 0x00, 0x4C, 7);  // PL1167's TX/RX Enable and Channel Register, Default channel 76
+	regWrite16(0x08, 0x6C, 0x90, 7);  // Recommended value by PMmicro
+	regWrite16(0x09, 0x48, 0x00, 7);  // PA Control register
+
+	regWrite16(0x0B, 0x00, 0x08, 7);  // Recommended value by PMmicro
+	regWrite16(0x0D, 0x48, 0xBD, 7);  // Recommended value by PMmicro
+	regWrite16(0x16, 0x00, 0xFF, 7);  // Recommended value by PMmicro
+	regWrite16(0x18, 0x00, 0x67, 7);  // Recommended value by PMmicro
+
+	regWrite16(0x1A, 0x19, 0xE0, 7);  // Recommended value by PMmicro
+	regWrite16(0x1B, 0x13, 0x00, 7);  // Recommended value by PMmicro
+
+	regWrite16(0x20, 0x48, 0x00, 7);  // Recommended value by PMmicro
+	regWrite16(0x21, 0x3F, 0xC7, 7);  // Recommended value by PMmicro
+	regWrite16(0x22, 0x20, 0x00, 7);  // Recommended value by PMmicro
+	regWrite16(0x23, 0x03, 0x00, 7);  // Recommended value by PMmicro
+
+	regWrite16(0x24, 0x72, 0x36, 7);  // Sync R0
+	regWrite16(0x27, 0x18, 0x09, 7);  // Sync R3
+	regWrite16(0x28, 0x44, 0x02, 7);  // Recommended value by PMmicro
+	regWrite16(0x29, 0xB0, 0x00, 7);  // Recommended value by PMmicro
+	regWrite16(0x2A, 0xFD, 0xB0, 7);  // Recommended value by PMmicro
+
+	if (bWriteDefaultDefault == true) {
+		regWrite16(0x01, 0x56, 0x81, 7);  // Recommended value by PMmicro
+		regWrite16(0x0A, 0x7F, 0xFD, 7);  // Recommended value by PMmicro
+		regWrite16(0x0C, 0x00, 0x00, 7);  // Recommended value by PMmicro
+		regWrite16(0x17, 0x80, 0x05, 7);  // Recommended value by PMmicro
+		regWrite16(0x19, 0x16, 0x59, 7);  // Recommended value by PMmicro
+		regWrite16(0x1C, 0x18, 0x00, 7);  // Recommended value by PMmicro
+
+		regWrite16(0x25, 0x00, 0x00, 7);  // Recommended value by PMmicro
+		regWrite16(0x26, 0x00, 0x00, 7);  // Recommended value by PMmicro
+		regWrite16(0x2B, 0x00, 0x0F, 7);  // Recommended value by PMmicro
 	}
 }
 
@@ -380,7 +320,7 @@ int LT8900MiLightRadio::begin()
 /**************************************************************************/
 int LT8900MiLightRadio::configure()
 {
-  vInitRadioModule(_config.type);
+  vInitRadioModule();
   vSetSyncWord(_config.syncword3, 0,0,_config.syncword0);
   vStartListening(_config.channels[0]);
   return 0;
@@ -412,8 +352,8 @@ int LT8900MiLightRadio::read(uint8_t frame[], size_t &frame_length)
   Serial.println(F("LT8900: Radio was available, reading packet..."));
   #endif
 
-  uint8_t buf[_config.getPacketLength()];
-  int packetSize = iReadRXBuffer(buf, _config.getPacketLength());
+  uint8_t buf[MILIGHT_MAX_PACKET_LENGTH];
+  int packetSize = iReadRXBuffer(buf, MILIGHT_MAX_PACKET_LENGTH);
 
   if (packetSize > 0) {
     frame_length = packetSize;

+ 1 - 2
lib/MiLight/LT8900MiLightRadio.h

@@ -7,7 +7,6 @@
 #endif
 
 #include <MiLightRadioConfig.h>
-#include <MiLightButtons.h>
 #include <MiLightRadio.h>
 
 //#define DEBUG_PRINTF
@@ -55,7 +54,7 @@ class LT8900MiLightRadio : public MiLightRadio {
 
   private:
 
-    void vInitRadioModule(MiLightRadioType type);
+    void vInitRadioModule();
     void vSetSyncWord(uint16_t syncWord3, uint16_t syncWord2, uint16_t syncWord1, uint16_t syncWord0);
     uint16_t uiReadRegister(uint8_t reg);
     void regWrite16(byte ADDR, byte V1, byte V2, byte WAIT);

+ 18 - 0
lib/Radio/MiLightConstants.h

@@ -0,0 +1,18 @@
+#ifndef _MILIGHT_BUTTONS
+#define _MILIGHT_BUTTONS
+
+enum MiLightRemoteType {
+  REMOTE_TYPE_UNKNOWN = 255,
+  REMOTE_TYPE_RGBW    = 0,
+  REMOTE_TYPE_CCT     = 1,
+  REMOTE_TYPE_RGB_CCT = 2,
+  REMOTE_TYPE_RGB     = 3,
+  REMOTE_TYPE_FUT089  = 4
+};
+
+enum MiLightStatus {
+  ON = 0,
+  OFF = 1
+};
+
+#endif

lib/MiLight/MiLightRadio.h → lib/Radio/MiLightRadio.h


+ 8 - 0
lib/Radio/MiLightRadioConfig.cpp

@@ -0,0 +1,8 @@
+#include <MiLightRadioConfig.h>
+
+MiLightRadioConfig MiLightRadioConfig::ALL_CONFIGS[] = {
+  MiLightRadioConfig(0x147A, 0x258B, 7, 9, 40, 71), // rgbw
+  MiLightRadioConfig(0x050A, 0x55AA, 7, 4, 39, 74), // cct
+  MiLightRadioConfig(0x7236, 0x1809, 9, 8, 39, 70), // rgb+cct, fut089
+  MiLightRadioConfig(0x9AAB, 0xBCCD, 6, 3, 38, 73)  // rgb
+};

+ 40 - 0
lib/Radio/MiLightRadioConfig.h

@@ -0,0 +1,40 @@
+#include <Arduino.h>
+#include <MiLightConstants.h>
+#include <Size.h>
+
+#ifndef _MILIGHT_RADIO_CONFIG
+#define _MILIGHT_RADIO_CONFIG
+
+#define MILIGHT_MAX_PACKET_LENGTH 9
+
+class MiLightRadioConfig {
+public:
+  static const size_t NUM_CHANNELS = 3;
+
+  MiLightRadioConfig(
+    const uint16_t syncword0,
+    const uint16_t syncword3,
+    const size_t packetLength,
+    const uint8_t channel0,
+    const uint8_t channel1,
+    const uint8_t channel2
+  )
+    : syncword0(syncword0),
+      syncword3(syncword3),
+      packetLength(packetLength)
+  {
+    channels[0] = channel0;
+    channels[1] = channel1;
+    channels[2] = channel2;
+  }
+
+  const uint16_t syncword0;
+  const uint16_t syncword3;
+  uint8_t channels[3];
+  const size_t packetLength;
+
+  static const size_t NUM_CONFIGS = 4;
+  static MiLightRadioConfig ALL_CONFIGS[NUM_CONFIGS];
+};
+
+#endif

lib/MiLight/MiLightRadioFactory.cpp → lib/Radio/MiLightRadioFactory.cpp


+ 1 - 1
lib/MiLight/MiLightRadioFactory.h

@@ -15,7 +15,7 @@ public:
   virtual MiLightRadio* create(const MiLightRadioConfig& config) = 0;
 
   static MiLightRadioFactory* fromSettings(const Settings& settings);
-  
+
 };
 
 class NRF24Factory : public MiLightRadioFactory {

+ 1 - 1
lib/MiLight/NRF24MiLightRadio.cpp

@@ -49,7 +49,7 @@ int NRF24MiLightRadio::configure() {
   }
 
   // +1 to be able to buffer the length
-  retval = _pl1167.setMaxPacketLength(_config.getPacketLength() + 1);
+  retval = _pl1167.setMaxPacketLength(_config.packetLength + 1);
   if (retval < 0) {
     return retval;
   }

lib/MiLight/NRF24MiLightRadio.h → lib/Radio/NRF24MiLightRadio.h


+ 274 - 0
lib/Radio/PL1167_nRF24.cpp

@@ -0,0 +1,274 @@
+/*
+ * PL1167_nRF24.cpp
+ *
+ * Adapted from work by henryk:
+ *  https://github.com/henryk/openmili
+ *  Created on: 29 May 2015
+ *      Author: henryk
+ * Optimizations by khamann:
+ *  https://github.com/khmann/esp8266_milight_hub/blob/e3600cef75b102ff3be51a7afdb55ab7460fe712/lib/MiLight/PL1167_nRF24.cpp
+ *
+ */
+
+#include "PL1167_nRF24.h"
+
+static uint16_t calc_crc(uint8_t *data, size_t data_length);
+static uint8_t reverse_bits(uint8_t data);
+
+PL1167_nRF24::PL1167_nRF24(RF24 &radio)
+  : _radio(radio)
+{ }
+
+int PL1167_nRF24::open()
+{
+  _radio.begin();
+  _radio.setAutoAck(false);
+  _radio.setPALevel(RF24_PA_MAX);
+  _radio.setDataRate(RF24_1MBPS);
+  _radio.disableCRC();
+
+  _syncwordLength = 5;
+  _radio.setAddressWidth(_syncwordLength);
+
+  return recalc_parameters();
+}
+
+int PL1167_nRF24::recalc_parameters()
+{
+  int packet_length = _maxPacketLength + 2;
+  int nrf_address_pos = _syncwordLength;
+
+  if (_syncword0 & 0x01) {
+    _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword0 << 4) & 0xf0 ) + 0x05 );
+  } else {
+    _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword0 << 4) & 0xf0 ) + 0x0a );
+  }
+  _nrf_pipe[ --nrf_address_pos ] = reverse_bits( (_syncword0 >> 4) & 0xff);
+  _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword0 >> 12) & 0x0f ) + ( (_syncword3 << 4) & 0xf0) );
+  _nrf_pipe[ --nrf_address_pos ] = reverse_bits( (_syncword3 >> 4) & 0xff);
+  _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword3 >> 12) & 0x0f ) + 0x50 );	// kh: spi says trailer is always "5" ?
+
+  _receive_length = packet_length;
+
+  _radio.openWritingPipe(_nrf_pipe);
+  _radio.openReadingPipe(1, _nrf_pipe);
+
+  _radio.setChannel(2 + _channel);
+
+  _radio.setPayloadSize( packet_length );
+  return 0;
+}
+
+
+int PL1167_nRF24::setPreambleLength(uint8_t preambleLength)
+{ return 0; }
+/* kh- no thanks, I'll take care of this */
+
+
+int PL1167_nRF24::setSyncword(uint16_t syncword0, uint16_t syncword3)
+{
+  _syncwordLength = 5;
+  _syncword0 = syncword0;
+  _syncword3 = syncword3;
+  return recalc_parameters();
+}
+
+int PL1167_nRF24::setTrailerLength(uint8_t trailerLength)
+{ return 0; }
+/* kh- no thanks, I'll take care of that.
+   One could argue there is potential value to "defining" the trailer - such that
+   we can use those "values" for internal (repeateR?) functions since they are
+   ignored by the real PL1167..  But there is no value in _this_ implementation...
+*/
+
+int PL1167_nRF24::setCRC(bool crc)
+{
+  _crc = crc;
+  return recalc_parameters();
+}
+
+int PL1167_nRF24::setMaxPacketLength(uint8_t maxPacketLength)
+{
+  _maxPacketLength = maxPacketLength;
+  return recalc_parameters();
+}
+
+int PL1167_nRF24::receive(uint8_t channel)
+{
+  if (channel != _channel) {
+    _channel = channel;
+    int retval = recalc_parameters();
+    if (retval < 0) {
+      return retval;
+    }
+  }
+
+  _radio.startListening();
+  if (_radio.available()) {
+#ifdef DEBUG_PRINTF
+  printf("Radio is available\n");
+#endif
+    internal_receive();
+  }
+
+  if(_received) {
+#ifdef DEBUG_PRINTF
+  if (_packet_length > 0) {
+    printf("Received packet (len = %d)!\n", _packet_length);
+  }
+#endif
+    return _packet_length;
+  } else {
+    return 0;
+  }
+}
+
+int PL1167_nRF24::readFIFO(uint8_t data[], size_t &data_length)
+{
+  if (data_length > _packet_length) {
+    data_length = _packet_length;
+  }
+  memcpy(data, _packet, data_length);
+  _packet_length -= data_length;
+  if (_packet_length) {
+    memmove(_packet, _packet + data_length, _packet_length);
+  }
+  return _packet_length;
+}
+
+int PL1167_nRF24::writeFIFO(const uint8_t data[], size_t data_length)
+{
+  if (data_length > sizeof(_packet)) {
+    data_length = sizeof(_packet);
+  }
+  memcpy(_packet, data, data_length);
+  _packet_length = data_length;
+  _received = false;
+
+  return data_length;
+}
+
+int PL1167_nRF24::transmit(uint8_t channel)
+{
+  if (channel != _channel) {
+    _channel = channel;
+    int retval = recalc_parameters();
+    if (retval < 0) {
+      return retval;
+    }
+    yield();
+  }
+
+  _radio.stopListening();
+  uint8_t tmp[sizeof(_packet)];
+  int outp=0;
+
+  uint16_t crc;
+  if (_crc) {
+    crc = calc_crc(_packet, _packet_length);
+  }
+
+  for (int inp = 0; inp < _packet_length + (_crc ? 2 : 0) + 1; inp++) {
+    if (inp < _packet_length) {
+      tmp[outp++] = reverse_bits(_packet[inp]);}
+    else if (_crc && inp < _packet_length + 2) {
+      tmp[outp++] = reverse_bits((crc >> ( (inp - _packet_length) * 8)) & 0xff);
+    }
+  }
+
+  yield();
+
+  _radio.write(tmp, outp);
+  return 0;
+}
+
+
+int PL1167_nRF24::internal_receive()
+{
+  uint8_t tmp[sizeof(_packet)];
+  int outp = 0;
+
+  _radio.read(tmp, _receive_length);
+
+  // HACK HACK HACK: Reset radio
+  open();
+
+#ifdef DEBUG_PRINTF
+  printf("Packet received: ");
+  for (int i = 0; i < _receive_length; i++) {
+    printf("%02X", tmp[i]);
+  }
+  printf("\n");
+#endif
+
+  for (int inp = 0; inp < _receive_length; inp++) {
+      tmp[outp++] = reverse_bits(tmp[inp]);
+  }
+
+
+#ifdef DEBUG_PRINTF
+  printf("Packet transformed: ");
+  for (int i = 0; i < outp; i++) {
+    printf("%02X", tmp[i]);
+  }
+  printf("\n");
+#endif
+
+
+  if (_crc) {
+    if (outp < 2) {
+#ifdef DEBUG_PRINTF
+  printf("Failed CRC: outp < 2\n");
+#endif
+      return 0;
+    }
+    uint16_t crc = calc_crc(tmp, outp - 2);
+    if ( ((crc & 0xff) != tmp[outp - 2]) || (((crc >> 8) & 0xff) != tmp[outp - 1]) ) {
+#ifdef DEBUG_PRINTF
+  uint16_t recv_crc = ((tmp[outp - 2] & 0xFF) << 8) | (tmp[outp - 1] & 0xFF);
+  printf("Failed CRC: expected %d, got %d\n", crc, recv_crc);
+#endif
+      return 0;
+    }
+    outp -= 2;
+  }
+
+  memcpy(_packet, tmp, outp);
+
+  _packet_length = outp;
+  _received = true;
+
+#ifdef DEBUG_PRINTF
+  printf("Successfully parsed packet of length %d\n", _packet_length);
+#endif
+
+  return outp;
+}
+
+#define CRC_POLY 0x8408
+
+static uint16_t calc_crc(uint8_t *data, size_t data_length) {
+  uint16_t state = 0;
+  for (size_t i = 0; i < data_length; i++) {
+    uint8_t byte = data[i];
+    for (int j = 0; j < 8; j++) {
+      if ((byte ^ state) & 0x01) {
+        state = (state >> 1) ^ CRC_POLY;
+      } else {
+        state = state >> 1;
+      }
+      byte = byte >> 1;
+    }
+  }
+  return state;
+}
+
+static uint8_t reverse_bits(uint8_t data) {
+  uint8_t result = 0;
+  for (int i = 0; i < 8; i++) {
+    result <<= 1;
+    result |= data & 1;
+    data >>= 1;
+  }
+  return result;
+}

lib/MiLight/PL1167_nRF24.h → lib/Radio/PL1167_nRF24.h


+ 43 - 0
lib/Settings/Settings.cpp

@@ -62,6 +62,24 @@ void Settings::updateGatewayConfigs(JsonArray& arr) {
   }
 }
 
+void Settings::updateGroupStateFields(JsonArray &arr) {
+  if (arr.success()) {
+    if (this->groupStateFields) {
+      delete this->groupStateFields;
+    }
+
+    this->groupStateFields = new GroupStateField[arr.size()];
+    this->numGroupStateFields = arr.size();
+
+    for (size_t i = 0; i < arr.size(); i++) {
+      String name = arr[i];
+      name.toLowerCase();
+
+      this->groupStateFields[i] = GroupStateFieldHelpers::getFieldByName(name.c_str());
+    }
+  }
+}
+
 void Settings::patch(JsonObject& parsedSettings) {
   if (parsedSettings.success()) {
     this->setIfPresent<String>(parsedSettings, "admin_username", adminUsername);
@@ -77,8 +95,14 @@ void Settings::patch(JsonObject& parsedSettings) {
     this->setIfPresent(parsedSettings, "mqtt_password", mqttPassword);
     this->setIfPresent(parsedSettings, "mqtt_topic_pattern", mqttTopicPattern);
     this->setIfPresent(parsedSettings, "mqtt_update_topic_pattern", mqttUpdateTopicPattern);
+    this->setIfPresent(parsedSettings, "mqtt_state_topic_pattern", mqttStateTopicPattern);
     this->setIfPresent(parsedSettings, "discovery_port", discoveryPort);
     this->setIfPresent(parsedSettings, "listen_repeats", listenRepeats);
+    this->setIfPresent(parsedSettings, "state_flush_interval", stateFlushInterval);
+    this->setIfPresent(parsedSettings, "mqtt_state_rate_limit", mqttStateRateLimit);
+    this->setIfPresent(parsedSettings, "packet_repeat_throttle_threshold", packetRepeatThrottleThreshold);
+    this->setIfPresent(parsedSettings, "packet_repeat_throttle_sensitivity", packetRepeatThrottleSensitivity);
+    this->setIfPresent(parsedSettings, "packet_repeat_minimum", packetRepeatMinimum);
 
     if (parsedSettings.containsKey("radio_interface_type")) {
       this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]);
@@ -92,6 +116,10 @@ void Settings::patch(JsonObject& parsedSettings) {
       JsonArray& arr = parsedSettings["gateway_configs"];
       updateGatewayConfigs(arr);
     }
+    if (parsedSettings.containsKey("group_state_fields")) {
+      JsonArray& arr = parsedSettings["group_state_fields"];
+      updateGroupStateFields(arr);
+    }
   }
 }
 
@@ -143,8 +171,14 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
   root["mqtt_password"] = this->mqttPassword;
   root["mqtt_topic_pattern"] = this->mqttTopicPattern;
   root["mqtt_update_topic_pattern"] = this->mqttUpdateTopicPattern;
+  root["mqtt_state_topic_pattern"] = this->mqttStateTopicPattern;
   root["discovery_port"] = this->discoveryPort;
   root["listen_repeats"] = this->listenRepeats;
+  root["state_flush_interval"] = this->stateFlushInterval;
+  root["mqtt_state_rate_limit"] = this->mqttStateRateLimit;
+  root["packet_repeat_throttle_sensitivity"] = this->packetRepeatThrottleSensitivity;
+  root["packet_repeat_throttle_threshold"] = this->packetRepeatThrottleThreshold;
+  root["packet_repeat_minimum"] = this->packetRepeatMinimum;
 
   if (this->deviceIds) {
     JsonArray& arr = jsonBuffer.createArray();
@@ -165,6 +199,15 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
     root["gateway_configs"] = arr;
   }
 
+  if (this->groupStateFields) {
+    JsonArray& arr = jsonBuffer.createArray();
+    for (size_t i = 0; i < this->numGroupStateFields; i++) {
+      arr.add(GroupStateFieldHelpers::getFieldName(this->groupStateFields[i]));
+    }
+
+    root["group_state_fields"] = arr;
+  }
+
   if (prettyPrint) {
     root.prettyPrintTo(stream);
   } else {

+ 45 - 4
lib/Settings/Settings.h

@@ -1,6 +1,8 @@
 #include <Arduino.h>
 #include <StringStream.h>
 #include <ArduinoJson.h>
+#include <GroupStateField.h>
+#include <Size.h>
 
 #ifndef _SETTINGS_H_INCLUDED
 #define _SETTINGS_H_INCLUDED
@@ -16,6 +18,14 @@
 #define MILIGHT_HUB_VERSION unknown
 #endif
 
+#ifndef MILIGHT_MAX_STATE_ITEMS
+#define MILIGHT_MAX_STATE_ITEMS 100
+#endif
+
+#ifndef MILIGHT_MAX_STALE_MQTT_GROUPS
+#define MILIGHT_MAX_STALE_MQTT_GROUPS 10
+#endif
+
 #define SETTINGS_FILE  "/config.json"
 #define SETTINGS_TERMINATOR '\0'
 
@@ -33,6 +43,15 @@ enum RadioInterfaceType {
   LT8900 = 1,
 };
 
+static const GroupStateField DEFAULT_GROUP_STATE_FIELDS[] = {
+  GroupStateField::STATE,
+  GroupStateField::BRIGHTNESS,
+  GroupStateField::COLOR,
+  GroupStateField::MODE,
+  GroupStateField::COLOR_TEMP,
+  GroupStateField::BULB_MODE
+};
+
 class GatewayConfig {
 public:
   GatewayConfig(uint16_t deviceId, uint16_t port, uint8_t protocolVersion)
@@ -60,12 +79,25 @@ public:
     gatewayConfigs(NULL),
     numDeviceIds(0),
     numGatewayConfigs(0),
-    packetRepeats(10),
-    httpRepeatFactor(5),
+    packetRepeats(50),
+    httpRepeatFactor(1),
     listenRepeats(3),
     _autoRestartPeriod(0),
-    discoveryPort(48899)
-  { }
+    discoveryPort(48899),
+    stateFlushInterval(10000),
+    mqttStateRateLimit(500),
+    packetRepeatThrottleThreshold(200),
+    packetRepeatThrottleSensitivity(0),
+    packetRepeatMinimum(3),
+    groupStateFields(NULL),
+    numGroupStateFields(0)
+  {
+    if (groupStateFields == NULL) {
+      numGroupStateFields = size(DEFAULT_GROUP_STATE_FIELDS);
+      groupStateFields = new GroupStateField[numGroupStateFields];
+      memcpy(groupStateFields, DEFAULT_GROUP_STATE_FIELDS, numGroupStateFields * sizeof(GroupStateField));
+    }
+  }
 
   ~Settings() {
     if (deviceIds) {
@@ -88,6 +120,7 @@ public:
   void serialize(Stream& stream, const bool prettyPrint = false);
   void updateDeviceIds(JsonArray& arr);
   void updateGatewayConfigs(JsonArray& arr);
+  void updateGroupStateFields(JsonArray& arr);
   void patch(JsonObject& obj);
   String mqttServer();
   uint16_t mqttPort();
@@ -109,8 +142,16 @@ public:
   String mqttPassword;
   String mqttTopicPattern;
   String mqttUpdateTopicPattern;
+  String mqttStateTopicPattern;
+  GroupStateField *groupStateFields;
+  size_t numGroupStateFields;
   uint16_t discoveryPort;
   uint8_t listenRepeats;
+  size_t stateFlushInterval;
+  size_t mqttStateRateLimit;
+  size_t packetRepeatThrottleSensitivity;
+  size_t packetRepeatThrottleThreshold;
+  size_t packetRepeatMinimum;
 
 protected:
   size_t _autoRestartPeriod;

+ 20 - 0
lib/Types/GroupStateField.cpp

@@ -0,0 +1,20 @@
+#include <GroupStateField.h>
+#include <Size.h>
+
+GroupStateField GroupStateFieldHelpers::getFieldByName(const char* name) {
+  for (size_t i = 0; i < size(STATE_NAMES); i++) {
+    if (0 == strcmp(name, STATE_NAMES[i])) {
+      return static_cast<GroupStateField>(i);
+    }
+  }
+  return GroupStateField::UNKNOWN;
+}
+
+const char* GroupStateFieldHelpers::getFieldName(GroupStateField field) {
+  for (size_t i = 0; i < size(STATE_NAMES); i++) {
+    if (field == static_cast<GroupStateField>(i)) {
+      return STATE_NAMES[i];
+    }
+  }
+  return STATE_NAMES[0];
+}

+ 40 - 0
lib/Types/GroupStateField.h

@@ -0,0 +1,40 @@
+#ifndef _GROUP_STATE_FIELDS_H
+#define _GROUP_STATE_FIELDS_H
+
+static const char* STATE_NAMES[] = {
+  "unknown",
+  "state",
+  "status",
+  "brightness",
+  "level",
+  "hue",
+  "saturation",
+  "color",
+  "mode",
+  "kelvin",
+  "color_temp",
+  "bulb_mode"
+};
+
+enum class GroupStateField {
+  UNKNOWN,
+  STATE,
+  STATUS,
+  BRIGHTNESS,
+  LEVEL,
+  HUE,
+  SATURATION,
+  COLOR,
+  MODE,
+  KELVIN,
+  COLOR_TEMP,
+  BULB_MODE
+};
+
+class GroupStateFieldHelpers {
+public:
+  static const char* getFieldName(GroupStateField field);
+  static GroupStateField getFieldByName(const char* name);
+};
+
+#endif

+ 18 - 0
lib/Types/MiLightConstants.h

@@ -0,0 +1,18 @@
+#ifndef _MILIGHT_BUTTONS
+#define _MILIGHT_BUTTONS
+
+enum MiLightRemoteType {
+  REMOTE_TYPE_UNKNOWN = 255,
+  REMOTE_TYPE_RGBW    = 0,
+  REMOTE_TYPE_CCT     = 1,
+  REMOTE_TYPE_RGB_CCT = 2,
+  REMOTE_TYPE_RGB     = 3,
+  REMOTE_TYPE_FUT089  = 4
+};
+
+enum MiLightStatus {
+  ON = 0,
+  OFF = 1
+};
+
+#endif

+ 29 - 18
lib/Udp/V5MiLightUdpServer.cpp

@@ -16,30 +16,28 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
     const MiLightStatus status = (command % 2) == 1 ? ON : OFF;
     const uint8_t groupId = (command - UDP_RGBW_GROUP_1_ON + 2)/2;
 
-    client->prepare(MilightRgbwConfig, deviceId, groupId);
+    client->prepare(&FUT096Config, deviceId, groupId);
     client->updateStatus(status);
 
     this->lastGroup = groupId;
   // Command set_white for RGBW
-  } else if (command >= UDP_RGBW_GROUP_ALL_WHITE && command <= UDP_RGBW_GROUP_4_WHITE) {
+ } else if (command == UDP_RGBW_GROUP_ALL_WHITE || command == UDP_RGBW_GROUP_1_WHITE || command == UDP_RGBW_GROUP_2_WHITE || command == UDP_RGBW_GROUP_3_WHITE || command == UDP_RGBW_GROUP_4_WHITE) {
     const uint8_t groupId = (command - UDP_RGBW_GROUP_ALL_WHITE)/2;
-    client->prepare(MilightRgbwConfig, deviceId, groupId);
+    client->prepare(&FUT096Config, deviceId, groupId);
     client->updateColorWhite();
+
     this->lastGroup = groupId;
-    // On/off for CCT
-  } else if (CctPacketFormatter::cctCommandIdToGroup(command) != 255) {
-    uint8_t cctGroup = CctPacketFormatter::cctCommandIdToGroup(command);
-    client->prepare(MilightCctConfig, deviceId, cctGroup);
-    this->lastGroup = cctGroup;
-
-    // Night mode commands are same as off commands with MSB set
-    if ((command & 0x80) == 0x80) {
-      client->enableNightMode();
-    } else {
-      client->updateStatus(CctPacketFormatter::cctCommandToStatus(command));
-    }
-  } else {
-    client->prepare(MilightRgbwConfig, deviceId, lastGroup);
+  // Set night_mode for RGBW
+ } else if (command == UDP_RGBW_GROUP_ALL_NIGHT || command == UDP_RGBW_GROUP_1_NIGHT || command == UDP_RGBW_GROUP_2_NIGHT || command == UDP_RGBW_GROUP_3_NIGHT || command == UDP_RGBW_GROUP_4_NIGHT) {
+    const uint8_t groupId = (command - UDP_RGBW_GROUP_1_NIGHT + 2)/2;
+    if (command == UDP_RGBW_GROUP_ALL_NIGHT) const uint8_t groupId = 0;
+
+    client->prepare(&FUT096Config, deviceId, groupId);
+    client->enableNightMode();
+
+    this->lastGroup = groupId;
+ } else {
+    client->prepare(&FUT096Config, deviceId, lastGroup);
     bool handled = true;
 
     switch (command) {
@@ -84,7 +82,20 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
       return;
     }
 
-    client->prepare(MilightCctConfig, deviceId, lastGroup);
+    uint8_t onOffGroup = CctPacketFormatter::cctCommandIdToGroup(command);
+
+    if (onOffGroup != 255) {
+      client->prepare(&FUT091Config, deviceId, onOffGroup);
+      // Night mode commands are same as off commands with MSB set
+      if ((command & 0x80) == 0x80) {
+        client->enableNightMode();
+      } else {
+        client->updateStatus(CctPacketFormatter::cctCommandToStatus(command));
+      }
+      return;
+    }
+
+    client->prepare(&FUT091Config, deviceId, lastGroup);
 
     switch(command) {
       case UDP_CCT_BRIGHTNESS_DOWN:

+ 5 - 0
lib/Udp/V5MiLightUdpServer.h

@@ -44,6 +44,11 @@ enum MiLightUdpCommands {
   UDP_RGBW_GROUP_2_WHITE     = 0xC7,
   UDP_RGBW_GROUP_3_WHITE     = 0xC9,
   UDP_RGBW_GROUP_4_WHITE     = 0xCB,
+  UDP_RGBW_GROUP_ALL_NIGHT   = 0xC1,
+  UDP_RGBW_GROUP_1_NIGHT     = 0xC6,
+  UDP_RGBW_GROUP_2_NIGHT     = 0xC8,
+  UDP_RGBW_GROUP_3_NIGHT     = 0xCA,
+  UDP_RGBW_GROUP_4_NIGHT     = 0xCC,
   UDP_RGBW_BRIGHTNESS        = 0x4E,
   UDP_RGBW_COLOR             = 0x40
 };

+ 1 - 1
lib/Udp/V6CctCommandHandler.h

@@ -18,7 +18,7 @@ enum CctCommandIds {
 class V6CctCommandHandler : public V6CommandHandler {
 public:
   V6CctCommandHandler()
-    : V6CommandHandler(0x0100, MilightCctConfig)
+    : V6CommandHandler(0x0100, FUT091Config)
   { }
 
   virtual bool handleCommand(

+ 1 - 1
lib/Udp/V6ComamndHandler.cpp

@@ -21,7 +21,7 @@ bool V6CommandHandler::handleCommand(MiLightClient* client,
   uint32_t command,
   uint32_t commandArg)
 {
-  client->prepare(radioConfig, deviceId, group);
+  client->prepare(&remoteConfig, deviceId, group);
 
   if (commandType == V6_PAIR) {
     client->pair();

+ 4 - 4
lib/Udp/V6CommandHandler.h

@@ -16,9 +16,9 @@ public:
   static V6CommandHandler* ALL_HANDLERS[];
   static const size_t NUM_HANDLERS;
 
-  V6CommandHandler(uint16_t commandId, MiLightRadioConfig& radioConfig)
+  V6CommandHandler(uint16_t commandId, const MiLightRemoteConfig& remoteConfig)
     : commandId(commandId),
-      radioConfig(radioConfig)
+      remoteConfig(remoteConfig)
   { }
 
   virtual bool handleCommand(
@@ -31,7 +31,7 @@ public:
   );
 
   const uint16_t commandId;
-  MiLightRadioConfig& radioConfig;
+  const MiLightRemoteConfig& remoteConfig;
 
 protected:
 
@@ -51,7 +51,7 @@ protected:
 class V6CommandDemuxer : public V6CommandHandler {
 public:
   V6CommandDemuxer(V6CommandHandler* handlers[], size_t numHandlers)
-    : V6CommandHandler(0, MilightRgbwConfig),
+    : V6CommandHandler(0, FUT096Config),
       handlers(handlers),
       numHandlers(numHandlers)
   { }

+ 6 - 6
lib/Udp/V6MiLightUdpServer.cpp

@@ -203,7 +203,7 @@ void V6MiLightUdpServer::handleCommand(
   uint32_t cmdArg = readInt<uint32_t>(cmd+5);
 
 #ifdef MILIGHT_UDP_DEBUG
-  printf_P("Command cmdType: %02X, cmdHeader: %08X, cmdArg: %08X\n", cmdType, cmdHeader, cmdArg);
+  printf("Command cmdType: %02X, cmdHeader: %08X, cmdArg: %08X\n", cmdType, cmdHeader, cmdArg);
 #endif
 
   bool handled = false;
@@ -232,11 +232,11 @@ void V6MiLightUdpServer::handleCommand(
   }
 
 #ifdef MILIGHT_UDP_DEBUG
-  printf_P("V6MiLightUdpServer - Unhandled command: ");
+  printf("V6MiLightUdpServer - Unhandled command: ");
   for (size_t i = 0; i < V6_COMMAND_LEN; i++) {
-    printf_P("%02X ", cmd[i]);
+    printf("%02X ", cmd[i]);
   }
-  printf_P("\n");
+  printf("\n");
 #endif
 }
 
@@ -244,7 +244,7 @@ void V6MiLightUdpServer::handleHeartbeat(uint16_t sessionId) {
   char header[] = { 0xD8, 0x00, 0x00, 0x00, 0x07 };
   memcpy(responseBuffer, header, size(header));
   writeMacAddr(responseBuffer + 5);
-  
+
   responseBuffer[11] = 0;
 
   sendResponse(sessionId, responseBuffer, 12);
@@ -274,7 +274,7 @@ void V6MiLightUdpServer::handlePacket(uint8_t* packet, size_t packetSize) {
     uint8_t checksum = packet[21];
 
 #ifdef MILIGHT_UDP_DEBUG
-    printf_P("session: %04X, sequence: %d, group: %d, checksum: %d\n", sessionId, sequenceNum, group, checksum);
+    printf("session: %04X, sequence: %d, group: %d, checksum: %d\n", sessionId, sequenceNum, group, checksum);
 #endif
 
     handleCommand(sessionId, sequenceNum, cmd, group, checksum);

+ 1 - 1
lib/Udp/V6RgbCctCommandHandler.h

@@ -23,7 +23,7 @@ enum V2CommandArgIds {
 class V6RgbCctCommandHandler : public V6CommandHandler {
 public:
   V6RgbCctCommandHandler()
-    : V6CommandHandler(0x0800, MilightRgbCctConfig)
+    : V6CommandHandler(0x0800, FUT092Config)
   { }
 
   virtual bool handleCommand(

+ 1 - 1
lib/Udp/V6RgbCommandHandler.h

@@ -19,7 +19,7 @@ enum RgbCommandIds {
 class V6RgbCommandHandler : public V6CommandHandler {
 public:
   V6RgbCommandHandler()
-    : V6CommandHandler(0x0500, MilightRgbConfig)
+    : V6CommandHandler(0x0500, FUT098Config)
   { }
 
   virtual bool handleCommand(

+ 1 - 1
lib/Udp/V6RgbwCommandHandler.h

@@ -20,7 +20,7 @@ enum RgbwCommandIds {
 class V6RgbwCommandHandler : public V6CommandHandler {
 public:
   V6RgbwCommandHandler()
-    : V6CommandHandler(0x0700, MilightRgbwConfig)
+    : V6CommandHandler(0x0700, FUT096Config)
   { }
 
   virtual bool handleCommand(

+ 99 - 45
lib/WebServer/MiLightHttpServer.cpp

@@ -12,15 +12,25 @@ void MiLightHttpServer::begin() {
   applySettings(settings);
 
   server.on("/", HTTP_GET, handleServe_P(index_html_gz, index_html_gz_len));
-  server.on("/settings", HTTP_GET, handleServeFile(SETTINGS_FILE, APPLICATION_JSON));
+  server.on("/settings", HTTP_GET, [this]() { serveSettings(); });
   server.on("/settings", HTTP_PUT, [this]() { handleUpdateSettings(); });
-  server.on("/settings", HTTP_POST, [this]() { server.send_P(200, TEXT_PLAIN, PSTR("success. rebooting")); ESP.restart(); }, handleUpdateFile(SETTINGS_FILE));
+  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(); });
 
   server.on("/gateway_traffic", HTTP_GET, [this]() { handleListenGateway(NULL); });
   server.onPattern("/gateway_traffic/:type", HTTP_GET, [this](const UrlTokenBindings* b) { handleListenGateway(b); });
 
-  server.onPattern("/gateways/:device_id/:type/:group_id", HTTP_ANY, [this](const UrlTokenBindings* b) { handleUpdateGroup(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.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.on("/about", HTTP_GET, [this]() { handleAbout(); });
@@ -44,6 +54,8 @@ void MiLightHttpServer::begin() {
         );
       }
 
+      delay(1000);
+
       ESP.restart();
     },
     [this](){
@@ -126,14 +138,18 @@ void MiLightHttpServer::handleSystemPost() {
   }
 }
 
+void MiLightHttpServer::serveSettings() {
+  // Save first to set defaults
+  settings.save();
+  serveFile(SETTINGS_FILE, APPLICATION_JSON);
+}
+
 void MiLightHttpServer::applySettings(Settings& settings) {
   if (settings.hasAuthSettings()) {
     server.requireAuthentication(settings.adminUsername, settings.adminPassword);
   } else {
     server.disableAuthentication();
   }
-
-  milightClient->setResendCount(settings.packetRepeats);
 }
 
 void MiLightHttpServer::onSettingsSaved(SettingsSavedHandler handler) {
@@ -161,7 +177,7 @@ void MiLightHttpServer::handleGetRadioConfigs() {
   JsonArray& arr = buffer.createArray();
 
   for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) {
-    const MiLightRadioConfig* config = MiLightRadioConfig::ALL_CONFIGS[i];
+    const MiLightRemoteConfig* config = MiLightRemoteConfig::ALL_REMOTES[i];
     arr.add(config->name);
   }
 
@@ -235,56 +251,85 @@ void MiLightHttpServer::handleUpdateSettings() {
 void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
   bool available = false;
   bool listenAll = bindings == NULL;
-  uint8_t configIx = 0;
-  MiLightRadioConfig* currentConfig =
-    listenAll
-      ? MiLightRadioConfig::ALL_CONFIGS[0]
-      : MiLightRadioConfig::fromString(bindings->get("type"));
-
-  if (currentConfig == NULL && bindings != NULL) {
-    String body = "Unknown device type: ";
-    body += bindings->get("type");
+  size_t configIx = 0;
+  const MiLightRadioConfig* radioConfig = NULL;
+  const MiLightRemoteConfig* remoteConfig = NULL;
+  uint8_t packet[MILIGHT_MAX_PACKET_LENGTH];
+
+  if (bindings != NULL) {
+    String strType(bindings->get("type"));
+    const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(strType);
+    milightClient->prepare(remoteConfig, 0, 0);
+    radioConfig = &remoteConfig->radioConfig;
+  }
 
-    server.send(400, "text/plain", body);
+  if (radioConfig == NULL && !listenAll) {
+    server.send_P(400, TEXT_PLAIN, PSTR("Unknown device type supplied."));
     return;
   }
 
-  while (!available) {
+  while (remoteConfig == NULL) {
     if (!server.clientConnected()) {
       return;
     }
 
     if (listenAll) {
-      currentConfig = MiLightRadioConfig::ALL_CONFIGS[
-        configIx++ % MiLightRadioConfig::NUM_CONFIGS
-      ];
+      radioConfig = &milightClient->switchRadio(configIx++ % milightClient->getNumRadios())->config();
     }
-    milightClient->prepare(*currentConfig, 0, 0);
 
     if (milightClient->available()) {
-      available = true;
+      size_t packetLen = milightClient->read(packet);
+      remoteConfig = MiLightRemoteConfig::fromReceivedPacket(
+        *radioConfig,
+        packet,
+        packetLen
+      );
     }
 
     yield();
   }
 
-  uint8_t packet[currentConfig->getPacketLength()];
-  milightClient->read(packet);
-
   char response[200];
   char* responseBuffer = response;
 
   responseBuffer += sprintf_P(
     responseBuffer,
     PSTR("\n%s packet received (%d bytes):\n"),
-    currentConfig->name,
-    sizeof(packet)
+    remoteConfig->name.c_str(),
+    remoteConfig->packetFormatter->getPacketLength()
   );
-  milightClient->formatPacket(packet, responseBuffer);
+  remoteConfig->packetFormatter->format(packet, responseBuffer);
 
   server.send(200, "text/plain", response);
 }
 
+void MiLightHttpServer::sendGroupState(GroupState &state) {
+  String body;
+  StaticJsonBuffer<200> jsonBuffer;
+  JsonObject& obj = jsonBuffer.createObject();
+  state.applyState(obj, settings.groupStateFields, settings.numGroupStateFields);
+  obj.printTo(body);
+
+  server.send(200, APPLICATION_JSON, body);
+}
+
+void MiLightHttpServer::handleGetGroup(const UrlTokenBindings* urlBindings) {
+  const String _deviceId = urlBindings->get("device_id");
+  uint8_t _groupId = atoi(urlBindings->get("group_id"));
+  const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(urlBindings->get("type"));
+
+  if (_remoteType == NULL) {
+    char buffer[40];
+    sprintf_P(buffer, PSTR("Unknown device type\n"));
+    server.send(400, TEXT_PLAIN, buffer);
+    return;
+  }
+
+  BulbId bulbId(parseInt<uint16_t>(_deviceId), _groupId, _remoteType->type);
+  GroupState& state = stateStore->get(bulbId);
+  sendGroupState(stateStore->get(bulbId));
+}
+
 void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
   DynamicJsonBuffer buffer;
   JsonObject& request = buffer.parse(server.arg("plain"));
@@ -300,25 +345,28 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
 
   String _deviceIds = urlBindings->get("device_id");
   String _groupIds = urlBindings->get("group_id");
-  String _radioTypes = urlBindings->get("type");
+  String _remoteTypes = urlBindings->get("type");
   char deviceIds[_deviceIds.length()];
   char groupIds[_groupIds.length()];
-  char radioTypes[_radioTypes.length()];
-  strcpy(radioTypes, _radioTypes.c_str());
+  char remoteTypes[_remoteTypes.length()];
+  strcpy(remoteTypes, _remoteTypes.c_str());
   strcpy(groupIds, _groupIds.c_str());
   strcpy(deviceIds, _deviceIds.c_str());
 
   TokenIterator deviceIdItr(deviceIds, _deviceIds.length());
   TokenIterator groupIdItr(groupIds, _groupIds.length());
-  TokenIterator radioTypesItr(radioTypes, _radioTypes.length());
+  TokenIterator remoteTypesItr(remoteTypes, _remoteTypes.length());
+
+  BulbId foundBulbId;
+  size_t groupCount = 0;
 
-  while (radioTypesItr.hasNext()) {
-    const char* _radioType = radioTypesItr.nextToken();
-    MiLightRadioConfig* config = MiLightRadioConfig::fromString(_radioType);
+  while (remoteTypesItr.hasNext()) {
+    const char* _remoteType = remoteTypesItr.nextToken();
+    const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(_remoteType);
 
     if (config == NULL) {
       char buffer[40];
-      sprintf_P(buffer, PSTR("Unknown device type: %s"), _radioType);
+      sprintf_P(buffer, PSTR("Unknown device type: %s"), _remoteType);
       server.send(400, "text/plain", buffer);
       return;
     }
@@ -331,13 +379,19 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
       while (groupIdItr.hasNext()) {
         const uint8_t groupId = atoi(groupIdItr.nextToken());
 
-        milightClient->prepare(*config, deviceId, groupId);
+        milightClient->prepare(config, deviceId, groupId);
         handleRequest(request);
+        foundBulbId = BulbId(deviceId, groupId, config->type);
+        groupCount++;
       }
     }
   }
 
-  server.send(200, APPLICATION_JSON, "true");
+  if (groupCount == 1) {
+    sendGroupState(stateStore->get(foundBulbId));
+  } else {
+    server.send(200, APPLICATION_JSON, "true");
+  }
 }
 
 void MiLightHttpServer::handleRequest(const JsonObject& request) {
@@ -347,7 +401,7 @@ void MiLightHttpServer::handleRequest(const JsonObject& request) {
 void MiLightHttpServer::handleSendRaw(const UrlTokenBindings* bindings) {
   DynamicJsonBuffer buffer;
   JsonObject& request = buffer.parse(server.arg("plain"));
-  MiLightRadioConfig* config = MiLightRadioConfig::fromString(bindings->get("type"));
+  const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(bindings->get("type"));
 
   if (config == NULL) {
     char buffer[50];
@@ -356,16 +410,16 @@ void MiLightHttpServer::handleSendRaw(const UrlTokenBindings* bindings) {
     return;
   }
 
-  uint8_t packet[config->getPacketLength()];
+  uint8_t packet[MILIGHT_MAX_PACKET_LENGTH];
   const String& hexPacket = request["packet"];
-  hexStrToBytes<uint8_t>(hexPacket.c_str(), hexPacket.length(), packet, config->getPacketLength());
+  hexStrToBytes<uint8_t>(hexPacket.c_str(), hexPacket.length(), packet, MILIGHT_MAX_PACKET_LENGTH);
 
   size_t numRepeats = MILIGHT_DEFAULT_RESEND_COUNT;
   if (request.containsKey("num_repeats")) {
     numRepeats = request["num_repeats"];
   }
 
-  milightClient->prepare(*config, 0, 0);
+  milightClient->prepare(config, 0, 0);
 
   for (size_t i = 0; i < numRepeats; i++) {
     milightClient->write(packet);
@@ -388,7 +442,7 @@ void MiLightHttpServer::handleWsEvent(uint8_t num, WStype_t type, uint8_t *paylo
   }
 }
 
-void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRadioConfig& config) {
+void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRemoteConfig& config) {
   if (numWsClients > 0) {
     size_t packetLen = config.packetFormatter->getPacketLength();
     char buffer[packetLen*3];
@@ -401,8 +455,8 @@ void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRadioConf
     sprintf_P(
       responseBuffer,
       PSTR("\n%s packet received (%d bytes):\n%s"),
-      config.name,
-      sizeof(packet),
+      config.name.c_str(),
+      packetLen,
       formattedPacket
     );
 

+ 9 - 3
lib/WebServer/MiLightHttpServer.h

@@ -2,6 +2,7 @@
 #include <MiLightClient.h>
 #include <Settings.h>
 #include <WebSocketsServer.h>
+#include <GroupStateStore.h>
 
 #ifndef _MILIGHT_HTTP_SERVER
 #define _MILIGHT_HTTP_SERVER
@@ -15,12 +16,13 @@ const char APPLICATION_JSON[] = "application/json";
 
 class MiLightHttpServer {
 public:
-  MiLightHttpServer(Settings& settings, MiLightClient*& milightClient)
+  MiLightHttpServer(Settings& settings, MiLightClient*& milightClient, GroupStateStore*& stateStore)
     : server(WebServer(80)),
       wsServer(WebSocketsServer(81)),
       numWsClients(0),
       milightClient(milightClient),
-      settings(settings)
+      settings(settings),
+      stateStore(stateStore)
   {
     this->applySettings(settings);
   }
@@ -29,7 +31,7 @@ public:
   void handleClient();
   void onSettingsSaved(SettingsSavedHandler handler);
   void on(const char* path, HTTPMethod method, ESP8266WebServer::THandlerFunction handler);
-  void handlePacketSent(uint8_t* packet, const MiLightRadioConfig& config);
+  void handlePacketSent(uint8_t* packet, const MiLightRemoteConfig& config);
   WiFiClient client();
 
 protected:
@@ -38,10 +40,12 @@ protected:
     const char* contentType,
     const char* defaultText = NULL);
 
+  void serveSettings();
   bool serveFile(const char* file, const char* contentType = "text/html");
   ESP8266WebServer::THandlerFunction handleUpdateFile(const char* filename);
   ESP8266WebServer::THandlerFunction handleServe_P(const char* data, size_t length);
   void applySettings(Settings& settings);
+  void sendGroupState(GroupState& state);
 
   void handleUpdateSettings();
   void handleGetRadioConfigs();
@@ -50,6 +54,7 @@ protected:
   void handleListenGateway(const UrlTokenBindings* urlBindings);
   void handleSendRaw(const UrlTokenBindings* urlBindings);
   void handleUpdateGroup(const UrlTokenBindings* urlBindings);
+  void handleGetGroup(const UrlTokenBindings* urlBindings);
 
   void handleRequest(const JsonObject& request);
   void handleWsEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length);
@@ -60,6 +65,7 @@ protected:
   WebSocketsServer wsServer;
   Settings& settings;
   MiLightClient*& milightClient;
+  GroupStateStore*& stateStore;
   SettingsSavedHandler settingsSavedHandler;
   size_t numWsClients;
 

+ 21 - 14
platformio.ini

@@ -9,6 +9,8 @@
 ; http://docs.platformio.org/page/projectconf.html
 
 [common]
+framework = arduino
+platform = https://github.com/platformio/platform-espressif8266.git#feature/stage
 board_f_cpu = 160000000L
 lib_deps_builtin =
   SPI
@@ -20,47 +22,52 @@ lib_deps_external =
   https://github.com/ratkins/RGBConverter
   Hash
   WebSockets
-build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -Idist
-extra_script =
-  .build_web.py
+  CircularBuffer
+extra_scripts =
+  pre:.build_web.py
+build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -Idist -Ilib/DataStructures
+# -D DEBUG_PRINTF
 # -D MQTT_DEBUG
 # -D MILIGHT_UDP_DEBUG
-# -D DEBUG_PRINTF
+# -D STATE_DEBUG
 
 [env:nodemcuv2]
-platform = espressif8266
-framework = arduino
+platform = ${common.platform}
+framework = ${common.framework}
 board = nodemcuv2
-; upload_speed = 115200
+upload_speed = 115200
 build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=nodemcuv2
-extra_script = ${common.extra_script}
+extra_scripts = ${common.extra_scripts}
 lib_deps =
   ${common.lib_deps_builtin}
   ${common.lib_deps_external}
 
 [env:d1_mini]
-platform = espressif8266
-framework = arduino
+platform = ${common.platform}
+framework = ${common.framework}
 board = d1_mini
 build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=d1_mini
+extra_scripts = ${common.extra_scripts}
 lib_deps =
   ${common.lib_deps_builtin}
   ${common.lib_deps_external}
 
 [env:esp12]
-platform = espressif8266
+platform = ${common.platform}
+framework = ${common.framework}
 board = esp12e
-framework = arduino
 build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=esp12
+extra_scripts = ${common.extra_scripts}
 lib_deps =
   ${common.lib_deps_builtin}
   ${common.lib_deps_external}
 
 [env:esp07]
-platform = espressif8266
+platform = ${common.platform}
+framework = ${common.framework}
 board = esp07
-framework = arduino
 build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.1m64.ld -D FIRMWARE_VARIANT=esp07
+extra_scripts = ${common.extra_scripts}
 lib_deps =
   ${common.lib_deps_builtin}
   ${common.lib_deps_external}

+ 116 - 20
src/main.cpp

@@ -5,7 +5,10 @@
 #include <FS.h>
 #include <IntParsing.h>
 #include <Size.h>
+#include <LinkedList.h>
+#include <GroupStateStore.h>
 #include <MiLightRadioConfig.h>
+#include <MiLightRemoteConfig.h>
 #include <MiLightHttpServer.h>
 #include <Settings.h>
 #include <MiLightUdpServer.h>
@@ -15,6 +18,7 @@
 #include <RGBConverter.h>
 #include <MiLightDiscoveryServer.h>
 #include <MiLightClient.h>
+#include <BulbStateUpdater.h>
 
 WiFiManager wifiManager;
 
@@ -27,10 +31,17 @@ MqttClient* mqttClient = NULL;
 MiLightDiscoveryServer* discoveryServer = NULL;
 uint8_t currentRadioType = 0;
 
+// For tracking and managing group state
+GroupStateStore* stateStore = NULL;
+BulbStateUpdater* bulbStateUpdater = NULL;
+
 int numUdpServers = 0;
-MiLightUdpServer** udpServers;
+MiLightUdpServer** udpServers = NULL;
 WiFiUDP udpSeder;
 
+/**
+ * Set up UDP servers (both v5 and v6).  Clean up old ones if necessary.
+ */
 void initMilightUdpServers() {
   if (udpServers) {
     for (int i = 0; i < numUdpServers; i++) {
@@ -64,44 +75,100 @@ void initMilightUdpServers() {
   }
 }
 
-void onPacketSentHandler(uint8_t* packet, const MiLightRadioConfig& config) {
+/**
+ * Milight RF packet handler.
+ *
+ * Called both when a packet is sent locally, and when an intercepted packet
+ * is read.
+ */
+void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) {
   StaticJsonBuffer<200> buffer;
   JsonObject& result = buffer.createObject();
-  config.packetFormatter->parsePacket(packet, result);
+  BulbId bulbId = config.packetFormatter->parsePacket(packet, result, stateStore);
 
-  uint16_t deviceId = result["device_id"];
-  uint16_t groupId = result["group_id"];
-  MiLightRadioType type = MiLightRadioConfig::fromString(result["device_type"])->type;
+  if (&bulbId == &DEFAULT_BULB_ID) {
+    Serial.println(F("Skipping packet handler because packet was not decoded"));
+    return;
+  }
 
-  char output[200];
-  result.printTo(output);
+  const MiLightRemoteConfig& remoteConfig =
+    *MiLightRemoteConfig::fromType(bulbId.deviceType);
+
+  GroupState& groupState = stateStore->get(bulbId);
+  groupState.patch(result);
+  stateStore->set(bulbId, groupState);
 
   if (mqttClient) {
-    mqttClient->sendUpdate(type, deviceId, groupId, output);
+    // Sends the state delta derived from the raw packet
+    char output[200];
+    result.printTo(output);
+    mqttClient->sendUpdate(remoteConfig, bulbId.deviceId, bulbId.groupId, output);
+
+    // Sends the entire state
+    bulbStateUpdater->enqueueUpdate(bulbId, groupState);
   }
-  httpServer->handlePacketSent(packet, config);
+
+  httpServer->handlePacketSent(packet, remoteConfig);
 }
 
+/**
+ * Listen for packets on one radio config.  Cycles through all configs as its
+ * called.
+ */
 void handleListen() {
   if (! settings.listenRepeats) {
     return;
   }
 
-  MiLightRadioConfig* config = MiLightRadioConfig::ALL_CONFIGS[
-    currentRadioType++ % MiLightRadioConfig::NUM_CONFIGS
-  ];
-  milightClient->prepare(*config);
+  MiLightRadio* radio = milightClient->switchRadio(currentRadioType++ % milightClient->getNumRadios());
 
   for (size_t i = 0; i < settings.listenRepeats; i++) {
     if (milightClient->available()) {
-      uint8_t readPacket[9];
-      milightClient->read(readPacket);
+      uint8_t readPacket[MILIGHT_MAX_PACKET_LENGTH];
+      size_t packetLen = milightClient->read(readPacket);
+
+      const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromReceivedPacket(
+        radio->config(),
+        readPacket,
+        packetLen
+      );
+
+      if (remoteConfig == NULL) {
+        // This can happen under normal circumstances, so not an error condition
+#ifdef DEBUG_PRINTF
+        Serial.println(F("WARNING: Couldn't find remote for received packet"));
+#endif
+        return;
+      }
 
-      onPacketSentHandler(readPacket, *config);
+      onPacketSentHandler(readPacket, *remoteConfig);
     }
   }
 }
 
+/**
+ * Called when MqttClient#update is first being processed.  Stop sending updates
+ * and aggregate state changes until the update is finished.
+ */
+void onUpdateBegin() {
+  if (bulbStateUpdater) {
+    bulbStateUpdater->disable();
+  }
+}
+
+/**
+ * Called when MqttClient#update is finished processing.  Re-enable state
+ * updates, which will flush accumulated state changes.
+ */
+void onUpdateEnd() {
+  if (bulbStateUpdater) {
+    bulbStateUpdater->enable();
+  }
+}
+
+/**
+ * Apply what's in the Settings object.
+ */
 void applySettings() {
   if (milightClient) {
     delete milightClient;
@@ -111,6 +178,13 @@ void applySettings() {
   }
   if (mqttClient) {
     delete mqttClient;
+    delete bulbStateUpdater;
+
+    mqttClient = NULL;
+    bulbStateUpdater = NULL;
+  }
+  if (stateStore) {
+    delete stateStore;
   }
 
   radioFactory = MiLightRadioFactory::fromSettings(settings);
@@ -119,13 +193,25 @@ void applySettings() {
     Serial.println(F("ERROR: unable to construct radio factory"));
   }
 
-  milightClient = new MiLightClient(radioFactory);
+  stateStore = new GroupStateStore(MILIGHT_MAX_STATE_ITEMS, settings.stateFlushInterval);
+
+  milightClient = new MiLightClient(
+    radioFactory,
+    *stateStore,
+    settings.packetRepeatThrottleThreshold,
+    settings.packetRepeatThrottleSensitivity,
+    settings.packetRepeatMinimum
+  );
   milightClient->begin();
   milightClient->onPacketSent(onPacketSentHandler);
+  milightClient->onUpdateBegin(onUpdateBegin);
+  milightClient->onUpdateEnd(onUpdateEnd);
+  milightClient->setResendCount(settings.packetRepeats);
 
   if (settings.mqttServer().length() > 0) {
     mqttClient = new MqttClient(settings, milightClient);
     mqttClient->begin();
+    bulbStateUpdater = new BulbStateUpdater(settings, *mqttClient, *stateStore);
   }
 
   initMilightUdpServers();
@@ -140,6 +226,9 @@ void applySettings() {
   }
 }
 
+/**
+ *
+ */
 bool shouldRestart() {
   if (! settings.isAutoRestartEnabled()) {
     return false;
@@ -150,7 +239,11 @@ bool shouldRestart() {
 
 void setup() {
   Serial.begin(9600);
-  wifiManager.autoConnect();
+  String ssid = "ESP" + String(ESP.getChipId());
+
+  wifiManager.setConfigPortalTimeout(180);
+  wifiManager.autoConnect(ssid.c_str(), "milightHub");
+
   SPIFFS.begin();
   Settings::load(settings);
   applySettings();
@@ -169,7 +262,7 @@ void setup() {
   SSDP.setDeviceType("upnp:rootdevice");
   SSDP.begin();
 
-  httpServer = new MiLightHttpServer(settings, milightClient);
+  httpServer = new MiLightHttpServer(settings, milightClient, stateStore);
   httpServer->onSettingsSaved(applySettings);
   httpServer->on("/description.xml", HTTP_GET, []() { SSDP.schema(httpServer->client()); });
   httpServer->begin();
@@ -182,6 +275,7 @@ void loop() {
 
   if (mqttClient) {
     mqttClient->handleClient();
+    bulbStateUpdater->loop();
   }
 
   if (udpServers) {
@@ -196,6 +290,8 @@ void loop() {
 
   handleListen();
 
+  stateStore->limitedFlush();
+
   if (shouldRestart()) {
     Serial.println(F("Auto-restart triggered. Restarting..."));
     ESP.restart();

+ 3 - 1
web/src/css/style.css

@@ -2,7 +2,7 @@
 label { display: block; }
 .radio-option { padding: 0 5px; cursor: pointer; }
 .command-buttons { list-style: none; margin: 0; padding: 0; }
-.command-buttons li { display: inline-block; margin-right: 1em; }
+.command-buttons li { display: inline-block; margin-right: 1em; overflow: auto; }
 .form-entry { margin: 0 0 20px 0; }
 .form-entry .form-control { width: 20em; }
 .form-entry label { display: inline-block; }
@@ -11,6 +11,8 @@ 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; }
 #sniffed-traffic { max-height: 50em; overflow-y: auto; }
 .btn-secondary {
   background-color: #fff;

+ 41 - 11
web/src/index.html

@@ -10,6 +10,7 @@
   <link href="https://gitcdn.github.io/bootstrap-toggle/2.2.2/css/bootstrap-toggle.min.css" rel="stylesheet"/>
   <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.7.0/css/bootstrap-slider.min.css" rel="stylesheet"/>
   <link href="https://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.12.4/css/selectize.bootstrap3.min.css" rel="stylesheet"/>
+  <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.12.4/css/bootstrap-select.min.css" rel="stylesheet"/>
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <!--[if lt IE 9]>
     <script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.js"></script>
@@ -24,7 +25,9 @@
   <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="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">
@@ -81,7 +84,7 @@
 
          <div id="latest-version">
            <h4>Latest Version</h4>
-           
+
            <div class="status"></div>
            <div id="latest-version-info">
              <label>Version</label>
@@ -146,7 +149,7 @@
     <div>&nbsp;</div>
 
     <div class="row">
-      <div class="col-sm-4">
+      <div class="col-sm-3">
         <label for="deviceId" id="device-id-label">
           Device Id
           <span class="error-info"></span>
@@ -155,8 +158,8 @@
 				</select>
       </div>
 
-      <div class="col-sm-3">
-        <div class="mode-option" id="group-option" data-for="cct,rgbw,rgb_cct">
+      <div class="col-sm-4">
+        <div class="mode-option" id="group-option" data-for="cct,rgbw,rgb_cct,fut089">
           <label for="groupId">Group</label>
 
           <div class="btn-group" id="groupId" data-toggle="buttons">
@@ -172,6 +175,18 @@
             <label class="btn btn-secondary">
               <input type="radio" name="options" autocomplete="off" data-value="4"> 4
             </label>
+            <label class="btn btn-secondary mode-option" data-for="fut089">
+              <input type="radio" name="options" autocomplete="off" data-value="5"> 5
+            </label>
+            <label class="btn btn-secondary mode-option" data-for="fut089">
+              <input type="radio" name="options" autocomplete="off" data-value="6"> 6
+            </label>
+            <label class="btn btn-secondary mode-option" data-for="fut089">
+              <input type="radio" name="options" autocomplete="off" data-value="7"> 7
+            </label>
+            <label class="btn btn-secondary mode-option" data-for="fut089">
+              <input type="radio" name="options" autocomplete="off" data-value="8"> 8
+            </label>
             <label class="btn btn-secondary">
               <input type="radio" name="options" autocomplete="off" data-value="0"> All
             </label>
@@ -180,7 +195,7 @@
       </div>
 
       <div class="col-sm-4">
-        <label for="groupId">Mode</label>
+        <label for="mode">Mode</label>
 
         <div class="btn-group" id="mode" data-toggle="buttons">
           <label class="btn btn-secondary active">
@@ -195,12 +210,15 @@
           <label class="btn btn-secondary">
             <input type="radio" name="mode" autocomplete="off" data-value="rgb"> RGB
           </label>
+          <label class="btn btn-secondary">
+            <input type="radio" name="mode" autocomplete="off" data-value="fut089"> FUT089
+          </label>
         </div>
       </div>
     </div>
 
     <div class="row"><div class="col-sm-12">
-    <div class="mode-option" data-for="rgbw,rgb_cct,rgb">
+    <div class="mode-option" data-for="rgbw,rgb_cct,rgb,fut089">
       <div class="row">
         <div class="col-sm-12">
           <h5>Hue</h5>
@@ -218,7 +236,7 @@
     </div>
     </div></div>
 
-    <div class="mode-option" data-for="rgb_cct">
+    <div class="mode-option" data-for="rgb_cct,fut089">
       <div class="row">
         <div class="col-sm-12">
           <h5>Saturation</h5>
@@ -235,7 +253,7 @@
       </div>
     </div>
 
-    <div class="mode-option" data-for="cct,rgb_cct">
+    <div class="mode-option" data-for="cct,rgb_cct,fut089">
       <div class="row">
         <div class="col-sm-12">
           <h5>Color Temperature</h5>
@@ -282,7 +300,7 @@
           <li>
             <input type="checkbox" name="status" class="raw-update" data-toggle="toggle" checked/>
           </li>
-          <div class="mode-option inline" data-for="rgbw,rgb_cct">
+          <div class="mode-option inline" data-for="rgbw,rgb_cct,fut089">
             <li>
               <button type="button" class="btn btn-secondary command-btn" data-command="set_white">White</button>
             </li>
@@ -302,7 +320,7 @@
         </ul>
         <p></p>
         <ul class="command-buttons">
-          <div class="mode-option inline" data-for="rgb,rgbw,rgb_cct">
+          <div class="mode-option inline" data-for="rgb">
             <li>
               <div class="plus-minus-group">
                 <button type="button" class="btn btn-default btn-number command-btn" data-command="previous_mode">
@@ -316,7 +334,19 @@
               </div>
             </li>
           </div>
-          <div class="mode-option inline" data-for="rgb,rgbw,rgb_cct">
+          <div class="mode-option inline" data-for="rgbw,rgb_cct,fut089">
+            <li>
+              <div class="dropdown">
+                <button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
+                  Mode
+                  <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu mode-dropdown">
+                </ul>
+              </div>
+          </li>
+          </div>
+          <div class="mode-option inline" data-for="rgb,rgbw,rgb_cct,fut089">
             <li>
               <div class="plus-minus-group">
                 <button type="button" class="btn btn-default btn-number command-btn" data-command="mode_speed_down">

+ 48 - 0
web/src/js/rgb2hsv.js

@@ -0,0 +1,48 @@
+// https://gist.github.com/mjackson/5311256
+function rgbToHsl(r, g, b) {
+  r /= 255, g /= 255, b /= 255;
+
+  var max = Math.max(r, g, b), min = Math.min(r, g, b);
+  var h, s, l = (max + min) / 2;
+
+  if (max == min) {
+    h = s = 0; // achromatic
+  } else {
+    var d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+    switch (max) {
+      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+      case g: h = (b - r) / d + 2; break;
+      case b: h = (r - g) / d + 4; break;
+    }
+
+    h /= 6;
+  }
+
+  return {h: h, s: s, l: l };
+}
+
+function RGBtoHSV(r, g, b) {
+    if (arguments.length === 1) {
+        g = r.g, b = r.b, r = r.r;
+    }
+    var max = Math.max(r, g, b), min = Math.min(r, g, b),
+        d = max - min,
+        h,
+        s = (max === 0 ? 0 : d / max),
+        v = max / 255;
+
+    switch (max) {
+        case min: h = 0; break;
+        case r: h = (g - b) + d * (g < b ? 6: 0); h /= 6 * d; break;
+        case g: h = (b - r) + d * 2; h /= 6 * d; break;
+        case b: h = (r - g) + d * 4; h /= 6 * d; break;
+    }
+
+    return {
+        h: h,
+        s: s,
+        v: v
+    };
+};

+ 152 - 24
web/src/js/script.js

@@ -1,8 +1,31 @@
+var UNIT_PARAMS = {
+  minMireds: 153,
+  maxMireds: 370,
+  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_username", "mqtt_password",
-  "radio_interface_type", "listen_repeats"
+  "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"
+];
+
+// TODO: sync this with GroupStateField.h
+var GROUP_STATE_KEYS = [
+  "state",
+  "status",
+  "brightness",
+  "level",
+  "hue",
+  "saturation",
+  "color",
+  "mode",
+  "kelvin",
+  "color_temp",
+  "bulb_mode"
 ];
 
 var FORM_SETTINGS_HELP = {
@@ -22,10 +45,28 @@ var FORM_SETTINGS_HELP = {
   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."
+    "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 UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
@@ -52,7 +93,6 @@ var activeUrl = function() {
     , mode = getCurrentMode();
 
   if (deviceId == "") {
-    alert("Please enter a device ID.");
     throw "Must enter device ID";
   }
 
@@ -69,14 +109,20 @@ var getCurrentMode = function() {
 
 var updateGroup = _.throttle(
   function(params) {
-    $.ajax(
-      activeUrl(),
-      {
+    try {
+      $.ajax({
+        url: activeUrl(),
         method: 'PUT',
         data: JSON.stringify(params),
-        contentType: 'application/json'
-      }
-    );
+        contentType: 'application/json',
+        success: function(e) {
+          console.log(e);
+          handleStateUpdate(e);
+        }
+      });
+    } catch (e) {
+      alert(e);
+    }
   },
   1000
 );
@@ -148,6 +194,11 @@ var loadSettings = function() {
       selectize.refreshOptions();
     }
 
+    if (val.group_state_fields) {
+      var elmt = $('select[name="group_state_fields"]');
+      elmt.selectpicker('val', val.group_state_fields);
+    }
+
     var gatewayForm = $('#gateway-server-configs').html('');
     if (val.gateway_configs) {
       val.gateway_configs.forEach(function(v) {
@@ -324,6 +375,47 @@ var handleCheckForUpdates = function() {
   );
 };
 
+var handleStateUpdate = function(state) {
+  console.log(state);
+  if (state.state) {
+    // Set without firing an event
+    $('input[name="status"]')
+      .prop('checked', state.state == 'ON')
+      .bootstrapToggle('destroy')
+      .bootstrapToggle();
+  }
+  if (state.color) {
+    // Browsers don't support HSV, but saturation from HSL doesn't match
+    // saturation from bulb state.
+    var hsl = rgbToHsl(state.color.r, state.color.g, state.color.b);
+    var hsv = RGBtoHSV(state.color.r, state.color.g, state.color.b);
+
+    $('input[name="saturation"]').slider('setValue', hsv.s*100);
+    updatePreviewColor(hsl.h*360,hsl.s*100,hsl.l*100);
+  }
+  if (state.color_temp) {
+    var scaledTemp
+      = 100*(state.color_temp - UNIT_PARAMS.minMireds) / (UNIT_PARAMS.maxMireds - UNIT_PARAMS.minMireds);
+    $('input[name="temperature"]').slider('setValue', scaledTemp);
+  }
+  if (state.brightness) {
+    var scaledBrightness = state.brightness * (100 / UNIT_PARAMS.maxBrightness);
+    $('input[name="level"]').slider('setValue', scaledBrightness);
+  }
+};
+
+var updatePreviewColor = function(hue, saturation, lightness) {
+  if (! saturation) {
+    saturation = 100;
+  }
+  if (! lightness) {
+    lightness = 50;
+  }
+  $('.hue-value-display').css({
+    backgroundColor: "hsl(" + hue + "," + saturation + "%," + lightness + "%)"
+  });
+};
+
 $(function() {
   $('.radio-option').click(function() {
     $(this).prev().prop('checked', true);
@@ -336,9 +428,7 @@ $(function() {
       , hue = Math.round(360*pct)
       ;
 
-    $('.hue-value-display').css({
-      backgroundColor: "hsl(" + hue + ",100%,50%)"
-    });
+    updatePreviewColor(hue);
 
     updateGroup({hue: hue});
   };
@@ -399,6 +489,29 @@ $(function() {
     $(this).closest('tr').remove();
   });
 
+  for (var i = 0; i < 9; i++) {
+    $('.mode-dropdown').append('<li><a href="#" data-mode-value="' + i + '">' + i + '</a></li>');
+  }
+
+  $('body').on('click', '.mode-dropdown li a', function(e) {
+    updateGroup({mode: $(this).data('mode-value')});
+    e.preventDefault();
+    return false;
+  });
+
+  $('input[name="mode"],input[name="options"],#deviceId').change(function(e) {
+    try {
+      $.getJSON(
+        activeUrl(),
+        function(e) {
+          handleStateUpdate(e);
+        }
+      );
+    } catch (e) {
+      // Skip
+    }
+  });
+
   selectize = $('#deviceId').selectize({
     create: true,
     sortField: 'text',
@@ -438,7 +551,7 @@ $(function() {
 
     elmt += '</div>';
 
-    if(k === "radio_interface_type") {
+    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' +
@@ -447,6 +560,12 @@ $(function() {
           '<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>';
@@ -457,17 +576,23 @@ $(function() {
 
   $('#settings').prepend(settings);
   $('#settings').submit(function(e) {
-    var obj = {};
+    e.preventDefault();
 
-    FORM_SETTINGS.forEach(function(k) {
-      var elmt = $('#settings input[name="' + k + '"]');
+    var obj = $('#settings')
+      .serializeArray()
+      .reduce(function(a, x) {
+        var val = a[x.name];
 
-      if (elmt.attr('type') === 'radio') {
-        obj[k] = elmt.filter(':checked').val();
-      } else {
-        obj[k] = elmt.val();
-      }
-    });
+        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(
@@ -477,6 +602,10 @@ $(function() {
       }
     );
 
+    // 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",
       {
@@ -486,7 +615,6 @@ $(function() {
       }
     );
 
-    e.preventDefault();
     return false;
   });