Bladeren bron

Merge pull request #118 from sidoh/state_persistence

State persistence
Chris Mullins 8 jaren geleden
bovenliggende
commit
57aa72d148
47 gewijzigde bestanden met toevoegingen van 1579 en 229 verwijderingen
  1. 2 2
      .build_web.py
  2. 1 1
      .travis.yml
  3. 1 0
      README.md
  4. 2 2
      dist/index.html.gz.h
  5. 316 0
      lib/DataStructures/LinkedList.h
  6. 5 12
      lib/Helpers/Units.h
  7. 50 0
      lib/MQTT/BulbStateUpdater.cpp
  8. 31 0
      lib/MQTT/BulbStateUpdater.h
  9. 32 17
      lib/MQTT/MqttClient.cpp
  10. 9 0
      lib/MQTT/MqttClient.h
  11. 7 8
      lib/MiLight/CctPacketFormatter.cpp
  12. 1 1
      lib/MiLight/CctPacketFormatter.h
  13. 20 16
      lib/MiLight/FUT089PacketFormatter.cpp
  14. 1 1
      lib/MiLight/FUT089PacketFormatter.h
  15. 4 3
      lib/MiLight/MiLightClient.cpp
  16. 3 2
      lib/MiLight/MiLightClient.h
  17. 27 21
      lib/MiLight/MiLightRemoteConfig.cpp
  18. 5 2
      lib/MiLight/MiLightRemoteConfig.h
  19. 3 1
      lib/MiLight/PacketFormatter.cpp
  20. 4 2
      lib/MiLight/PacketFormatter.h
  21. 32 23
      lib/MiLight/RgbCctPacketFormatter.cpp
  22. 3 3
      lib/MiLight/RgbCctPacketFormatter.h
  23. 7 7
      lib/MiLight/RgbPacketFormatter.cpp
  24. 1 1
      lib/MiLight/RgbPacketFormatter.h
  25. 8 9
      lib/MiLight/RgbwPacketFormatter.cpp
  26. 3 1
      lib/MiLight/RgbwPacketFormatter.h
  27. 324 0
      lib/MiLightState/GroupState.cpp
  28. 102 23
      lib/MiLightState/GroupState.h
  29. 63 0
      lib/MiLightState/GroupStateCache.cpp
  30. 33 0
      lib/MiLightState/GroupStateCache.h
  31. 40 0
      lib/MiLightState/GroupStatePersistence.cpp
  32. 18 0
      lib/MiLightState/GroupStatePersistence.h
  33. 85 0
      lib/MiLightState/GroupStateStore.cpp
  34. 36 5
      lib/MiLightState/GroupStateStore.h
  35. 0 1
      lib/Radio/LT8900MiLightRadio.h
  36. 0 18
      lib/Radio/MiLightButtons.h
  37. 18 0
      lib/Radio/MiLightConstants.h
  38. 1 1
      lib/Radio/MiLightRadioConfig.h
  39. 6 0
      lib/Settings/Settings.cpp
  40. 14 1
      lib/Settings/Settings.h
  41. 51 9
      lib/WebServer/MiLightHttpServer.cpp
  42. 8 2
      lib/WebServer/MiLightHttpServer.h
  43. 9 4
      platformio.ini
  44. 58 15
      src/main.cpp
  45. 3 2
      web/src/index.html
  46. 48 0
      web/src/js/rgb2hsv.js
  47. 84 13
      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 - 1
.travis.yml

@@ -14,7 +14,7 @@ install:
 - platformio lib install
 - cd web && npm install && cd ..
 script:
-- platformio run
+- platformio run -v
 before_deploy:
   - ./.prepare_release
 deploy:

+ 1 - 0
README.md

@@ -92,6 +92,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}

File diff suppressed because it is too large
+ 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

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

+ 50 - 0
lib/MQTT/BulbStateUpdater.cpp

@@ -0,0 +1,50 @@
+#include <BulbStateUpdater.h>
+
+BulbStateUpdater::BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore)
+  : settings(settings),
+    mqttClient(mqttClient),
+    stateStore(stateStore),
+    lastFlush(0)
+{ }
+
+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);
+  message.printTo(buffer);
+
+  mqttClient.sendState(
+    *MiLightRemoteConfig::fromType(bulbId.deviceType),
+    bulbId.deviceId,
+    bulbId.groupId,
+    buffer
+  );
+
+  lastFlush = millis();
+}
+
+inline bool BulbStateUpdater::canFlush() const {
+  return (millis() > (lastFlush + settings.mqttStateRateLimit));
+}

+ 31 - 0
lib/MQTT/BulbStateUpdater.h

@@ -0,0 +1,31 @@
+/**
+ * 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();
+
+private:
+  Settings& settings;
+  MqttClient& mqttClient;
+  GroupStateStore& stateStore;
+  CircularBuffer<BulbId, MILIGHT_MAX_STALE_MQTT_GROUPS> staleGroups;
+  unsigned long lastFlush;
+
+  inline void flushGroup(BulbId bulbId, GroupState& state);
+  inline bool canFlush() const;
+};
+
+#endif

+ 32 - 17
lib/MQTT/MqttClient.cpp

@@ -87,24 +87,11 @@ void MqttClient::handleClient() {
 }
 
 void MqttClient::sendUpdate(const MiLightRemoteConfig& remoteConfig, 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", remoteConfig.name);
-
-#ifdef MQTT_DEBUG
-  printf_P(PSTR("MqttClient - publishing update to %s: %s\n"), topic.c_str(), update);
-#endif
+  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() {
@@ -121,6 +108,34 @@ 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;
+
+  String deviceIdStr = String(deviceId, 16);
+  deviceIdStr.toUpperCase();
+
+  topic.replace(":device_id", String("0x") + deviceIdStr);
+  topic.replace(":group_id", String(groupId));
+  topic.replace(":device_type", remoteConfig.name);
+
+#ifdef MQTT_DEBUG
+  printf_P(PSTR("MqttClient - publishing update to %s: %s\n"), topic.c_str(), update);
+#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;

+ 9 - 0
lib/MQTT/MqttClient.h

@@ -20,6 +20,7 @@ public:
   void handleClient();
   void reconnect();
   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;
@@ -32,6 +33,14 @@ 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
+  );
 };
 
 #endif

+ 7 - 8
lib/MiLight/CctPacketFormatter.cpp

@@ -1,5 +1,4 @@
 #include <CctPacketFormatter.h>
-#include <MiLightButtons.h>
 
 void CctPacketFormatter::initializePacket(uint8_t* packet) {
   size_t packetPtr = 0;
@@ -146,12 +145,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 +169,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) {

+ 1 - 1
lib/MiLight/CctPacketFormatter.h

@@ -43,7 +43,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);

+ 20 - 16
lib/MiLight/FUT089PacketFormatter.cpp

@@ -33,7 +33,7 @@ void FUT089PacketFormatter::updateTemperature(uint8_t value) {
 }
 
 void FUT089PacketFormatter::updateSaturation(uint8_t value) {
-  command(FUT089_SATURATION, value);
+  command(FUT089_SATURATION, 100 - value);
 }
 
 void FUT089PacketFormatter::updateColorWhite() {
@@ -45,14 +45,16 @@ void FUT089PacketFormatter::enableNightMode() {
   command(FUT089_ON | 0x80, arg);
 }
 
-void FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
+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);
 
-  result["device_id"] = (packetCopy[2] << 8) | packetCopy[3];
-  result["group_id"] = packetCopy[7];
-  result["device_type"] = "fut089";
+  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];
@@ -66,10 +68,10 @@ void FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& resul
       result["command"] = "white_mode";
     } else if (arg <= 8) { // Group is not reliably encoded in group byte. Extract from arg byte
       result["state"] = "ON";
-      result["group_id"] = arg;
+      bulbId.groupId = arg;
     } else if (arg >= 9 && arg <= 17) {
       result["state"] = "OFF";
-      result["group_id"] = arg-9;
+      bulbId.groupId = arg-9;
     }
   } else if (command == FUT089_COLOR) {
     uint8_t rescaledColor = (arg - FUT089_COLOR_OFFSET) % 0x100;
@@ -78,12 +80,16 @@ void FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& resul
   } 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 won't be able to parse
-  // both unless state is persisted
-  // } else if (command == FUT089_SATURATION) {
-  //   result["saturation"] = constrain(arg, 0, 100);
-  // } else if (command == FUT089_KELVIN) {
-  //   result["color_temp"] = Units::whiteValToMireds(arg, 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 {
@@ -91,7 +97,5 @@ void FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& resul
     result["argument"] = arg;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
+  return bulbId;
 }

+ 1 - 1
lib/MiLight/FUT089PacketFormatter.h

@@ -39,7 +39,7 @@ public:
   virtual void modeSpeedUp();
   virtual void updateMode(uint8_t mode);
 
-  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result, GroupStateStore* stateStore);
 };
 
 #endif

+ 4 - 3
lib/MiLight/MiLightClient.cpp

@@ -4,12 +4,13 @@
 #include <RGBConverter.h>
 #include <Units.h>
 
-MiLightClient::MiLightClient(MiLightRadioFactory* radioFactory)
+MiLightClient::MiLightClient(MiLightRadioFactory* radioFactory, GroupStateStore& stateStore)
   : resendCount(MILIGHT_DEFAULT_RESEND_COUNT),
     currentRadio(NULL),
     currentRemote(NULL),
     numRadios(MiLightRadioConfig::NUM_CONFIGS),
-    packetSentHandler(NULL)
+    packetSentHandler(NULL),
+    stateStore(stateStore)
 {
   radios = new MiLightRadio*[numRadios];
 
@@ -108,7 +109,7 @@ void MiLightClient::write(uint8_t packet[]) {
   }
 
 #ifdef DEBUG_PRINTF
-  printf("Sending packet: ");
+  printf("Sending packet (%d repeats): ", this->resendCount);
   for (int i = 0; i < currentRemote->packetFormatter->getPacketLength(); i++) {
     printf("%02X", packet[i]);
   }

+ 3 - 2
lib/MiLight/MiLightClient.h

@@ -2,9 +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
@@ -17,7 +17,7 @@
 
 class MiLightClient {
 public:
-  MiLightClient(MiLightRadioFactory* radioFactory);
+  MiLightClient(MiLightRadioFactory* radioFactory, GroupStateStore& stateStore);
 
   ~MiLightClient() {
     delete[] radios;
@@ -82,6 +82,7 @@ protected:
   const size_t numRadios;
   unsigned int resendCount;
   PacketSentHandler packetSentHandler;
+  GroupStateStore& stateStore;
 
   MiLightRadio* switchRadio(const MiLightRemoteConfig* remoteConfig);
   uint8_t parseStatus(const JsonObject& object);

+ 27 - 21
lib/MiLight/MiLightRemoteConfig.cpp

@@ -1,11 +1,14 @@
 #include <MiLightRemoteConfig.h>
 
+/**
+ * IMPORTANT NOTE: These should be in the same order as MiLightRemoteType.
+ */
 const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = {
-  &FUT096Config,
-  &FUT091Config,
-  &FUT092Config,
-  &FUT089Config,
-  &FUT098Config
+  &FUT096Config, // rgbw
+  &FUT091Config, // cct
+  &FUT092Config, // rgb+cct
+  &FUT098Config, // rgb
+  &FUT089Config  // 8-group rgb+cct (b8, fut089)
 };
 
 const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
@@ -29,22 +32,18 @@ const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
     return &FUT098Config;
   }
 
+  Serial.println(F("ERROR - tried to fetch remote config for type"));
+
   return NULL;
 }
 
 const MiLightRemoteConfig* MiLightRemoteConfig::fromType(MiLightRemoteType type) {
-  switch (type) {
-    case REMOTE_TYPE_RGB:
-      return &FUT096Config;
-    case REMOTE_TYPE_CCT:
-      return &FUT091Config;
-    case REMOTE_TYPE_RGB_CCT:
-      return &FUT092Config;
-    case REMOTE_TYPE_FUT089:
-      return &FUT089Config;
-    default:
-      return NULL;
+  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(
@@ -60,6 +59,8 @@ const MiLightRemoteConfig* MiLightRemoteConfig::fromReceivedPacket(
     }
   }
 
+  Serial.println(F("ERROR - tried to fetch remote config for unknown packet"));
+
   return NULL;
 }
 
@@ -67,33 +68,38 @@ const MiLightRemoteConfig FUT096Config( //rgbw
   new RgbwPacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[0],
   REMOTE_TYPE_RGBW,
-  "rgbw"
+  "rgbw",
+  4
 );
 
 const MiLightRemoteConfig FUT091Config( //cct
   new CctPacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[1],
   REMOTE_TYPE_CCT,
-  "cct"
+  "cct",
+  4
 );
 
 const MiLightRemoteConfig FUT092Config( //rgb+cct
   new RgbCctPacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[2],
   REMOTE_TYPE_RGB_CCT,
-  "rgb_cct"
+  "rgb_cct",
+  4
 );
 
 const MiLightRemoteConfig FUT089Config( //rgb+cct B8 / FUT089
   new FUT089PacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[2],
   REMOTE_TYPE_FUT089,
-  "fut089"
+  "fut089",
+  8
 );
 
 const MiLightRemoteConfig FUT098Config( //rgb
   new RgbPacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[3],
   REMOTE_TYPE_RGB,
-  "rgb"
+  "rgb",
+  0
 );

+ 5 - 2
lib/MiLight/MiLightRemoteConfig.h

@@ -17,17 +17,20 @@ public:
     PacketFormatter* packetFormatter,
     MiLightRadioConfig& radioConfig,
     const MiLightRemoteType type,
-    const String name
+    const String name,
+    const size_t numGroups
   ) : packetFormatter(packetFormatter),
       radioConfig(radioConfig),
       type(type),
-      name(name)
+      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);

+ 3 - 1
lib/MiLight/PacketFormatter.cpp

@@ -64,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++) {

+ 4 - 2
lib/MiLight/PacketFormatter.h

@@ -1,8 +1,10 @@
 #include <Arduino.h>
 #include <inttypes.h>
 #include <functional>
-#include <MiLightButtons.h>
+#include <MiLightConstants.h>
 #include <ArduinoJson.h>
+#include <GroupState.h>
+#include <GroupStateStore.h>
 
 #ifndef _PACKET_FORMATTER_H
 #define _PACKET_FORMATTER_H
@@ -69,7 +71,7 @@ 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);
 

+ 32 - 23
lib/MiLight/RgbCctPacketFormatter.cpp

@@ -37,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) {
@@ -54,14 +63,16 @@ void RgbCctPacketFormatter::enableNightMode() {
   command(RGB_CCT_ON | 0x80, arg);
 }
 
-void RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
+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[V2_COMMAND_INDEX] & 0x7F);
   uint8_t arg = packetCopy[V2_ARGUMENT_INDEX];
@@ -73,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
@@ -111,7 +122,5 @@ void RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& resul
     result["argument"] = arg;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
+  return bulbId;
 }

+ 3 - 3
lib/MiLight/RgbCctPacketFormatter.h

@@ -11,8 +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
+#define RGB_CCT_KELVIN_REMOTE_START  0x94
+#define RGB_CCT_KELVIN_REMOTE_END    0xCC
 
 enum MiLightRgbCctCommand {
   RGB_CCT_ON = 0x01,
@@ -50,7 +50,7 @@ public:
   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);
 
 protected:
 

+ 7 - 7
lib/MiLight/RgbPacketFormatter.cpp

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

+ 8 - 9
lib/MiLight/RgbwPacketFormatter.cpp

@@ -1,5 +1,4 @@
 #include <RgbwPacketFormatter.h>
-#include <MiLightButtons.h>
 #include <Units.h>
 
 #define STATUS_COMMAND(status, groupId) ( RGBW_GROUP_1_ON + ((groupId - 1)*2) + status )
@@ -93,19 +92,21 @@ void RgbwPacketFormatter::enableNightMode() {
   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";
     // 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_BRIGHTNESS) {
     uint8_t brightness = 31;
     brightness -= packet[RGBW_BRIGHTNESS_GROUP_INDEX] >> 3;
@@ -126,9 +127,7 @@ void RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result)
     result["button_id"] = command;
   }
 
-  if (! result.containsKey("state")) {
-    result["state"] = "ON";
-  }
+  return bulbId;
 }
 
 void RgbwPacketFormatter::format(uint8_t const* packet, char* buffer) {

+ 3 - 1
lib/MiLight/RgbwPacketFormatter.h

@@ -62,12 +62,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

+ 324 - 0
lib/MiLightState/GroupState.cpp

@@ -0,0 +1,324 @@
+#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::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::applyState(JsonObject& partialState) {
+  if (isSetState()) {
+    partialState["state"] = getState() == ON ? "ON" : "OFF";
+  }
+  if (isSetBrightness()) {
+    partialState["brightness"] = Units::rescale(getBrightness(), 255, 100);
+  }
+  if (isSetBulbMode()) {
+    partialState["bulb_mode"] = BULB_MODE_NAMES[getBulbMode()];
+
+    if (getBulbMode() == BULB_MODE_COLOR) {
+      if (isSetHue() && isSetSaturation()) {
+        uint8_t rgb[3];
+        RGBConverter converter;
+        converter.hsvToRgb(getHue()/360.0, getSaturation()/100.0, 1, rgb);
+        JsonObject& color = partialState.createNestedObject("color");
+        color["r"] = rgb[0];
+        color["g"] = rgb[1];
+        color["b"] = rgb[2];
+      } else if (isSetHue()) {
+        partialState["hue"] = getHue();
+      } else if (isSetSaturation()) {
+        partialState["saturation"] = getSaturation();
+      }
+    } else if (getBulbMode() == BULB_MODE_SCENE) {
+      if (isSetMode()) {
+        partialState["mode"] = getMode();
+      }
+    } else if (getBulbMode() == BULB_MODE_WHITE) {
+      if (isSetKelvin()) {
+        partialState["color_temp"] = getMireds();
+      }
+    }
+  }
+}

+ 102 - 23
lib/MiLightState/GroupState.h

@@ -1,48 +1,127 @@
+#include <stddef.h>
 #include <inttypes.h>
-#include <Arduino.h>
+#include <MiLightConstants.h>
+#include <ArduinoJson.h>
 
 #ifndef _GROUP_STATE_H
 #define _GROUP_STATE_H
 
-struct GroupId {
+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"
 };
 
-struct GroupState {
-  // xxxx xxxx  xxxx xxxx  xxxx xxxx  xxxx xxxx
-  // ^..
-  uint32_t data;
+class GroupState {
+public:
+  GroupState();
 
   // 1 bit
-  bool isOn();
-  void setOn(bool on);
+  bool isSetState() const;
+  MiLightStatus getState() const;
+  bool setState(const MiLightStatus on);
 
   // 7 bits
-  uint8_t getBrightness();
-  void setBrightness(uint8_t brightness);
+  bool isSetBrightness() const;
+  uint8_t getBrightness() const;
+  bool setBrightness(uint8_t brightness);
 
   // 8 bits
-  uint8_t getHue();
-  void setHue(uint8_t hue);
+  bool isSetHue() const;
+  uint16_t getHue() const;
+  bool setHue(uint16_t hue);
 
   // 7 bits
-  uint8_t getSaturation();
-  void setSaturation(uint8_t saturation);
+  bool isSetSaturation() const;
+  uint8_t getSaturation() const;
+  bool setSaturation(uint8_t saturation);
 
   // 5 bits
-  uint8_t getMode();
-  void setMode(uint8_t mode);
+  bool isSetMode() const;
+  uint8_t getMode() const;
+  bool setMode(uint8_t mode);
 
   // 7 bits
-  uint8_t getKelvin();
-  void setKelvin(uint8_t kelvin);
-};
+  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();
 
-struct GroupStateNode {
-  GroupState state;
-  GroupId nextNode;
-  GroupId prevNode;
+  bool isMqttDirty() const;
+  inline bool setMqttDirty();
+  bool clearMqttDirty();
+
+  bool patch(const JsonObject& state);
+  void applyState(JsonObject& state);
+
+  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;
+    }
+  }
+}

+ 36 - 5
lib/MiLightState/GroupStateStore.h

@@ -1,15 +1,46 @@
 #include <GroupState.h>
+#include <GroupStateCache.h>
+#include <GroupStatePersistence.h>
 
-#ifndef _STATE_CACHE_H
-#define _STATE_CACHE_H
+#ifndef _GROUP_STATE_STORE_H
+#define _GROUP_STATE_STORE_H
 
 class GroupStateStore {
 public:
-  bool get(const GroupId& id, GroupState& state);
-  void set(const GroupId& id, const GroupState& state);
+  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:
-  void evictOldest(GroupState& state);
+  GroupStateCache cache;
+  GroupStatePersistence persistence;
+  LinkedList<BulbId> evictedIds;
+  const size_t flushRate;
+  unsigned long lastFlush;
+
+  void trackEviction();
 };
 
 #endif

+ 0 - 1
lib/Radio/LT8900MiLightRadio.h

@@ -7,7 +7,6 @@
 #endif
 
 #include <MiLightRadioConfig.h>
-#include <MiLightButtons.h>
 #include <MiLightRadio.h>
 
 //#define DEBUG_PRINTF

+ 0 - 18
lib/Radio/MiLightButtons.h

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

+ 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

+ 1 - 1
lib/Radio/MiLightRadioConfig.h

@@ -1,5 +1,5 @@
 #include <Arduino.h>
-#include <MiLightButtons.h>
+#include <MiLightConstants.h>
 #include <Size.h>
 
 #ifndef _MILIGHT_RADIO_CONFIG

+ 6 - 0
lib/Settings/Settings.cpp

@@ -77,8 +77,11 @@ 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);
 
     if (parsedSettings.containsKey("radio_interface_type")) {
       this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]);
@@ -143,8 +146,11 @@ 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;
 
   if (this->deviceIds) {
     JsonArray& arr = jsonBuffer.createArray();

+ 14 - 1
lib/Settings/Settings.h

@@ -16,6 +16,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'
 
@@ -64,7 +72,9 @@ public:
     httpRepeatFactor(5),
     listenRepeats(3),
     _autoRestartPeriod(0),
-    discoveryPort(48899)
+    discoveryPort(48899),
+    stateFlushInterval(1000),
+    mqttStateRateLimit(500)
   { }
 
   ~Settings() {
@@ -109,8 +119,11 @@ public:
   String mqttPassword;
   String mqttTopicPattern;
   String mqttUpdateTopicPattern;
+  String mqttStateTopicPattern;
   uint16_t discoveryPort;
   uint8_t listenRepeats;
+  size_t stateFlushInterval;
+  size_t mqttStateRateLimit;
 
 protected:
   size_t _autoRestartPeriod;

+ 51 - 9
lib/WebServer/MiLightHttpServer.cpp

@@ -7,21 +7,24 @@
 #include <string.h>
 #include <TokenIterator.h>
 #include <index.html.gz.h>
-#include <WiFiManager.h>
 
 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]() { 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(); });
@@ -112,8 +115,7 @@ void MiLightHttpServer::handleSystemPost() {
         server.send_P(200, TEXT_PLAIN, PSTR("true"));
 
         delay(100);
-        WiFiManager wifiManager;
-        wifiManager.resetSettings();
+        ESP.eraseConfig();
         delay(100);
         ESP.restart();
 
@@ -128,14 +130,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) {
@@ -290,6 +296,33 @@ void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
   server.send(200, "text/plain", response);
 }
 
+void MiLightHttpServer::sendGroupState(GroupState &state) {
+  String body;
+  StaticJsonBuffer<200> jsonBuffer;
+  JsonObject& obj = jsonBuffer.createObject();
+  state.applyState(obj);
+  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"));
@@ -317,6 +350,9 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
   TokenIterator groupIdItr(groupIds, _groupIds.length());
   TokenIterator remoteTypesItr(remoteTypes, _remoteTypes.length());
 
+  BulbId foundBulbId;
+  size_t groupCount = 0;
+
   while (remoteTypesItr.hasNext()) {
     const char* _remoteType = remoteTypesItr.nextToken();
     const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(_remoteType);
@@ -338,11 +374,17 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
 
         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) {

+ 8 - 2
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);
   }
@@ -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;
 

+ 9 - 4
platformio.ini

@@ -20,12 +20,14 @@ lib_deps_external =
   https://github.com/ratkins/RGBConverter
   Hash
   WebSockets
-extra_script =
-  .build_web.py
-build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -Idist
+  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 STATE_DEBUG
 
 [env:nodemcuv2]
 platform = espressif8266
@@ -33,7 +35,7 @@ framework = arduino
 board = nodemcuv2
 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}
@@ -43,6 +45,7 @@ platform = espressif8266
 framework = arduino
 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}
@@ -52,6 +55,7 @@ platform = espressif8266
 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}
@@ -61,6 +65,7 @@ platform = espressif8266
 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}

+ 58 - 15
src/main.cpp

@@ -5,6 +5,8 @@
 #include <FS.h>
 #include <IntParsing.h>
 #include <Size.h>
+#include <LinkedList.h>
+#include <GroupStateStore.h>
 #include <MiLightRadioConfig.h>
 #include <MiLightRemoteConfig.h>
 #include <MiLightHttpServer.h>
@@ -16,6 +18,7 @@
 #include <RGBConverter.h>
 #include <MiLightDiscoveryServer.h>
 #include <MiLightClient.h>
+#include <BulbStateUpdater.h>
 
 WiFiManager wifiManager;
 
@@ -28,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++) {
@@ -65,30 +75,46 @@ void initMilightUdpServers() {
   }
 }
 
+/**
+ * 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);
 
-  if (!result.containsKey("device_id")
-    ||!result.containsKey("group_id")
-    ||!result.containsKey("device_type")) {
-    Serial.println(F("Skipping update because packet formatter didn't supply necessary information."));
+  if (&bulbId == &DEFAULT_BULB_ID) {
+    Serial.println(F("Skipping packet handler because packet was not decoded"));
     return;
   }
 
-  uint16_t deviceId = result["device_id"];
-  uint16_t groupId = result["group_id"];
-
-  char output[200];
-  result.printTo(output);
+  const MiLightRemoteConfig& remoteConfig =
+    *MiLightRemoteConfig::fromType(bulbId.deviceType);
 
   if (mqttClient) {
-    mqttClient->sendUpdate(config, deviceId, groupId, output);
+    GroupState& groupState = stateStore->get(bulbId);
+    groupState.patch(result);
+    stateStore->set(bulbId, groupState);
+
+    // 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;
@@ -117,6 +143,9 @@ void handleListen() {
   }
 }
 
+/**
+ * Apply what's in the Settings object.
+ */
 void applySettings() {
   if (milightClient) {
     delete milightClient;
@@ -126,6 +155,10 @@ void applySettings() {
   }
   if (mqttClient) {
     delete mqttClient;
+    delete bulbStateUpdater;
+  }
+  if (stateStore) {
+    delete stateStore;
   }
 
   radioFactory = MiLightRadioFactory::fromSettings(settings);
@@ -134,13 +167,17 @@ 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);
   milightClient->begin();
   milightClient->onPacketSent(onPacketSentHandler);
+  milightClient->setResendCount(settings.packetRepeats);
 
   if (settings.mqttServer().length() > 0) {
     mqttClient = new MqttClient(settings, milightClient);
     mqttClient->begin();
+    bulbStateUpdater = new BulbStateUpdater(settings, *mqttClient, *stateStore);
   }
 
   initMilightUdpServers();
@@ -155,6 +192,9 @@ void applySettings() {
   }
 }
 
+/**
+ *
+ */
 bool shouldRestart() {
   if (! settings.isAutoRestartEnabled()) {
     return false;
@@ -185,7 +225,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();
@@ -198,6 +238,7 @@ void loop() {
 
   if (mqttClient) {
     mqttClient->handleClient();
+    bulbStateUpdater->loop();
   }
 
   if (udpServers) {
@@ -212,6 +253,8 @@ void loop() {
 
   handleListen();
 
+  stateStore->limitedFlush();
+
   if (shouldRestart()) {
     Serial.println(F("Auto-restart triggered. Restarting..."));
     ESP.restart();

+ 3 - 2
web/src/index.html

@@ -25,6 +25,7 @@
   <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.7.0/bootstrap-slider.min.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.12.4/js/standalone/selectize.min.js"></script>
 
+  <script src="js/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">
@@ -192,7 +193,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">
@@ -341,7 +342,7 @@
                 <ul class="dropdown-menu mode-dropdown">
                 </ul>
               </div>
-            </li>
+          </li>
           </div>
           <div class="mode-option inline" data-for="rgb,rgbw,rgb_cct,fut089">
             <li>

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

+ 84 - 13
web/src/js/script.js

@@ -1,8 +1,15 @@
+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"
 ];
 
 var FORM_SETTINGS_HELP = {
@@ -22,10 +29,17 @@ 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."
 }
 
 var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
@@ -52,7 +66,6 @@ var activeUrl = function() {
     , mode = getCurrentMode();
 
   if (deviceId == "") {
-    alert("Please enter a device ID.");
     throw "Must enter device ID";
   }
 
@@ -69,14 +82,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
 );
@@ -324,6 +343,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 +396,7 @@ $(function() {
       , hue = Math.round(360*pct)
       ;
 
-    $('.hue-value-display').css({
-      backgroundColor: "hsl(" + hue + ",100%,50%)"
-    });
+    updatePreviewColor(hue);
 
     updateGroup({hue: hue});
   };
@@ -409,6 +467,19 @@ $(function() {
     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',