Bladeren bron

Merge pull request #309 from sidoh/1.8.0

1.8.0
Chris Mullins 7 jaren geleden
bovenliggende
commit
675dd8d65c

+ 12 - 6
README.md

@@ -16,16 +16,22 @@ This project is a replacement for the wifi gateway.
 4. Official hubs connect to remote servers to enable WAN access, and this behavior is not disableable.
 5. This project is capable of passively listening for Milight packets sent from other devices (like remotes). It can publish data from intercepted packets to MQTT. This could, for example, allow the use of Milight remotes while keeping your home automation platform's state in sync. See the MQTT section for more detail.
 
-## Supported bulbs
+## Supported remotes
+
+The following remotes can be emulated:
 
 Support has been added for the following [bulb types](http://futlight.com/productlist.aspx?typeid=101):
 
-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, FUT105
+Model #|Name|Compatible Bulbs
+-------|-----------|----------------
+|FUT096|RGB/W|<ol><li>FUT014</li><li>FUT016</li><li>FUT103</li>|
+|FUT005, FUT006,FUT007</li></ol>|CCT|<ol><li>FUT011</li><li>FUT017</li><li>FUT019</li></ol>|
+|FUT098|RGB|Most RGB LED Strip Controlers|
+|FUT092|RGB/CCT|<ol><li>FUT012</li><li>FUT013</li><li>FUT014</li><li>FUT015</li><li>FUT103</li><li>FUT104</li><li>FUT105</li><li>Many RGB/CCT LED Strip Controllers</li></ol>|
+|FUT091|CCT v2|Most newer dual white bulbs and controllers|
+|FUT089|8-zone RGB/CCT|Most newer rgb + dual white bulbs and controllers|
 
-Other bulb types might work, but have not been tested. It is also relatively easy to add support for new bulb types.
+Other remotes or bulbs, but have not been tested. 
 
 ## What you'll need
 

File diff suppressed because it is too large
+ 2 - 2
dist/index.html.gz.h


+ 6 - 0
lib/MiLight/FUT089PacketFormatter.cpp

@@ -84,6 +84,12 @@ void FUT089PacketFormatter::enableNightMode() {
 }
 
 BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
+  if (stateStore == NULL) {
+    Serial.println(F("ERROR: stateStore not set.  Prepare was not called!  **THIS IS A BUG**"));
+    BulbId fakeId(0, 0, REMOTE_TYPE_FUT089);
+    return fakeId;
+  }
+
   uint8_t packetCopy[V2_PACKET_LEN];
   memcpy(packetCopy, packet, V2_PACKET_LEN);
   V2RFEncoding::decodeV2Packet(packetCopy);

+ 57 - 0
lib/MiLight/FUT091PacketFormatter.cpp

@@ -0,0 +1,57 @@
+#include <FUT091PacketFormatter.h>
+#include <V2RFEncoding.h>
+#include <Units.h>
+
+static const uint8_t BRIGHTNESS_SCALE_MAX = 0x97;
+static const uint8_t KELVIN_SCALE_MAX = 0xC5;
+
+void FUT091PacketFormatter::updateBrightness(uint8_t value) {
+  command(static_cast<uint8_t>(FUT091Command::BRIGHTNESS), V2PacketFormatter::tov2scale(value, BRIGHTNESS_SCALE_MAX, 2));
+}
+
+void FUT091PacketFormatter::updateTemperature(uint8_t value) {
+  command(static_cast<uint8_t>(FUT091Command::KELVIN), V2PacketFormatter::tov2scale(value, KELVIN_SCALE_MAX, 2, false));
+}
+
+void FUT091PacketFormatter::enableNightMode() {
+  uint8_t arg = groupCommandArg(OFF, groupId);
+  command(static_cast<uint8_t>(FUT091Command::ON_OFF) | 0x80, arg);
+}
+
+BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
+  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_FUT091
+  );
+
+  uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F);
+  uint8_t arg = packetCopy[V2_ARGUMENT_INDEX];
+
+  if (command == (uint8_t)FUT091Command::ON_OFF) {
+    if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) {
+      result["command"] = "night_mode";
+    } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte
+      result["state"] = "ON";
+      bulbId.groupId = arg;
+    } else {
+      result["state"] = "OFF";
+      bulbId.groupId = arg-5;
+    }
+  } else if (command == (uint8_t)FUT091Command::BRIGHTNESS) {
+    uint8_t level = V2PacketFormatter::fromv2scale(arg, BRIGHTNESS_SCALE_MAX, 2, true);
+    result["brightness"] = Units::rescale<uint8_t, uint8_t>(level, 255, 100);
+  } else if (command == (uint8_t)FUT091Command::KELVIN) {
+    uint8_t kelvin = V2PacketFormatter::fromv2scale(arg, KELVIN_SCALE_MAX, 2, false);
+    result["color_temp"] = Units::whiteValToMireds(kelvin, 100);
+  } else {
+    result["button_id"] = command;
+    result["argument"] = arg;
+  }
+
+  return bulbId;
+}

+ 25 - 0
lib/MiLight/FUT091PacketFormatter.h

@@ -0,0 +1,25 @@
+#include <V2PacketFormatter.h>
+
+#ifndef _FUT091_PACKET_FORMATTER_H
+#define _FUT091_PACKET_FORMATTER_H
+
+enum class FUT091Command {
+  ON_OFF = 0x01,
+  BRIGHTNESS = 0x2,
+  KELVIN = 0x03
+};
+
+class FUT091PacketFormatter : public V2PacketFormatter {
+public:
+  FUT091PacketFormatter()
+    : V2PacketFormatter(0x21, 4)    // protocol is 0x21, and there are 4 groups
+  { }
+
+  virtual void updateBrightness(uint8_t value);
+  virtual void updateTemperature(uint8_t value);
+  virtual void enableNightMode();
+
+  virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result);
+};
+
+#endif

+ 1 - 1
lib/MiLight/MiLightClient.cpp

@@ -86,7 +86,7 @@ void MiLightClient::prepare(const MiLightRemoteType type,
   const uint16_t deviceId,
   const uint8_t groupId
 ) {
-  prepare(MiLightRemoteConfig::fromType(type));
+  prepare(MiLightRemoteConfig::fromType(type), deviceId, groupId);
 }
 
 void MiLightClient::setResendCount(const unsigned int resendCount) {

+ 18 - 5
lib/MiLight/MiLightRemoteConfig.cpp

@@ -5,10 +5,11 @@
  */
 const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = {
   &FUT096Config, // rgbw
-  &FUT091Config, // cct
+  &FUT007Config, // cct
   &FUT092Config, // rgb+cct
   &FUT098Config, // rgb
-  &FUT089Config  // 8-group rgb+cct (b8, fut089)
+  &FUT089Config, // 8-group rgb+cct (b8, fut089)
+  &FUT091Config
 };
 
 const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
@@ -16,8 +17,8 @@ const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
     return &FUT096Config;
   }
 
-  if (type.equalsIgnoreCase("cct") || type.equalsIgnoreCase("fut091")) {
-    return &FUT091Config;
+  if (type.equalsIgnoreCase("cct") || type.equalsIgnoreCase("fut007")) {
+    return &FUT007Config;
   }
 
   if (type.equalsIgnoreCase("rgb_cct") || type.equalsIgnoreCase("fut092")) {
@@ -32,6 +33,10 @@ const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
     return &FUT098Config;
   }
 
+  if (type.equalsIgnoreCase("v2_cct") || type.equalsIgnoreCase("fut091")) {
+    return &FUT091Config;
+  }
+
   Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for type: "));
   Serial.println(type);
 
@@ -77,7 +82,7 @@ const MiLightRemoteConfig FUT096Config( //rgbw
   4
 );
 
-const MiLightRemoteConfig FUT091Config( //cct
+const MiLightRemoteConfig FUT007Config( //cct
   new CctPacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[1],
   REMOTE_TYPE_CCT,
@@ -85,6 +90,14 @@ const MiLightRemoteConfig FUT091Config( //cct
   4
 );
 
+const MiLightRemoteConfig FUT091Config( //v2 cct
+  new FUT091PacketFormatter(),
+  MiLightRadioConfig::ALL_CONFIGS[2],
+  REMOTE_TYPE_FUT091,
+  "fut091",
+  4
+);
+
 const MiLightRemoteConfig FUT092Config( //rgb+cct
   new RgbCctPacketFormatter(),
   MiLightRadioConfig::ALL_CONFIGS[2],

+ 4 - 2
lib/MiLight/MiLightRemoteConfig.h

@@ -6,6 +6,7 @@
 #include <RgbCctPacketFormatter.h>
 #include <CctPacketFormatter.h>
 #include <FUT089PacketFormatter.h>
+#include <FUT091PacketFormatter.h>
 #include <PacketFormatter.h>
 
 #ifndef _MILIGHT_REMOTE_CONFIG_H
@@ -36,14 +37,15 @@ public:
   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 size_t NUM_REMOTES = 6;
   static const MiLightRemoteConfig* ALL_REMOTES[NUM_REMOTES];
 };
 
 extern const MiLightRemoteConfig FUT096Config; //rgbw
-extern const MiLightRemoteConfig FUT091Config; //cct
+extern const MiLightRemoteConfig FUT007Config; //cct
 extern const MiLightRemoteConfig FUT092Config; //rgb+cct
 extern const MiLightRemoteConfig FUT089Config; //rgb+cct B8 / FUT089
 extern const MiLightRemoteConfig FUT098Config; //rgb
+extern const MiLightRemoteConfig FUT091Config; //v2 cct
 
 #endif

+ 3 - 20
lib/MiLight/RgbCctPacketFormatter.cpp

@@ -40,11 +40,7 @@ void RgbCctPacketFormatter::updateColorRaw(uint8_t value) {
 void RgbCctPacketFormatter::updateTemperature(uint8_t value) {
   // 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
-  uint8_t cmdValue = ((100 - value) * 2) + RGB_CCT_KELVIN_REMOTE_END;
+  uint8_t cmdValue = V2PacketFormatter::tov2scale(value, RGB_CCT_KELVIN_REMOTE_END, 2);
 
   // when updating temperature, the bulb switches to white.  If we are not already
   // in white mode, that makes changing temperature annoying because the current hue/mode
@@ -87,7 +83,7 @@ void RgbCctPacketFormatter::updateColorWhite() {
   // there is no direct white command, so let's look up our prior temperature and set that, which
   // causes the bulb to go white 
   GroupState ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT);
-  uint8_t value = ((100 - ourState.getKelvin()) * 2) + RGB_CCT_KELVIN_REMOTE_END;
+  uint8_t value = V2PacketFormatter::tov2scale(ourState.getKelvin(), RGB_CCT_KELVIN_REMOTE_END, 2);
 
   // issue command to set kelvin to prior value, which will drive to white
   command(RGB_CCT_KELVIN, value);
@@ -131,20 +127,7 @@ BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& res
     uint16_t hue = Units::rescale<uint16_t, uint16_t>(rescaledColor, 360, 255.0);
     result["hue"] = hue;
   } else if (command == RGB_CCT_KELVIN) {
-    // 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);
-
+    uint8_t temperature = V2PacketFormatter::fromv2scale(arg, RGB_CCT_KELVIN_REMOTE_END, 2);
     result["color_temp"] = Units::whiteValToMireds(temperature, 100);
   // brightness == saturation
   } else if (command == RGB_CCT_BRIGHTNESS && arg >= (RGB_CCT_BRIGHTNESS_OFFSET - 15)) {

+ 9 - 1
lib/MiLight/RgbPacketFormatter.cpp

@@ -47,11 +47,15 @@ void RgbPacketFormatter::updateColorRaw(uint8_t value) {
 }
 
 void RgbPacketFormatter::updateBrightness(uint8_t value) {
+  const GroupState& state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_RGB);
+  int8_t knownValue = state.isSetBrightness() ? state.getBrightness() : -1;
+
   valueByStepFunction(
     &PacketFormatter::increaseBrightness,
     &PacketFormatter::decreaseBrightness,
     RGB_INTERVALS,
-    value / RGB_INTERVALS
+    value / RGB_INTERVALS,
+    knownValue / RGB_INTERVALS
   );
 }
 
@@ -104,6 +108,10 @@ BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result
     result["command"] = "mode_speed_down";
   } else if (command == RGB_SPEED_UP) {
     result["command"] = "mode_speed_up";
+  } else if (command == RGB_BRIGHTNESS_DOWN) {
+    result["command"] = "brightness_down";
+  } else if (command == RGB_BRIGHTNESS_UP) {
+    result["command"] = "brightness_up";
   } else {
     result["button_id"] = command;
   }

+ 25 - 0
lib/MiLight/V2PacketFormatter.cpp

@@ -103,3 +103,28 @@ void V2PacketFormatter::switchMode(GroupState currentState, BulbMode desiredMode
   }
   
 }
+
+uint8_t V2PacketFormatter::tov2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse) {
+  if (reverse) {
+    value = 100 - value;
+  }
+
+  return (value * interval) + endValue;
+}
+
+uint8_t V2PacketFormatter::fromv2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse, uint8_t buffer) {
+  value = (((value + (0x100 - endValue))%0x100) / interval);
+  if (reverse) {
+    value = 100 - value;
+  }
+  if (value > 100) {
+    // overflow
+    if (value <= (100 + buffer)) {
+      value = 100;
+    // underflow (value is unsigned)
+    } else {
+      value = 0;
+    }
+  }
+  return value;
+}

+ 20 - 0
lib/MiLight/V2PacketFormatter.h

@@ -10,6 +10,9 @@
 #define V2_COMMAND_INDEX 4
 #define V2_ARGUMENT_INDEX 5
 
+// Default number of values to allow before and after strictly defined range for V2 scales
+#define V2_DEFAULT_RANGE_BUFFER 0x13
+
 class V2PacketFormatter : public PacketFormatter {
 public:
   V2PacketFormatter(uint8_t protocolId, uint8_t numGroups);
@@ -26,6 +29,23 @@ public:
 
   uint8_t groupCommandArg(MiLightStatus status, uint8_t groupId);
 
+  /*
+   * Some protocols have scales which have the following characteristics:
+   *   Start at some value X, goes down to 0, then up to Y.
+   * eg:
+   *   0x8F, 0x8D, ..., 0, 0x2, ..., 0x20
+   * This is a parameterized method to convert from [0, 100] TO this scale
+   */
+  static uint8_t tov2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse = true);
+
+  /*
+   * Method to convert FROM the scale described above to [0, 100].
+   * 
+   * An extra parameter is exposed: `buffer`, which allows for a range of values before/after the 
+   * max that will be mapped to 0 and 100, respectively.
+   */
+  static uint8_t fromv2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse = true, uint8_t buffer = V2_DEFAULT_RANGE_BUFFER);
+
 protected:
   const uint8_t protocolId;
   const uint8_t numGroups;

+ 49 - 0
lib/MiLightState/GroupState.cpp

@@ -4,6 +4,15 @@
 #include <RGBConverter.h>
 
 const BulbId DEFAULT_BULB_ID;
+static const GroupStateField ALL_PHYSICAL_FIELDS[] = {
+  GroupStateField::BRIGHTNESS,
+  GroupStateField::BULB_MODE,
+  GroupStateField::HUE,
+  GroupStateField::KELVIN,
+  GroupStateField::MODE,
+  GroupStateField::SATURATION,
+  GroupStateField::STATE
+};
 
 // Number of units each increment command counts for
 static const uint8_t INCREMENT_COMMAND_VALUE = 10;
@@ -89,6 +98,36 @@ GroupState::GroupState() {
   scratchpad.fields._kelvinScratch          = 0;
 }
 
+GroupState& GroupState::operator=(const GroupState& other) {
+  memcpy(state.rawData, other.state.rawData, DATA_LONGS * sizeof(uint32_t));
+  scratchpad.rawData = other.scratchpad.rawData;
+}
+
+GroupState::GroupState(const GroupState& other) {
+  memcpy(state.rawData, other.state.rawData, DATA_LONGS * sizeof(uint32_t));
+  scratchpad.rawData = other.scratchpad.rawData;
+}
+
+bool GroupState::operator==(const GroupState& other) const {
+  return memcmp(state.rawData, other.state.rawData, DATA_LONGS * sizeof(uint32_t)) == 0;
+}
+
+bool GroupState::isEqualIgnoreDirty(const GroupState& other) const {
+  GroupState meCopy = *this;
+  GroupState otherCopy = other;
+
+  meCopy.clearDirty();
+  meCopy.clearMqttDirty();
+  otherCopy.clearDirty();
+  otherCopy.clearMqttDirty();
+
+  return meCopy == otherCopy;
+}
+
+void GroupState::print(Stream& stream) const {
+  stream.printf("State: %08X %08X\n", state.rawData[0], state.rawData[1]);
+}
+
 bool GroupState::isSetField(GroupStateField field) const {
   switch (field) {
     case GroupStateField::COMPUTED_COLOR:
@@ -492,6 +531,16 @@ bool GroupState::applyIncrementCommand(GroupStateField field, IncrementDirection
   return false;
 }
 
+bool GroupState::patch(const GroupState& other) {
+  for (size_t i = 0; i < size(ALL_PHYSICAL_FIELDS); ++i) {
+    GroupStateField field = ALL_PHYSICAL_FIELDS[i];
+
+    if (other.isSetField(field)) {
+      setFieldValue(field, other.getFieldValue(field));
+    }
+  }
+}
+
 /*
   Update group state to reflect a packet state
 

+ 14 - 1
lib/MiLightState/GroupState.h

@@ -46,6 +46,13 @@ class GroupState {
 public:
 
   GroupState();
+  GroupState(const GroupState& other);
+  GroupState& operator=(const GroupState& other);
+
+  bool operator==(const GroupState& other) const;
+  bool isEqualIgnoreDirty(const GroupState& other) const;
+  void print(Stream& stream) const;
+
 
   bool isSetField(GroupStateField field) const;
   uint16_t getFieldValue(GroupStateField field) const;
@@ -108,6 +115,12 @@ public:
   inline bool setMqttDirty();
   bool clearMqttDirty();
 
+  // Patches this state with ONLY the set fields in the other. Returns 
+  // true if there were any changes.
+  bool patch(const GroupState& other);
+
+  // Patches this state with the fields defined in the JSON state.  Returns 
+  // true if there were any changes.
   bool patch(const JsonObject& state);
 
   // It's a little weird to need to pass in a BulbId here.  The purpose is to
@@ -141,7 +154,7 @@ public:
   static const GroupState& defaultState(MiLightRemoteType remoteType);
 
 private:
-  static const size_t DATA_LONGS = 3;
+  static const size_t DATA_LONGS = 2;
   union StateData {
     uint32_t rawData[DATA_LONGS];
     struct Fields {

+ 17 - 4
lib/MiLightState/GroupStateStore.cpp

@@ -13,9 +13,20 @@ GroupState& GroupStateStore::get(const BulbId& id) {
   if (state == NULL) {
     trackEviction();
     GroupState loadedState = GroupState::defaultState(id.deviceType);
-    persistence.get(id, loadedState);
 
-    state = cache.set(id, loadedState);
+    // For device types with groups, group 0 is a "virtual" group.  All devices paired with the same ID will respond
+    // to group 0.  So it doesn't make sense to store group 0 state by itself.
+    //
+    // For devices that don't have groups, we made the unfortunate decision to represent state using the fake group
+    // ID 0, so we can't always ignore group 0.
+    const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(id.deviceType);
+
+    if (id.groupId != 0 || remoteConfig == NULL || remoteConfig->numGroups == 0) {
+      persistence.get(id, loadedState);
+      state = cache.set(id, loadedState);
+    } else {
+      state = &loadedState;
+    }
   }
 
   return *state;
@@ -26,7 +37,7 @@ GroupState& GroupStateStore::get(const uint16_t deviceId, const uint8_t groupId,
   return get(bulbId);
 }
 
-// save state for a bulb.  If id.groupId == 0, will iternate across all groups
+// save state for a bulb.  If id.groupId == 0, will iterate across all groups
 // and individually save each group (recursively)
 GroupState& GroupStateStore::set(const BulbId &id, const GroupState& state) {
   GroupState& storedState = get(id);
@@ -38,7 +49,9 @@ GroupState& GroupStateStore::set(const BulbId &id, const GroupState& state) {
 
     for (size_t i = 1; i <= remote->numGroups; i++) {
       individualBulb.groupId = i;
-      set(individualBulb, state);
+
+      GroupState& individualState = get(individualBulb);
+      individualState.patch(state);
     }
   }
   

+ 2 - 1
lib/Radio/MiLightConstants.h

@@ -7,7 +7,8 @@ enum MiLightRemoteType {
   REMOTE_TYPE_CCT     = 1,
   REMOTE_TYPE_RGB_CCT = 2,
   REMOTE_TYPE_RGB     = 3,
-  REMOTE_TYPE_FUT089  = 4
+  REMOTE_TYPE_FUT089  = 4,
+  REMOTE_TYPE_FUT091  = 5
 };
 
 enum MiLightStatus {

+ 2 - 1
lib/Types/MiLightConstants.h

@@ -7,7 +7,8 @@ enum MiLightRemoteType {
   REMOTE_TYPE_CCT     = 1,
   REMOTE_TYPE_RGB_CCT = 2,
   REMOTE_TYPE_RGB     = 3,
-  REMOTE_TYPE_FUT089  = 4
+  REMOTE_TYPE_FUT089  = 4,
+  REMOTE_TYPE_FUT091  = 5
 };
 
 enum MiLightStatus {

+ 2 - 2
lib/Udp/V5MiLightUdpServer.cpp

@@ -85,7 +85,7 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
     uint8_t onOffGroup = CctPacketFormatter::cctCommandIdToGroup(command);
 
     if (onOffGroup != 255) {
-      client->prepare(&FUT091Config, deviceId, onOffGroup);
+      client->prepare(&FUT007Config, deviceId, onOffGroup);
       // Night mode commands are same as off commands with MSB set
       if ((command & 0x80) == 0x80) {
         client->enableNightMode();
@@ -95,7 +95,7 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
       return;
     }
 
-    client->prepare(&FUT091Config, deviceId, lastGroup);
+    client->prepare(&FUT007Config, deviceId, lastGroup);
 
     switch(command) {
       case UDP_CCT_BRIGHTNESS_DOWN:

+ 1 - 1
lib/Udp/V6CctCommandHandler.h

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

+ 14 - 1
src/main.cpp

@@ -1,3 +1,5 @@
+#ifndef UNIT_TEST
+
 #include <SPI.h>
 #include <WiFiManager.h>
 #include <ArduinoJson.h>
@@ -87,8 +89,17 @@ void initMilightUdpServers() {
 void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) {
   StaticJsonBuffer<200> buffer;
   JsonObject& result = buffer.createObject();
-  BulbId bulbId = config.packetFormatter->parsePacket(packet, result);
 
+  // This is gross.  But if a packet is received for a remote type before one is
+  // sent, prepare() won't have been called, meaning stateStore and settings will
+  // not have been initialized.  Both of these things should either be passed in
+  // as constructor parameters, or as prameters to the methods that require them.
+  // 
+  // But for now, just hackily call prepare.  At least fixes a bug.  The deviceId
+  // and groupId won't matter.
+  config.packetFormatter->prepare(0, 0, stateStore, &settings);
+
+  BulbId bulbId = config.packetFormatter->parsePacket(packet, result);
 
   // set LED mode for a packet movement
   ledStatus->oneshot(settings.ledModePacket, settings.ledModePacketCount);
@@ -344,3 +355,5 @@ void loop() {
     ESP.restart();
   }
 }
+
+#endif

+ 348 - 0
test/d1_mini/test.cpp

@@ -0,0 +1,348 @@
+// #if defined(ARDUINO) && defined(UNIT_TEST)
+
+#include <FS.h>
+#include <Arduino.h>
+
+#include <GroupState.h>
+#include <GroupStateStore.h>
+#include <GroupStateCache.h>
+#include <GroupStatePersistence.h>
+
+#include <RgbCctPacketFormatter.h>
+#include <FUT091PacketFormatter.h>
+#include <Units.h>
+
+#include "unity.h"
+
+//================================================================================
+// Packet formatter
+//================================================================================
+
+template <typename T>
+void run_packet_test(uint8_t* packet, PacketFormatter* packetFormatter, const BulbId& expectedBulbId, const String& expectedKey, const T expectedValue) {
+  GroupStateStore stateStore(10, 0);
+  Settings settings;
+  RgbCctPacketFormatter formatter;
+  DynamicJsonBuffer jsonBuffer;
+  JsonObject& result = jsonBuffer.createObject();
+
+  packetFormatter->prepare(0, 0, &stateStore, &settings);
+  BulbId bulbId = packetFormatter->parsePacket(packet, result);
+
+  TEST_ASSERT_EQUAL_INT_MESSAGE(expectedBulbId.deviceId, bulbId.deviceId, "Should get the expected device ID");
+  TEST_ASSERT_EQUAL_INT_MESSAGE(expectedBulbId.groupId, bulbId.groupId, "Should get the expected group ID");
+  TEST_ASSERT_EQUAL_INT_MESSAGE(expectedBulbId.deviceType, bulbId.deviceType, "Should get the expected remote type");
+
+  TEST_ASSERT_TRUE_MESSAGE(result.containsKey(expectedKey), "Parsed packet should be for expected command type");
+  TEST_ASSERT_TRUE_MESSAGE(result[expectedKey] == expectedValue, "Parsed packet should have expected value");
+}
+
+void test_fut092_packet_formatter() {
+  RgbCctPacketFormatter packetFormatter;
+
+  uint8_t onPacket[] = {0x00, 0xDB, 0xE1, 0x24, 0x66, 0xCA, 0x54, 0x66, 0xD2};
+  run_packet_test(
+    onPacket, 
+    &packetFormatter, 
+    BulbId(1, 1, REMOTE_TYPE_RGB_CCT), 
+    "state", 
+    "OFF"
+  );
+
+  uint8_t minColorTempPacket[] = {0x00, 0xDB, 0xE1, 0x24, 0x64, 0x3C, 0x47, 0x66, 0x31};
+  run_packet_test(
+    minColorTempPacket, 
+    &packetFormatter, 
+    BulbId(1, 1, REMOTE_TYPE_RGB_CCT), 
+    "color_temp", 
+    COLOR_TEMP_MIN_MIREDS
+  );
+
+  uint8_t maxColorTempPacket[] = {0x00, 0xDB, 0xE1, 0x24, 0x64, 0x94, 0x62, 0x66, 0x88};
+  run_packet_test(
+    maxColorTempPacket, 
+    &packetFormatter, 
+    BulbId(1, 1, REMOTE_TYPE_RGB_CCT), 
+    "color_temp", 
+    COLOR_TEMP_MAX_MIREDS
+  );
+}
+
+void test_fut091_packet_formatter() {
+  FUT091PacketFormatter packetFormatter;
+
+  uint8_t onPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x66, 0xCA, 0xBA, 0x66, 0xB5};
+  run_packet_test(
+    onPacket, 
+    &packetFormatter, 
+    BulbId(1, 1, REMOTE_TYPE_FUT091), 
+    "state", 
+    "OFF"
+  );
+
+  uint8_t minColorTempPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x64, 0x8D, 0xB9, 0x66, 0x71};
+  run_packet_test(
+    minColorTempPacket, 
+    &packetFormatter, 
+    BulbId(1, 1, REMOTE_TYPE_FUT091), 
+    "color_temp", 
+    COLOR_TEMP_MIN_MIREDS
+  );
+
+  uint8_t maxColorTempPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x64, 0x55, 0xB7, 0x66, 0x27};
+  run_packet_test(
+    maxColorTempPacket, 
+    &packetFormatter, 
+    BulbId(1, 1, REMOTE_TYPE_FUT091), 
+    "color_temp", 
+    COLOR_TEMP_MAX_MIREDS
+  );
+}
+
+//================================================================================
+// Group State
+//================================================================================
+
+GroupState color() {
+  GroupState s;
+
+  s.setState(MiLightStatus::ON);
+  s.setBulbMode(BulbMode::BULB_MODE_COLOR);
+  s.setBrightness(100);
+  s.setHue(1);
+  s.setSaturation(10);
+
+  return s;
+}
+
+void test_init_state() {
+  GroupState s;
+
+  TEST_ASSERT_EQUAL(s.getBulbMode(), BulbMode::BULB_MODE_WHITE);
+  TEST_ASSERT_EQUAL(s.isSetBrightness(), false);
+}
+
+void test_state_updates() {
+  GroupState s = color();
+
+  // Make sure values are packed and unpacked correctly
+  TEST_ASSERT_EQUAL(s.getBulbMode(), BulbMode::BULB_MODE_COLOR);
+  TEST_ASSERT_EQUAL(s.getBrightness(), 100);
+  TEST_ASSERT_EQUAL(s.getHue(), 1);
+  TEST_ASSERT_EQUAL(s.getSaturation(), 10);
+
+  // Make sure brightnesses are tied to mode
+  s.setBulbMode(BulbMode::BULB_MODE_WHITE);
+  s.setBrightness(0);
+
+  TEST_ASSERT_EQUAL(s.getBulbMode(), BulbMode::BULB_MODE_WHITE);
+  TEST_ASSERT_EQUAL(s.getBrightness(), 0);
+
+  s.setBulbMode(BulbMode::BULB_MODE_COLOR);
+
+  TEST_ASSERT_EQUAL(s.getBrightness(), 100);
+}
+
+void test_cache() {
+  BulbId id1(1, 1, REMOTE_TYPE_FUT089);
+  BulbId id2(1, 2, REMOTE_TYPE_FUT089);
+
+  GroupState s = color();
+  s.clearDirty();
+  s.clearMqttDirty();
+
+  GroupStateCache cache(1);
+  GroupState* storedState = cache.get(id2);
+
+  TEST_ASSERT_NULL_MESSAGE(storedState, "Should not retrieve value which hasn't been stored");
+
+  cache.set(id1, s);
+  storedState = cache.get(id1);
+
+  TEST_ASSERT_NOT_NULL_MESSAGE(storedState, "Should retrieve a value");
+  TEST_ASSERT_TRUE_MESSAGE(s == *storedState, "State should be the same when retrieved");
+
+  cache.set(id2, s);
+  storedState = cache.get(id2);
+
+  TEST_ASSERT_NOT_NULL_MESSAGE(storedState, "Should retrieve a value");
+  TEST_ASSERT_TRUE_MESSAGE(s == *storedState, "State should be the same when retrieved");
+
+  storedState = cache.get(id1);
+
+  TEST_ASSERT_NULL_MESSAGE(storedState, "Should evict old entry from cache");
+}
+
+void test_persistence() {
+  BulbId id1(1, 1, REMOTE_TYPE_FUT089);
+  BulbId id2(1, 2, REMOTE_TYPE_FUT089);
+
+  GroupStatePersistence persistence;
+
+  persistence.clear(id1);
+  persistence.clear(id2);
+
+  GroupState storedState;
+  GroupState s = color();
+  s.clearDirty();
+  s.clearMqttDirty();
+
+  GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089);
+
+  persistence.get(id1, storedState);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Should start with clean state");
+  persistence.get(id2, storedState);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Should start with clean state");
+
+  persistence.set(id1, s);
+
+  persistence.get(id2, storedState);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Should return default for state that hasn't been stored");
+
+  persistence.get(id1, storedState);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(s), "Should retrieve state from flash without modification");
+
+  GroupState newState = s;
+  newState.setBulbMode(BulbMode::BULB_MODE_WHITE);
+  newState.setBrightness(255);
+  persistence.set(id2, newState);
+
+  persistence.get(id1, storedState);
+
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(s), "Should retrieve unmodified state");
+
+  persistence.get(id2, storedState);
+
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(newState), "Should retrieve modified state");
+}
+
+void test_store() {
+  BulbId id1(1, 1, REMOTE_TYPE_FUT089);
+  BulbId id2(1, 2, REMOTE_TYPE_FUT089);
+
+  // cache 1 item, flush immediately
+  GroupStateStore store(1, 0);
+  GroupStatePersistence persistence;
+
+  persistence.clear(id1);
+  persistence.clear(id2);
+
+  GroupState initState = color();
+  GroupState initState2 = color();
+  GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089);
+  initState2.setBrightness(255);
+
+  GroupState* storedState;
+
+  storedState = &store.get(id2);
+  TEST_ASSERT_TRUE_MESSAGE(*storedState == defaultState, "Should return default for state that hasn't been stored");
+
+  store.set(id1, initState);
+  storedState = &store.get(id1);
+
+  TEST_ASSERT_TRUE_MESSAGE(*storedState == initState, "Should return cached state");
+
+  store.flush();
+  storedState = &store.get(id1);
+  TEST_ASSERT_FALSE_MESSAGE(storedState->isDirty(), "Should not be dirty after flushing");
+  TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(initState), "Should return cached state after flushing");
+
+  store.set(id2, defaultState);
+  storedState = &store.get(id2);
+  TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(defaultState), "Should return cached state");
+
+  storedState = &store.get(id1);
+  TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(initState), "Should return persisted state");
+}
+
+void test_group_0() {
+  BulbId group0Id(1, 0, REMOTE_TYPE_FUT089);
+  BulbId id1(1, 1, REMOTE_TYPE_FUT089);
+  BulbId id2(1, 2, REMOTE_TYPE_FUT089);
+
+  // cache 1 item, flush immediately
+  GroupStateStore store(10, 0);
+  GroupStatePersistence persistence;
+
+  persistence.clear(id1);
+  persistence.clear(id2);
+
+  GroupState initState = color();
+  GroupState initState2 = color();
+  GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089);
+  GroupState storedState;
+  GroupState expectedState;
+  GroupState group0State;
+
+  initState2.setBrightness(255);
+  group0State.setHue(100);
+
+  store.set(id1, initState);
+  store.set(id2, initState2);
+
+  TEST_ASSERT_FALSE_MESSAGE(group0State.isEqualIgnoreDirty(initState), "group0 state should be different than initState");
+  TEST_ASSERT_FALSE_MESSAGE(group0State.isEqualIgnoreDirty(initState2), "group0 state should be different than initState2");
+
+  storedState = store.get(id1);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(initState), "Should fetch persisted state");
+
+  storedState = store.get(id2);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(initState2), "Should fetch persisted state");
+
+  store.set(group0Id, group0State);
+
+  storedState = store.get(id1);
+  expectedState = initState;
+  expectedState.setHue(group0State.getHue());
+
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(expectedState), "Saving group 0 should only update changed field");
+
+  storedState = store.get(id2);
+  expectedState = initState2;
+  expectedState.setHue(group0State.getHue());
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(expectedState), "Saving group 0 should only update changed field");
+
+  // Test that state for group 0 is not persisted
+  storedState = store.get(group0Id);
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Group 0 state should not be stored -- should return default state");
+
+  // Should persist group 0 for device types with 0 groups
+  BulbId rgbId(1, 0, REMOTE_TYPE_RGB);
+  GroupState rgbState = GroupState::defaultState(REMOTE_TYPE_RGB);
+  rgbState.setHue(100);
+  rgbState.setBrightness(100);
+
+  store.set(rgbId, rgbState);
+  store.flush();
+
+  storedState = store.get(rgbId);
+
+  TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(rgbState), "Should persist group 0 for device type with no groups");
+}
+
+// setup connects serial, runs test cases (upcoming)
+void setup() {
+  delay(2000);
+  SPIFFS.begin();
+  Serial.begin(9600);
+
+  UNITY_BEGIN();
+
+  RUN_TEST(test_init_state);
+  RUN_TEST(test_state_updates);
+  RUN_TEST(test_cache);
+  RUN_TEST(test_persistence);
+  RUN_TEST(test_store);
+  RUN_TEST(test_group_0);
+
+  RUN_TEST(test_fut091_packet_formatter);
+  RUN_TEST(test_fut092_packet_formatter);
+
+  UNITY_END();
+}
+
+void loop() {
+  // nothing to be done here.
+}
+
+// #endif

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

@@ -16,6 +16,7 @@ label:not(.error) .error-info { display: none; }
 #content .dropdown-menu li { display: block; }
 #traffic-sniff { display: none; }
 #sniffed-traffic { max-height: 50em; overflow-y: auto; }
+.dropdown label { display: inline-block; }
 .navbar { background-color: rgba(41,41,45,.9) !important; border-radius: 0 }
 .navbar-brand, .navbar-brand:hover { color: white; }
 .btn-secondary {

+ 33 - 4
web/src/index.html

@@ -77,7 +77,7 @@
       </div>
 
       <div class="col-sm-4">
-        <div class="mode-option" id="group-option" data-for="cct,rgbw,rgb_cct,fut089">
+        <div class="mode-option" id="group-option" data-for="cct,rgbw,rgb_cct,fut089,fut091">
           <label for="groupId">Group</label>
 
           <div class="btn-group" id="groupId" data-toggle="buttons">
@@ -113,9 +113,9 @@
       </div>
 
       <div class="col-sm-4">
-        <label for="mode">Mode</label>
+        <label for="mode">Remote Type</label>
 
-        <div class="btn-group" id="mode" data-toggle="buttons">
+        <!-- <div class="btn-group" id="mode" data-toggle="buttons">
           <label class="btn btn-secondary active">
             <input type="radio" name="mode" autocomplete="off" data-value="rgbw" checked> RGBW
           </label>
@@ -131,6 +131,35 @@
           <label class="btn btn-secondary">
             <input type="radio" name="mode" autocomplete="off" data-value="fut089"> FUT089
           </label>
+          <label class="btn btn-secondary">
+            <input type="radio" name="mode" autocomplete="off" data-value="fut091"> FUT091
+          </label>
+        </div> -->
+        <div class="dropdown">
+          <button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
+            <label>Mode</label>
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu" id="mode">
+            <li data-value="rgbw">
+              <a href="#">RGBW</a>
+            </li>
+            <li data-value="cct">
+              <a href="#">CCT</a>
+            </li>
+            <li data-value="rgb_cct" class="active">
+              <a href="#">RGB+CCT</a>
+            </li>
+            <li data-value="rgb">
+              <a href="#">RGB</a>
+            </li>
+            <li data-value="fut089">
+              <a href="#">FUT089 / B8</a>
+            </li>
+            <li data-value="fut091">
+              <a href="#">FUT091 / B2</a>
+            </li>
+          </ul>
         </div>
       </div>
     </div>
@@ -171,7 +200,7 @@
       </div>
     </div>
 
-    <div class="mode-option" data-for="cct,rgb_cct,fut089">
+    <div class="mode-option" data-for="cct,rgb_cct,fut089,fut091">
       <div class="row">
         <div class="col-sm-12">
           <h5>Color Temperature</h5>

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

@@ -288,7 +288,7 @@ var activeUrl = function() {
 }
 
 var getCurrentMode = function() {
-  return $('input[name="mode"]:checked').data('value');
+  return $('#mode li.active').data('value');
 };
 
 var updateGroup = _.throttle(
@@ -476,7 +476,10 @@ var deviceIdError = function(v) {
 };
 
 var updateModeOptions = function() {
-  var currentMode = getCurrentMode();
+  var currentMode = getCurrentMode()
+    , modeLabel = $('#mode li[data-value="' + currentMode + '"] a').html();
+
+  $('label', $('#mode').closest('.dropdown')).html(modeLabel);
 
   $('.mode-option').map(function() {
     if ($(this).data('for').split(',').includes(currentMode)) {
@@ -720,7 +723,14 @@ $(function() {
     $('#gateway-server-configs').append(gatewayServerRow('', ''));
   });
 
-  $('#mode').change(updateModeOptions);
+  $('#mode li').click(function(e) {
+    e.preventDefault();
+
+    $('li', $(this).parent()).removeClass('active');
+    $(this).addClass('active');
+
+    updateModeOptions.bind(this)();
+  });
 
   $('body').on('click', '.remove-gateway-server', function() {
     $(this).closest('tr').remove();