Преглед на файлове

New feature: transitions (#488)

Chris Mullins преди 6 години
родител
ревизия
00fe835fdd
променени са 41 файла, в които са добавени 1887 реда и са изтрити 266 реда
  1. 20 0
      lib/DataStructures/LinkedList.h
  2. 6 5
      lib/MQTT/HomeAssistantDiscoveryClient.cpp
  3. 7 7
      lib/MQTT/MqttClient.cpp
  4. 7 6
      lib/MiLight/CctPacketFormatter.cpp
  5. 12 11
      lib/MiLight/FUT089PacketFormatter.cpp
  6. 6 5
      lib/MiLight/FUT091PacketFormatter.cpp
  7. 266 117
      lib/MiLight/MiLightClient.cpp
  8. 37 4
      lib/MiLight/MiLightClient.h
  9. 4 0
      lib/MiLight/PacketFormatter.cpp
  10. 1 0
      lib/MiLight/PacketFormatter.h
  11. 11 10
      lib/MiLight/RgbCctPacketFormatter.cpp
  12. 10 9
      lib/MiLight/RgbPacketFormatter.cpp
  13. 9 8
      lib/MiLight/RgbwPacketFormatter.cpp
  14. 88 45
      lib/MiLightState/GroupState.cpp
  15. 10 0
      lib/MiLightState/GroupState.h
  16. 9 0
      lib/MiLightState/GroupStateCache.cpp
  17. 1 0
      lib/MiLightState/GroupStateCache.h
  18. 2 2
      lib/MiLightState/GroupStateStore.h
  19. 152 0
      lib/Transitions/ColorTransition.cpp
  20. 58 0
      lib/Transitions/ColorTransition.h
  21. 76 0
      lib/Transitions/FieldTransition.cpp
  22. 48 0
      lib/Transitions/FieldTransition.h
  23. 149 0
      lib/Transitions/Transition.cpp
  24. 81 0
      lib/Transitions/Transition.h
  25. 113 0
      lib/Transitions/TransitionController.cpp
  26. 36 0
      lib/Transitions/TransitionController.h
  27. 7 0
      lib/Types/BulbId.cpp
  28. 2 0
      lib/Types/BulbId.h
  29. 18 18
      lib/Types/GroupStateField.cpp
  30. 24 0
      lib/Types/GroupStateField.h
  31. 18 0
      lib/Types/MiLightCommands.h
  32. 56 0
      lib/Types/ParsedColor.cpp
  33. 13 0
      lib/Types/ParsedColor.h
  34. 89 9
      lib/WebServer/MiLightHttpServer.cpp
  35. 10 1
      lib/WebServer/MiLightHttpServer.h
  36. 19 2
      src/main.cpp
  37. 1 1
      test/remote/helpers/mqtt_helpers.rb
  38. 28 1
      test/remote/lib/api_client.rb
  39. 2 2
      test/remote/spec/mqtt_spec.rb
  40. 3 3
      test/remote/spec/settings_spec.rb
  41. 378 0
      test/remote/spec/transition_spec.rb

+ 20 - 0
lib/DataStructures/LinkedList.h

@@ -67,6 +67,7 @@ public:
     else, decrement _size
   */
   virtual T remove(int index);
+  virtual void remove(ListNode<T>* node);
   /*
     Remove last object;
   */
@@ -193,6 +194,7 @@ bool LinkedList<T>::add(T _t){
   if(root){
     // Already have elements inserted
     last->next = tmp;
+    tmp->prev = last;
     last = tmp;
   }else{
     // First element being inserted
@@ -277,6 +279,24 @@ T LinkedList<T>::shift(){
 }
 
 template<typename T>
+void LinkedList<T>::remove(ListNode<T>* node){
+  if (node == root) {
+    shift();
+  } else if (node == last) {
+    pop();
+  } else {
+    ListNode<T>* prev = node->prev;
+    ListNode<T>* next = node->next;
+
+    prev->next = next;
+    next->prev = prev;
+
+    delete node;
+    --_size;
+  }
+}
+
+template<typename T>
 T LinkedList<T>::remove(int index){
   if (index < 0 || index >= _size)
   {

+ 6 - 5
lib/MQTT/HomeAssistantDiscoveryClient.cpp

@@ -1,4 +1,5 @@
 #include <HomeAssistantDiscoveryClient.h>
+#include <MiLightCommands.h>
 
 HomeAssistantDiscoveryClient::HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient)
   : settings(settings)
@@ -50,11 +51,11 @@ void HomeAssistantDiscoveryClient::addConfig(const char* alias, const BulbId& bu
   // Configure supported commands based on the bulb type
 
   // All supported bulbs support brightness and night mode
-  config[F("brightness")] = true;
-  config[F("effect")] = true;
+  config[GroupStateFieldNames::BRIGHTNESS] = true;
+  config[GroupStateFieldNames::EFFECT] = true;
 
   JsonArray effects = config.createNestedArray(F("effect_list"));
-  effects.add(F("night_mode"));
+  effects.add(MiLightCommandNames::NIGHT_MODE);
 
   // These bulbs support RGB color
   switch (bulbId.deviceType) {
@@ -74,7 +75,7 @@ void HomeAssistantDiscoveryClient::addConfig(const char* alias, const BulbId& bu
     case REMOTE_TYPE_FUT089:
     case REMOTE_TYPE_FUT091:
     case REMOTE_TYPE_RGB_CCT:
-      config[F("color_temp")] = true;
+      config[GroupStateFieldNames::COLOR_TEMP] = true;
       break;
     default:
       break; //nothing
@@ -85,7 +86,7 @@ void HomeAssistantDiscoveryClient::addConfig(const char* alias, const BulbId& bu
     case REMOTE_TYPE_FUT089:
     case REMOTE_TYPE_RGB_CCT:
     case REMOTE_TYPE_RGBW:
-      effects.add(F("white_mode"));
+      effects.add("white_mode");
       break;
     default:
       break; //nothing

+ 7 - 7
lib/MQTT/MqttClient.cpp

@@ -238,20 +238,20 @@ void MqttClient::publishCallback(char* topic, byte* payload, int length) {
       groupId = bulbId.groupId;
     }
   } else {
-    if (tokenBindings.hasBinding("device_id")) {
-      deviceId = parseInt<uint16_t>(tokenBindings.get("device_id"));
+    if (tokenBindings.hasBinding(GroupStateFieldNames::DEVICE_ID)) {
+      deviceId = parseInt<uint16_t>(tokenBindings.get(GroupStateFieldNames::DEVICE_ID));
     } else if (tokenBindings.hasBinding("hex_device_id")) {
       deviceId = parseInt<uint16_t>(tokenBindings.get("hex_device_id"));
     } else if (tokenBindings.hasBinding("dec_device_id")) {
       deviceId = parseInt<uint16_t>(tokenBindings.get("dec_device_id"));
     }
 
-    if (tokenBindings.hasBinding("group_id")) {
-      groupId = parseInt<uint16_t>(tokenBindings.get("group_id"));
+    if (tokenBindings.hasBinding(GroupStateFieldNames::GROUP_ID)) {
+      groupId = parseInt<uint16_t>(tokenBindings.get(GroupStateFieldNames::GROUP_ID));
     }
 
-    if (tokenBindings.hasBinding("device_type")) {
-      config = MiLightRemoteConfig::fromType(tokenBindings.get("device_type"));
+    if (tokenBindings.hasBinding(GroupStateFieldNames::DEVICE_TYPE)) {
+      config = MiLightRemoteConfig::fromType(tokenBindings.get(GroupStateFieldNames::DEVICE_TYPE));
     } else {
       Serial.println(F("MqttClient - WARNING: could not find device_type token.  Defaulting to FUT092.\n"));
     }
@@ -304,7 +304,7 @@ String MqttClient::generateConnectionStatusMessage(const char* connectionStatus)
     }
   } else {
     StaticJsonDocument<1024> json;
-    json["status"] = connectionStatus;
+    json[GroupStateFieldNames::STATUS] = connectionStatus;
 
     // Fill other fields
     AboutHelper::generateAboutObject(json, true);

+ 7 - 6
lib/MiLight/CctPacketFormatter.cpp

@@ -1,4 +1,5 @@
 #include <CctPacketFormatter.h>
+#include <MiLightCommands.h>
 
 static const uint8_t CCT_PROTOCOL_ID = 0x5A;
 
@@ -201,17 +202,17 @@ BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result)
 
   // Night mode
   if (command & 0x10) {
-    result["command"] = "night_mode";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE;
   } else if (onOffGroupId < 255) {
-    result["state"] = cctCommandToStatus(command) == ON ? "ON" : "OFF";
+    result[GroupStateFieldNames::STATE] = cctCommandToStatus(command) == ON ? "ON" : "OFF";
   } else if (command == CCT_BRIGHTNESS_DOWN) {
-    result["command"] = "brightness_down";
+    result[GroupStateFieldNames::COMMAND] = "brightness_down";
   } else if (command == CCT_BRIGHTNESS_UP) {
-    result["command"] = "brightness_up";
+    result[GroupStateFieldNames::COMMAND] = "brightness_up";
   } else if (command == CCT_TEMPERATURE_DOWN) {
-    result["command"] = "temperature_down";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::TEMPERATURE_DOWN;
   } else if (command == CCT_TEMPERATURE_UP) {
-    result["command"] = "temperature_up";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::TEMPERATURE_UP;
   } else {
     result["button_id"] = command;
   }

+ 12 - 11
lib/MiLight/FUT089PacketFormatter.cpp

@@ -1,6 +1,7 @@
 #include <FUT089PacketFormatter.h>
 #include <V2RFEncoding.h>
 #include <Units.h>
+#include <MiLightCommands.h>
 
 void FUT089PacketFormatter::modeSpeedDown() {
   command(FUT089_ON, FUT089_MODE_SPEED_DOWN);
@@ -113,39 +114,39 @@ BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject resu
 
   if (command == FUT089_ON) {
     if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) {
-      result["command"] = "night_mode";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE;
     } else if (arg == FUT089_MODE_SPEED_DOWN) {
-      result["command"] = "mode_speed_down";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN;
     } else if (arg == FUT089_MODE_SPEED_UP) {
-      result["command"] = "mode_speed_up";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP;
     } else if (arg == FUT089_WHITE_MODE) {
-      result["command"] = "set_white";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::SET_WHITE;
     } else if (arg <= 8) { // Group is not reliably encoded in group byte. Extract from arg byte
-      result["state"] = "ON";
+      result[GroupStateFieldNames::STATE] = "ON";
       bulbId.groupId = arg;
     } else if (arg >= 9 && arg <= 17) {
-      result["state"] = "OFF";
+      result[GroupStateFieldNames::STATE] = "OFF";
       bulbId.groupId = arg-9;
     }
   } else if (command == FUT089_COLOR) {
     uint8_t rescaledColor = (arg - FUT089_COLOR_OFFSET) % 0x100;
     uint16_t hue = Units::rescale<uint16_t, uint16_t>(rescaledColor, 360, 255.0);
-    result["hue"] = hue;
+    result[GroupStateFieldNames::HUE] = hue;
   } else if (command == FUT089_BRIGHTNESS) {
     uint8_t level = constrain(arg, 0, 100);
-    result["brightness"] = Units::rescale<uint8_t, uint8_t>(level, 255, 100);
+    result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale<uint8_t, uint8_t>(level, 255, 100);
   // saturation == kelvin. arg ranges are the same, so can't distinguish
   // without using state
   } else if (command == FUT089_SATURATION) {
     const GroupState* state = stateStore->get(bulbId);
 
     if (state != NULL && state->getBulbMode() == BULB_MODE_COLOR) {
-      result["saturation"] = 100 - constrain(arg, 0, 100);
+      result[GroupStateFieldNames::SATURATION] = 100 - constrain(arg, 0, 100);
     } else {
-      result["color_temp"] = Units::whiteValToMireds(100 - arg, 100);
+      result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(100 - arg, 100);
     }
   } else if (command == FUT089_MODE) {
-    result["mode"] = arg;
+    result[GroupStateFieldNames::MODE] = arg;
   } else {
     result["button_id"] = command;
     result["argument"] = arg;

+ 6 - 5
lib/MiLight/FUT091PacketFormatter.cpp

@@ -1,6 +1,7 @@
 #include <FUT091PacketFormatter.h>
 #include <V2RFEncoding.h>
 #include <Units.h>
+#include <MiLightCommands.h>
 
 static const uint8_t BRIGHTNESS_SCALE_MAX = 0x97;
 static const uint8_t KELVIN_SCALE_MAX = 0xC5;
@@ -34,20 +35,20 @@ BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject resu
 
   if (command == (uint8_t)FUT091Command::ON_OFF) {
     if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) {
-      result["command"] = "night_mode";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE;
     } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte
-      result["state"] = "ON";
+      result[GroupStateFieldNames::STATE] = "ON";
       bulbId.groupId = arg;
     } else {
-      result["state"] = "OFF";
+      result[GroupStateFieldNames::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);
+    result[GroupStateFieldNames::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);
+    result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(kelvin, 100);
   } else {
     result["button_id"] = command;
     result["argument"] = arg;

+ 266 - 117
lib/MiLight/MiLightClient.cpp

@@ -4,18 +4,69 @@
 #include <RGBConverter.h>
 #include <Units.h>
 #include <TokenIterator.h>
+#include <ParsedColor.h>
+#include <MiLightCommands.h>
+#include <functional>
+
+using namespace std::placeholders;
+
+const char* MiLightClient::FIELD_ORDERINGS[] = {
+  // These are handled manually
+  // GroupStateFieldNames::STATE,
+  // GroupStateFieldNames::STATUS,
+  GroupStateFieldNames::HUE,
+  GroupStateFieldNames::SATURATION,
+  GroupStateFieldNames::KELVIN,
+  GroupStateFieldNames::TEMPERATURE,
+  GroupStateFieldNames::COLOR_TEMP,
+  GroupStateFieldNames::MODE,
+  GroupStateFieldNames::COLOR,
+  // Level/Brightness must be processed last because they're specific to a particular bulb mode.
+  // So make sure bulb mode is set before applying level/brightness.
+  GroupStateFieldNames::LEVEL,
+  GroupStateFieldNames::BRIGHTNESS,
+  GroupStateFieldNames::COMMAND,
+  GroupStateFieldNames::COMMANDS
+};
+
+const std::map<const char*, std::function<void(MiLightClient*, JsonVariant)>, MiLightClient::cmp_str> MiLightClient::FIELD_SETTERS = {
+  {GroupStateFieldNames::LEVEL, &MiLightClient::updateBrightness},
+  {
+    GroupStateFieldNames::BRIGHTNESS,
+    [](MiLightClient* client, uint16_t arg) {
+      client->updateBrightness(Units::rescale<uint16_t, uint16_t>(arg, 100, 255));
+    }
+  },
+  {GroupStateFieldNames::HUE, &MiLightClient::updateHue},
+  {GroupStateFieldNames::SATURATION, &MiLightClient::updateSaturation},
+  {GroupStateFieldNames::KELVIN, &MiLightClient::updateTemperature},
+  {GroupStateFieldNames::TEMPERATURE, &MiLightClient::updateTemperature},
+  {
+    GroupStateFieldNames::COLOR_TEMP,
+    [](MiLightClient* client, uint16_t arg) {
+      client->updateTemperature(Units::miredsToWhiteVal(arg, 100));
+    }
+  },
+  {GroupStateFieldNames::MODE, &MiLightClient::updateMode},
+  {GroupStateFieldNames::COLOR, &MiLightClient::updateColor},
+  {GroupStateFieldNames::EFFECT, &MiLightClient::handleEffect},
+  {GroupStateFieldNames::COMMAND, &MiLightClient::handleCommand},
+  {GroupStateFieldNames::COMMANDS, &MiLightClient::handleCommands}
+};
 
 MiLightClient::MiLightClient(
   RadioSwitchboard& radioSwitchboard,
   PacketSender& packetSender,
   GroupStateStore* stateStore,
-  Settings& settings
+  Settings& settings,
+  TransitionController& transitions
 ) : radioSwitchboard(radioSwitchboard)
   , updateBeginHandler(NULL)
   , updateEndHandler(NULL)
   , stateStore(stateStore)
   , settings(settings)
   , packetSender(packetSender)
+  , transitions(transitions)
   , repeatsOverride(0)
 { }
 
@@ -218,119 +269,69 @@ void MiLightClient::toggleStatus() {
   flushPacket();
 }
 
+void MiLightClient::updateColor(JsonVariant json) {
+  ParsedColor color = ParsedColor::fromJson(json);
+
+  if (!color.success) {
+    Serial.println(F("Error parsing JSON color"));
+    return;
+  }
+
+  // We consider an RGB color "white" if all color intensities are roughly the
+  // same value.  An unscientific value of 10 (~4%) is chosen.
+  if ( abs(color.r - color.g) < RGB_WHITE_THRESHOLD
+    && abs(color.g - color.b) < RGB_WHITE_THRESHOLD
+    && abs(color.r - color.b) < RGB_WHITE_THRESHOLD) {
+      this->updateColorWhite();
+  } else {
+    this->updateHue(color.hue);
+    this->updateSaturation(color.saturation);
+  }
+}
+
 void MiLightClient::update(JsonObject request) {
   if (this->updateBeginHandler) {
     this->updateBeginHandler();
   }
 
   const uint8_t parsedStatus = this->parseStatus(request);
+  const JsonVariant jsonTransition = request[RequestKeys::TRANSITION];
+  float transition = 0;
+
+  if (!jsonTransition.isNull()) {
+    if (jsonTransition.is<float>()) {
+      transition = jsonTransition.as<float>();
+    } else if (jsonTransition.is<size_t>()) {
+      transition = jsonTransition.as<size_t>();
+    } else {
+      Serial.println(F("MiLightClient - WARN: unsupported transition type.  Must be float or int."));
+    }
+  }
 
   // Always turn on first
   if (parsedStatus == ON) {
     this->updateStatus(ON);
   }
 
-  if (request.containsKey("command")) {
-    this->handleCommand(request["command"]);
-  }
-
-  if (request.containsKey("commands")) {
-    JsonArray commands = request["commands"];
-
-    if (! commands.isNull()) {
-      for (size_t i = 0; i < commands.size(); i++) {
-        this->handleCommand(commands[i].as<const char*>());
+  for (const char* fieldName : FIELD_ORDERINGS) {
+    if (request.containsKey(fieldName)) {
+      auto handler = FIELD_SETTERS.find(fieldName);
+      JsonVariant value = request[fieldName];
+
+      if (handler != FIELD_SETTERS.end()) {
+        if (transition != 0) {
+          handleTransition(
+            GroupStateFieldHelpers::getFieldByName(fieldName),
+            value,
+            transition
+          );
+        } else {
+          handler->second(this, value);
+        }
       }
     }
   }
 
-  //Homeassistant - Handle effect
-  if (request.containsKey("effect")) {
-    this->handleEffect(request["effect"]);
-  }
-
-  if (request.containsKey("hue")) {
-    this->updateHue(request["hue"]);
-  }
-  if (request.containsKey("saturation")) {
-    this->updateSaturation(request["saturation"]);
-  }
-
-  // Convert RGB to HSV
-  if (request.containsKey("color")) {
-    uint16_t r, g, b;
-
-    if (request["color"].is<JsonObject>()) {
-      JsonObject color = request["color"];
-
-      r = color["r"];
-      g = color["g"];
-      b = color["b"];
-    } else if (request["color"].is<const char*>()) {
-      String colorStr = request["color"];
-      char colorCStr[colorStr.length()];
-      uint8_t parsedRgbColors[3] = {0, 0, 0};
-
-      strcpy(colorCStr, colorStr.c_str());
-      TokenIterator colorValueItr(colorCStr, strlen(colorCStr), ',');
-
-      for (size_t i = 0; i < 3 && colorValueItr.hasNext(); ++i) {
-        parsedRgbColors[i] = atoi(colorValueItr.nextToken());
-      }
-
-      r = parsedRgbColors[0];
-      g = parsedRgbColors[1];
-      b = parsedRgbColors[2];
-    } else {
-      Serial.println(F("Unknown format for `color' command"));
-      return;
-    }
-
-    // We consider an RGB color "white" if all color intensities are roughly the
-    // same value.  An unscientific value of 10 (~4%) is chosen.
-    if ( abs(r - g) < RGB_WHITE_THRESHOLD
-      && abs(g - b) < RGB_WHITE_THRESHOLD
-      && abs(r - b) < RGB_WHITE_THRESHOLD) {
-        this->updateColorWhite();
-    } else {
-      double hsv[3];
-      RGBConverter converter;
-      converter.rgbToHsv(r, g, b, hsv);
-
-      uint16_t hue = round(hsv[0]*360);
-      uint8_t saturation = round(hsv[1]*100);
-
-      this->updateHue(hue);
-      this->updateSaturation(saturation);
-    }
-  }
-
-  if (request.containsKey("level")) {
-    this->updateBrightness(request["level"]);
-  }
-  // HomeAssistant
-  if (request.containsKey("brightness")) {
-    uint8_t scaledBrightness = Units::rescale(request["brightness"].as<uint8_t>(), 100, 255);
-    this->updateBrightness(scaledBrightness);
-  }
-
-  if (request.containsKey("temperature")) {
-    this->updateTemperature(request["temperature"]);
-  }
-  if (request.containsKey("kelvin")) {
-    this->updateTemperature(request["kelvin"]);
-  }
-  // HomeAssistant
-  if (request.containsKey("color_temp")) {
-    this->updateTemperature(
-      Units::miredsToWhiteVal(request["color_temp"], 100)
-    );
-  }
-
-  if (request.containsKey("mode")) {
-    this->updateMode(request["mode"]);
-  }
-
   // Raw packet command/args
   if (request.containsKey("button_id") && request.containsKey("argument")) {
     this->command(request["button_id"], request["argument"]);
@@ -346,38 +347,186 @@ void MiLightClient::update(JsonObject request) {
   }
 }
 
-void MiLightClient::handleCommand(const String& command) {
-  if (command == "unpair") {
+void MiLightClient::handleCommands(JsonArray commands) {
+  if (! commands.isNull()) {
+    for (size_t i = 0; i < commands.size(); i++) {
+      this->handleCommand(commands[i]);
+    }
+  }
+}
+
+void MiLightClient::handleCommand(JsonVariant command) {
+  String cmdName;
+  JsonObject args;
+
+  if (command.is<JsonObject>()) {
+    JsonObject cmdObj = command.as<JsonObject>();
+    cmdName = cmdObj[GroupStateFieldNames::COMMAND].as<const char*>();
+    args = cmdObj["args"];
+  } else if (command.is<const char*>()) {
+    cmdName = command.as<const char*>();
+  }
+
+  if (cmdName == MiLightCommandNames::UNPAIR) {
     this->unpair();
-  } else if (command == "pair") {
+  } else if (cmdName == MiLightCommandNames::PAIR) {
     this->pair();
-  } else if (command == "set_white") {
+  } else if (cmdName == MiLightCommandNames::SET_WHITE) {
     this->updateColorWhite();
-  } else if (command == "night_mode") {
+  } else if (cmdName == MiLightCommandNames::NIGHT_MODE) {
     this->enableNightMode();
-  } else if (command == "level_up") {
+  } else if (cmdName == MiLightCommandNames::LEVEL_UP) {
     this->increaseBrightness();
-  } else if (command == "level_down") {
+  } else if (cmdName == MiLightCommandNames::LEVEL_DOWN) {
     this->decreaseBrightness();
-  } else if (command == "temperature_up") {
+  } else if (cmdName == MiLightCommandNames::TEMPERATURE_UP) {
     this->increaseTemperature();
-  } else if (command == "temperature_down") {
+  } else if (cmdName == MiLightCommandNames::TEMPERATURE_DOWN) {
     this->decreaseTemperature();
-  } else if (command == "next_mode") {
+  } else if (cmdName == MiLightCommandNames::NEXT_MODE) {
     this->nextMode();
-  } else if (command == "previous_mode") {
+  } else if (cmdName == MiLightCommandNames::PREVIOUS_MODE) {
     this->previousMode();
-  } else if (command == "mode_speed_down") {
+  } else if (cmdName == MiLightCommandNames::MODE_SPEED_DOWN) {
     this->modeSpeedDown();
-  } else if (command == "mode_speed_up") {
+  } else if (cmdName == MiLightCommandNames::MODE_SPEED_UP) {
     this->modeSpeedUp();
-  } else if (command == "toggle") {
+  } else if (cmdName == MiLightCommandNames::TOGGLE) {
     this->toggleStatus();
+  } else if (cmdName == MiLightCommandNames::TRANSITION) {
+    StaticJsonDocument<100> fakedoc;
+    this->handleTransition(args, fakedoc);
+  }
+}
+
+void MiLightClient::handleTransition(GroupStateField field, JsonVariant value, float duration) {
+  BulbId bulbId = currentRemote->packetFormatter->currentBulbId();
+  GroupState* currentState = stateStore->get(bulbId);
+  std::shared_ptr<Transition::Builder> transitionBuilder = nullptr;
+
+  if (currentState == nullptr) {
+    Serial.println(F("Error planning transition: could not find current bulb state."));
+    return;
+  }
+
+  if (!currentState->isSetField(field)) {
+    Serial.println(F("Error planning transition: current state for field could not be determined"));
+    return;
+  }
+
+  if (field == GroupStateField::COLOR) {
+    ParsedColor currentColor = currentState->getColor();
+    ParsedColor endColor = ParsedColor::fromJson(value);
+
+    transitionBuilder = transitions.buildColorTransition(
+      bulbId,
+      currentColor,
+      endColor
+    );
+  } else {
+    uint16_t currentValue = currentState->getParsedFieldValue(field);
+    uint16_t endValue = value;
+
+    transitionBuilder = transitions.buildFieldTransition(
+      bulbId,
+      field,
+      currentValue,
+      endValue
+    );
+  }
+
+  if (transitionBuilder == nullptr) {
+    Serial.printf_P(PSTR("Unsupported transition field: %s\n"), GroupStateFieldHelpers::getFieldName(field));
+    return;
+  }
+
+  transitionBuilder->setDuration(duration);
+  transitions.addTransition(transitionBuilder->build());
+}
+
+bool MiLightClient::handleTransition(JsonObject args, JsonDocument& responseObj) {
+  if (! args.containsKey(FS(TransitionParams::FIELD))
+    || ! args.containsKey(FS(TransitionParams::START_VALUE))
+    || ! args.containsKey(FS(TransitionParams::END_VALUE))) {
+    responseObj[F("error")] = F("Ignoring transition missing required arguments");
+    return false;
+  }
+
+  const char* fieldName = args[FS(TransitionParams::FIELD)];
+  GroupStateField field = GroupStateFieldHelpers::getFieldByName(fieldName);
+  std::shared_ptr<Transition::Builder> transitionBuilder = nullptr;
+
+  if (field == GroupStateField::UNKNOWN) {
+    char errorMsg[30];
+    sprintf_P(errorMsg, PSTR("Unknown transition field: %s\n"), fieldName);
+    responseObj[F("error")] = errorMsg;
+    return false;
+  }
+
+  // These fields can be transitioned directly.
+  switch (field) {
+    case GroupStateField::HUE:
+    case GroupStateField::SATURATION:
+    case GroupStateField::BRIGHTNESS:
+    case GroupStateField::LEVEL:
+    case GroupStateField::KELVIN:
+    case GroupStateField::COLOR_TEMP:
+      transitionBuilder = transitions.buildFieldTransition(
+        currentRemote->packetFormatter->currentBulbId(),
+        field,
+        args[FS(TransitionParams::START_VALUE)],
+        args[FS(TransitionParams::END_VALUE)]
+      );
+      break;
+
+    default:
+      break;
+  }
+
+  // Color can be decomposed into hue/saturation and these can be transitioned separately
+  if (field == GroupStateField::COLOR) {
+    ParsedColor startColor = ParsedColor::fromJson(args[FS(TransitionParams::START_VALUE)]);
+    ParsedColor endColor = ParsedColor::fromJson(args[FS(TransitionParams::END_VALUE)]);
+
+    if (! startColor.success) {
+      responseObj[F("error")] = F("Transition - error parsing start color");
+      return false;
+    }
+    if (! endColor.success) {
+      responseObj[F("error")] = F("Transition - error parsing end color");
+      return false;
+    }
+
+    transitionBuilder = transitions.buildColorTransition(
+      currentRemote->packetFormatter->currentBulbId(),
+      startColor,
+      endColor
+    );
+  }
+
+  if (transitionBuilder == nullptr) {
+    char errorMsg[30];
+    sprintf_P(errorMsg, PSTR("Recognized, but unsupported transition field: %s\n"), fieldName);
+    responseObj[F("error")] = errorMsg;
+    return false;
   }
+
+  if (args.containsKey(FS(TransitionParams::DURATION))) {
+    transitionBuilder->setDuration(args[FS(TransitionParams::DURATION)]);
+  }
+  if (args.containsKey(FS(TransitionParams::PERIOD))) {
+    transitionBuilder->setPeriod(args[FS(TransitionParams::PERIOD)]);
+  }
+  if (args.containsKey(FS(TransitionParams::NUM_PERIODS))) {
+    transitionBuilder->setNumPeriods(args[FS(TransitionParams::NUM_PERIODS)]);
+  }
+
+  transitions.addTransition(transitionBuilder->build());
+  return true;
 }
 
 void MiLightClient::handleEffect(const String& effect) {
-  if (effect == "night_mode") {
+  if (effect == MiLightCommandNames::NIGHT_MODE) {
     this->enableNightMode();
   } else if (effect == "white" || effect == "white_mode") {
     this->updateColorWhite();
@@ -389,10 +538,10 @@ void MiLightClient::handleEffect(const String& effect) {
 uint8_t MiLightClient::parseStatus(JsonObject object) {
   JsonVariant status;
 
-  if (object.containsKey("status")) {
-    status = object["status"];
-  } else if (object.containsKey("state")) {
-    status = object["state"];
+  if (object.containsKey(GroupStateFieldNames::STATUS)) {
+    status = object[GroupStateFieldNames::STATUS];
+  } else if (object.containsKey(GroupStateFieldNames::STATE)) {
+    status = object[GroupStateFieldNames::STATE];
   } else {
     return 255;
   }

+ 37 - 4
lib/MiLight/MiLightClient.h

@@ -6,6 +6,10 @@
 #include <Settings.h>
 #include <GroupStateStore.h>
 #include <PacketSender.h>
+#include <TransitionController.h>
+#include <cstring>
+#include <map>
+#include <set>
 
 #ifndef _MILIGHTCLIENT_H
 #define _MILIGHTCLIENT_H
@@ -13,6 +17,21 @@
 //#define DEBUG_PRINTF
 //#define DEBUG_CLIENT_COMMANDS     // enable to show each individual change command (like hue, brightness, etc)
 
+#define FS(str) (reinterpret_cast<const __FlashStringHelper*>(str))
+
+namespace RequestKeys {
+  static const char TRANSITION[] = "transition";
+};
+
+namespace TransitionParams {
+  static const char FIELD[] PROGMEM = "field";
+  static const char START_VALUE[] PROGMEM = "start_value";
+  static const char END_VALUE[] PROGMEM = "end_value";
+  static const char DURATION[] PROGMEM = "duration";
+  static const char PERIOD[] PROGMEM = "period";
+  static const char NUM_PERIODS[] PROGMEM = "num_periods";
+}
+
 // Used to determine RGB colros that are approximately white
 #define RGB_WHITE_THRESHOLD 10
 
@@ -22,7 +41,8 @@ public:
     RadioSwitchboard& radioSwitchboard,
     PacketSender& packetSender,
     GroupStateStore* stateStore,
-    Settings& settings
+    Settings& settings,
+    TransitionController& transitions
   );
 
   ~MiLightClient() { }
@@ -58,6 +78,7 @@ public:
   void updateColorWhite();
   void updateColorRaw(const uint8_t color);
   void enableNightMode();
+  void updateColor(JsonVariant json);
 
   // CCT methods
   void updateTemperature(const uint8_t colorTemperature);
@@ -69,7 +90,10 @@ public:
   void updateSaturation(const uint8_t saturation);
 
   void update(JsonObject object);
-  void handleCommand(const String& command);
+  void handleCommand(JsonVariant command);
+  void handleCommands(JsonArray commands);
+  bool handleTransition(JsonObject args, JsonDocument& responseObj);
+  void handleTransition(GroupStateField field, JsonVariant value, float duration);
   void handleEffect(const String& effect);
 
   void onUpdateBegin(EventHandler handler);
@@ -86,7 +110,17 @@ public:
   // Clear the repeats override so that the default is used
   void clearRepeatsOverride();
 
+  uint8_t parseStatus(JsonObject object);
+
 protected:
+  struct cmp_str {
+    bool operator()(char const *a, char const *b) const {
+        return std::strcmp(a, b) < 0;
+    }
+  };
+  static const std::map<const char*, std::function<void(MiLightClient*, JsonVariant)>, cmp_str> FIELD_SETTERS;
+  static const char* FIELD_ORDERINGS[];
+
   RadioSwitchboard& radioSwitchboard;
   std::vector<std::shared_ptr<MiLightRadio>> radios;
   std::shared_ptr<MiLightRadio> currentRadio;
@@ -98,12 +132,11 @@ protected:
   GroupStateStore* stateStore;
   Settings& settings;
   PacketSender& packetSender;
+  TransitionController& transitions;
 
   // If set, override the number of packet repeats used.
   size_t repeatsOverride;
 
-  uint8_t parseStatus(JsonObject object);
-
   void flushPacket();
 };
 

+ 4 - 0
lib/MiLight/PacketFormatter.cpp

@@ -182,3 +182,7 @@ void PacketFormatter::formatV1Packet(uint8_t const* packet, char* buffer) {
 size_t PacketFormatter::getPacketLength() const {
   return packetLength;
 }
+
+BulbId PacketFormatter::currentBulbId() const {
+  return BulbId(deviceId, groupId, deviceType);
+}

+ 1 - 0
lib/MiLight/PacketFormatter.h

@@ -84,6 +84,7 @@ public:
   virtual void format(uint8_t const* packet, char* buffer);
 
   virtual BulbId parsePacket(const uint8_t* packet, JsonObject result);
+  virtual BulbId currentBulbId() const;
 
   static void formatV1Packet(uint8_t const* packet, char* buffer);
 

+ 11 - 10
lib/MiLight/RgbCctPacketFormatter.cpp

@@ -1,6 +1,7 @@
 #include <RgbCctPacketFormatter.h>
 #include <V2RFEncoding.h>
 #include <Units.h>
+#include <MiLightCommands.h>
 
 void RgbCctPacketFormatter::modeSpeedDown() {
   command(RGB_CCT_ON, RGB_CCT_MODE_SPEED_DOWN);
@@ -122,33 +123,33 @@ BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject resu
 
   if (command == RGB_CCT_ON) {
     if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) {
-      result["command"] = "night_mode";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE;
     } else if (arg == RGB_CCT_MODE_SPEED_DOWN) {
-      result["command"] = "mode_speed_down";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN;
     } else if (arg == RGB_CCT_MODE_SPEED_UP) {
-      result["command"] = "mode_speed_up";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP;
     } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte
-      result["state"] = "ON";
+      result[GroupStateFieldNames::STATE] = "ON";
       bulbId.groupId = arg;
     } else {
-      result["state"] = "OFF";
+      result[GroupStateFieldNames::STATE] = "OFF";
       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;
+    result[GroupStateFieldNames::HUE] = hue;
   } else if (command == RGB_CCT_KELVIN) {
     uint8_t temperature = V2PacketFormatter::fromv2scale(arg, RGB_CCT_KELVIN_REMOTE_END, 2);
-    result["color_temp"] = Units::whiteValToMireds(temperature, 100);
+    result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(temperature, 100);
   // brightness == saturation
   } else if (command == RGB_CCT_BRIGHTNESS && arg >= (RGB_CCT_BRIGHTNESS_OFFSET - 15)) {
     uint8_t level = constrain(arg - RGB_CCT_BRIGHTNESS_OFFSET, 0, 100);
-    result["brightness"] = Units::rescale<uint8_t, uint8_t>(level, 255, 100);
+    result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale<uint8_t, uint8_t>(level, 255, 100);
   } else if (command == RGB_CCT_SATURATION) {
-    result["saturation"] = constrain(arg - RGB_CCT_SATURATION_OFFSET, 0, 100);
+    result[GroupStateFieldNames::SATURATION] = constrain(arg - RGB_CCT_SATURATION_OFFSET, 0, 100);
   } else if (command == RGB_CCT_MODE) {
-    result["mode"] = arg;
+    result[GroupStateFieldNames::MODE] = arg;
   } else {
     result["button_id"] = command;
     result["argument"] = arg;

+ 10 - 9
lib/MiLight/RgbPacketFormatter.cpp

@@ -1,5 +1,6 @@
 #include <RgbPacketFormatter.h>
 #include <Units.h>
+#include <MiLightCommands.h>
 
 void RgbPacketFormatter::initializePacket(uint8_t *packet) {
   size_t packetPtr = 0;
@@ -93,25 +94,25 @@ BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result)
   );
 
   if (command == RGB_ON) {
-    result["state"] = "ON";
+    result[GroupStateFieldNames::STATE] = "ON";
   } else if (command == RGB_OFF) {
-    result["state"] = "OFF";
+    result[GroupStateFieldNames::STATE] = "OFF";
   } else if (command == 0) {
     uint16_t remappedColor = Units::rescale<uint16_t, uint16_t>(packet[RGB_COLOR_INDEX], 360.0, 255.0);
     remappedColor = (remappedColor + 320) % 360;
-    result["hue"] = remappedColor;
+    result[GroupStateFieldNames::HUE] = remappedColor;
   } else if (command == RGB_MODE_DOWN) {
-    result["command"] = "previous_mode";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::PREVIOUS_MODE;
   } else if (command == RGB_MODE_UP) {
-    result["command"] = "next_mode";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NEXT_MODE;
   } else if (command == RGB_SPEED_DOWN) {
-    result["command"] = "mode_speed_down";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN;
   } else if (command == RGB_SPEED_UP) {
-    result["command"] = "mode_speed_up";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP;
   } else if (command == RGB_BRIGHTNESS_DOWN) {
-    result["command"] = "brightness_down";
+    result[GroupStateFieldNames::COMMAND] = "brightness_down";
   } else if (command == RGB_BRIGHTNESS_UP) {
-    result["command"] = "brightness_up";
+    result[GroupStateFieldNames::COMMAND] = "brightness_up";
   } else {
     result["button_id"] = command;
   }

+ 9 - 8
lib/MiLight/RgbwPacketFormatter.cpp

@@ -1,5 +1,6 @@
 #include <RgbwPacketFormatter.h>
 #include <Units.h>
+#include <MiLightCommands.h>
 
 #define STATUS_COMMAND(status, groupId) ( RGBW_GROUP_1_ON + (((groupId) - 1)*2) + (status) )
 #define GROUP_FOR_STATUS_COMMAND(buttonId) ( ((buttonId) - 1) / 2 )
@@ -118,7 +119,7 @@ BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result
   );
 
   if (command >= RGBW_ALL_ON && command <= RGBW_GROUP_4_OFF) {
-    result["state"] = (STATUS_FOR_COMMAND(command) == ON) ? "ON" : "OFF";
+    result[GroupStateFieldNames::STATE] = (STATUS_FOR_COMMAND(command) == ON) ? "ON" : "OFF";
 
     // Determine group ID from button ID for on/off. The remote's state is from
     // the last packet sent, not the current one, and that can be wrong for
@@ -126,9 +127,9 @@ BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result
     bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command);
   } else if (command & 0x10) {
     if ((command % 2) == 0) {
-      result["command"] = "night_mode";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE;
     } else {
-      result["command"] = "set_white";
+      result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::SET_WHITE;
     }
     bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command & 0xF);
   } else if (command == RGBW_BRIGHTNESS) {
@@ -136,17 +137,17 @@ BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result
     brightness -= packet[RGBW_BRIGHTNESS_GROUP_INDEX] >> 3;
     brightness += 17;
     brightness %= 32;
-    result["brightness"] = Units::rescale<uint8_t, uint8_t>(brightness, 255, 25);
+    result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale<uint8_t, uint8_t>(brightness, 255, 25);
   } else if (command == RGBW_COLOR) {
     uint16_t remappedColor = Units::rescale<uint16_t, uint16_t>(packet[RGBW_COLOR_INDEX], 360.0, 255.0);
     remappedColor = (remappedColor + 320) % 360;
-    result["hue"] = remappedColor;
+    result[GroupStateFieldNames::HUE] = remappedColor;
   } else if (command == RGBW_SPEED_DOWN) {
-    result["command"] = "mode_speed_down";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN;
   } else if (command == RGBW_SPEED_UP) {
-    result["command"] = "mode_speed_up";
+    result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP;
   } else if (command == RGBW_DISCO_MODE) {
-    result["mode"] = packet[0] & ~RGBW_PROTOCOL_ID_BYTE;
+    result[GroupStateFieldNames::MODE] = packet[0] & ~RGBW_PROTOCOL_ID_BYTE;
   } else {
     result["button_id"] = command;
   }

+ 88 - 45
lib/MiLightState/GroupState.cpp

@@ -3,6 +3,7 @@
 #include <MiLightRemoteConfig.h>
 #include <RGBConverter.h>
 #include <BulbId.h>
+#include <MiLightCommands.h>
 
 static const char* BULB_MODE_NAMES[] = {
   "white",
@@ -13,7 +14,7 @@ static const char* BULB_MODE_NAMES[] = {
 
 const BulbId DEFAULT_BULB_ID;
 
-static const GroupStateField ALL_PHYSICAL_FIELDS[] = {
+const GroupStateField GroupState::ALL_PHYSICAL_FIELDS[] = {
   GroupStateField::BULB_MODE,
   GroupStateField::HUE,
   GroupStateField::KELVIN,
@@ -275,6 +276,19 @@ uint16_t GroupState::getFieldValue(GroupStateField field) const {
   return 0;
 }
 
+uint16_t GroupState::getParsedFieldValue(GroupStateField field) const {
+  switch (field) {
+    case GroupStateField::LEVEL:
+      return getBrightness();
+    case GroupStateField::BRIGHTNESS:
+      return Units::rescale(getBrightness(), 255, 100);
+    case GroupStateField::COLOR_TEMP:
+      return getMireds();
+    default:
+      return getFieldValue(field);
+  }
+}
+
 uint16_t GroupState::getScratchFieldValue(GroupStateField field) const {
   switch (field) {
     case GroupStateField::BRIGHTNESS:
@@ -716,49 +730,49 @@ bool GroupState::patch(JsonObject state) {
   Serial.println();
 #endif
 
-  if (state.containsKey("state")) {
-    bool stateChange = setState(state["state"] == "ON" ? ON : OFF);
+  if (state.containsKey(GroupStateFieldNames::STATE)) {
+    bool stateChange = setState(state[GroupStateFieldNames::STATE] == "ON" ? ON : OFF);
     changes |= stateChange;
   }
 
   // Devices do not support changing their state while off, so don't apply state
   // changes to devices we know are off.
 
-  if (isOn() && state.containsKey("brightness")) {
-    bool stateChange = setBrightness(Units::rescale(state["brightness"].as<uint8_t>(), 100, 255));
+  if (isOn() && state.containsKey(GroupStateFieldNames::BRIGHTNESS)) {
+    bool stateChange = setBrightness(Units::rescale(state[GroupStateFieldNames::BRIGHTNESS].as<uint8_t>(), 100, 255));
     changes |= stateChange;
   }
-  if (isOn() && state.containsKey("hue")) {
-    changes |= setHue(state["hue"]);
+  if (isOn() && state.containsKey(GroupStateFieldNames::HUE)) {
+    changes |= setHue(state[GroupStateFieldNames::HUE]);
     changes |= setBulbMode(BULB_MODE_COLOR);
   }
-  if (isOn() && state.containsKey("saturation")) {
-    changes |= setSaturation(state["saturation"]);
+  if (isOn() && state.containsKey(GroupStateFieldNames::SATURATION)) {
+    changes |= setSaturation(state[GroupStateFieldNames::SATURATION]);
   }
-  if (isOn() && state.containsKey("mode")) {
-    changes |= setMode(state["mode"]);
+  if (isOn() && state.containsKey(GroupStateFieldNames::MODE)) {
+    changes |= setMode(state[GroupStateFieldNames::MODE]);
     changes |= setBulbMode(BULB_MODE_SCENE);
   }
-  if (isOn() && state.containsKey("color_temp")) {
-    changes |= setMireds(state["color_temp"]);
+  if (isOn() && state.containsKey(GroupStateFieldNames::COLOR_TEMP)) {
+    changes |= setMireds(state[GroupStateFieldNames::COLOR_TEMP]);
     changes |= setBulbMode(BULB_MODE_WHITE);
   }
 
-  if (state.containsKey("command")) {
-    const String& command = state["command"];
+  if (state.containsKey(GroupStateFieldNames::COMMAND)) {
+    const String& command = state[GroupStateFieldNames::COMMAND];
 
-    if (isOn() && command == "set_white") {
+    if (isOn() && command == MiLightCommandNames::SET_WHITE) {
       changes |= setBulbMode(BULB_MODE_WHITE);
-    } else if (command == "night_mode") {
+    } else if (command == MiLightCommandNames::NIGHT_MODE) {
       changes |= setBulbMode(BULB_MODE_NIGHT);
     } else if (isOn() && command == "brightness_up") {
       changes |= applyIncrementCommand(GroupStateField::BRIGHTNESS, IncrementDirection::INCREASE);
     } else if (isOn() && command == "brightness_down") {
       changes |= applyIncrementCommand(GroupStateField::BRIGHTNESS, IncrementDirection::DECREASE);
-    } else if (isOn() && command == "temperature_up") {
+    } else if (isOn() && command == MiLightCommandNames::TEMPERATURE_UP) {
       changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::INCREASE);
       changes |= setBulbMode(BULB_MODE_WHITE);
-    } else if (isOn() && command == "temperature_down") {
+    } else if (isOn() && command == MiLightCommandNames::TEMPERATURE_DOWN) {
       changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::DECREASE);
       changes |= setBulbMode(BULB_MODE_WHITE);
     }
@@ -775,20 +789,12 @@ bool GroupState::patch(JsonObject state) {
 }
 
 void GroupState::applyColor(JsonObject state) const {
-  uint8_t rgb[3];
-  RGBConverter converter;
-  converter.hsvToRgb(
-    getHue()/360.0,
-    // Default to fully saturated
-    (isSetSaturation() ? getSaturation() : 100)/100.0,
-    1,
-    rgb
-  );
-  applyColor(state, rgb[0], rgb[1], rgb[2]);
+  ParsedColor color = getColor();
+  applyColor(state, color.r, color.g, color.b);
 }
 
 void GroupState::applyColor(JsonObject state, uint8_t r, uint8_t g, uint8_t b) const {
-  JsonObject color = state.createNestedObject("color");
+  JsonObject color = state.createNestedObject(GroupStateFieldNames::COLOR);
   color["r"] = r;
   color["g"] = g;
   color["b"] = b;
@@ -806,7 +812,7 @@ void GroupState::applyOhColor(JsonObject state) const {
   );
   char ohColorStr[13];
   sprintf(ohColorStr, "%d,%d,%d", rgb[0], rgb[1], rgb[2]);
-  state["color"] = ohColorStr;
+  state[GroupStateFieldNames::COLOR] = ohColorStr;
 }
 
 // gather partial state for a single field; see GroupState::applyState to gather many fields
@@ -819,15 +825,15 @@ void GroupState::applyField(JsonObject partialState, const BulbId& bulbId, Group
         break;
 
       case GroupStateField::BRIGHTNESS:
-        partialState["brightness"] = Units::rescale(getBrightness(), 255, 100);
+        partialState[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(getBrightness(), 255, 100);
         break;
 
       case GroupStateField::LEVEL:
-        partialState["level"] = getBrightness();
+        partialState[GroupStateFieldNames::LEVEL] = getBrightness();
         break;
 
       case GroupStateField::BULB_MODE:
-        partialState["bulb_mode"] = BULB_MODE_NAMES[getBulbMode()];
+        partialState[GroupStateFieldNames::BULB_MODE] = BULB_MODE_NAMES[getBulbMode()];
         break;
 
       case GroupStateField::COLOR:
@@ -852,57 +858,57 @@ void GroupState::applyField(JsonObject partialState, const BulbId& bulbId, Group
 
       case GroupStateField::HUE:
         if (getBulbMode() == BULB_MODE_COLOR) {
-          partialState["hue"] = getHue();
+          partialState[GroupStateFieldNames::HUE] = getHue();
         }
         break;
 
       case GroupStateField::SATURATION:
         if (getBulbMode() == BULB_MODE_COLOR) {
-          partialState["saturation"] = getSaturation();
+          partialState[GroupStateFieldNames::SATURATION] = getSaturation();
         }
         break;
 
       case GroupStateField::MODE:
         if (getBulbMode() == BULB_MODE_SCENE) {
-          partialState["mode"] = getMode();
+          partialState[GroupStateFieldNames::MODE] = getMode();
         }
         break;
 
       case GroupStateField::EFFECT:
         if (getBulbMode() == BULB_MODE_SCENE) {
-          partialState["effect"] = String(getMode());
+          partialState[GroupStateFieldNames::EFFECT] = String(getMode());
         } else if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) {
-          partialState["effect"] = "white_mode";
+          partialState[GroupStateFieldNames::EFFECT] = "white_mode";
         } else if (getBulbMode() == BULB_MODE_NIGHT) {
-          partialState["effect"] = "night_mode";
+          partialState[GroupStateFieldNames::EFFECT] = MiLightCommandNames::NIGHT_MODE;
         }
         break;
 
       case GroupStateField::COLOR_TEMP:
         if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) {
-          partialState["color_temp"] = getMireds();
+          partialState[GroupStateFieldNames::COLOR_TEMP] = getMireds();
         }
         break;
 
       case GroupStateField::KELVIN:
         if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) {
-          partialState["kelvin"] = getKelvin();
+          partialState[GroupStateFieldNames::KELVIN] = getKelvin();
         }
         break;
 
       case GroupStateField::DEVICE_ID:
-        partialState["device_id"] = bulbId.deviceId;
+        partialState[GroupStateFieldNames::DEVICE_ID] = bulbId.deviceId;
         break;
 
       case GroupStateField::GROUP_ID:
-        partialState["group_id"] = bulbId.groupId;
+        partialState[GroupStateFieldNames::GROUP_ID] = bulbId.groupId;
         break;
 
       case GroupStateField::DEVICE_TYPE:
         {
           const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(bulbId.deviceType);
           if (remoteConfig) {
-            partialState["device_type"] = remoteConfig->name;
+            partialState[GroupStateFieldNames::DEVICE_TYPE] = remoteConfig->name;
           }
         }
         break;
@@ -947,6 +953,34 @@ void GroupState::debugState(char const *debugMessage) const {
 #endif
 }
 
+bool GroupState::isSetColor() const {
+  return isSetHue();
+}
+
+ParsedColor GroupState::getColor() const {
+  uint8_t rgb[3];
+  RGBConverter converter;
+  uint16_t hue = getHue();
+  uint8_t sat = isSetSaturation() ? getSaturation() : 100;
+
+  converter.hsvToRgb(
+    hue / 360.0,
+    // Default to fully saturated
+    sat / 100.0,
+    1,
+    rgb
+  );
+
+  return {
+    .success = true,
+    .hue = hue,
+    .r = rgb[0],
+    .g = rgb[1],
+    .b = rgb[2],
+    .saturation = sat
+  };
+}
+
 // build up a partial state representation based on the specified GrouipStateField array.  Used
 // to gather a subset of states (configurable in the UI) for sending to MQTT and web responses.
 void GroupState::applyState(JsonObject partialState, const BulbId& bulbId, std::vector<GroupStateField>& fields) const {
@@ -954,3 +988,12 @@ void GroupState::applyState(JsonObject partialState, const BulbId& bulbId, std::
     applyField(partialState, bulbId, *itr);
   }
 }
+
+bool GroupState::isPhysicalField(GroupStateField field) {
+  for (size_t i = 0; i < size(ALL_PHYSICAL_FIELDS); ++i) {
+    if (field == ALL_PHYSICAL_FIELDS[i]) {
+      return true;
+    }
+  }
+  return false;
+}

+ 10 - 0
lib/MiLightState/GroupState.h

@@ -6,6 +6,7 @@
 #include <GroupStateField.h>
 #include <ArduinoJson.h>
 #include <BulbId.h>
+#include <ParsedColor.h>
 
 #ifndef _GROUP_STATE_H
 #define _GROUP_STATE_H
@@ -27,6 +28,7 @@ enum class IncrementDirection : unsigned {
 
 class GroupState {
 public:
+  static const GroupStateField ALL_PHYSICAL_FIELDS[];
 
   GroupState();
   GroupState(const GroupState& other);
@@ -44,6 +46,7 @@ public:
 
   bool isSetField(GroupStateField field) const;
   uint16_t getFieldValue(GroupStateField field) const;
+  uint16_t getParsedFieldValue(GroupStateField field) const;
   void setFieldValue(GroupStateField field, uint16_t value);
   bool clearField(GroupStateField field);
 
@@ -139,12 +142,19 @@ public:
   // returns true if a (real, not scratch) state change was made
   bool applyIncrementCommand(GroupStateField field, IncrementDirection dir);
 
+  // Helpers that convert raw state values
+
+  // Return true if hue is set.  If saturation is not set, will assume 100.
+  bool isSetColor() const;
+  ParsedColor getColor() const;
+
   void load(Stream& stream);
   void dump(Stream& stream) const;
 
   void debugState(char const *debugMessage) const;
 
   static const GroupState& defaultState(MiLightRemoteType remoteType);
+  static bool isPhysicalField(GroupStateField field);
 
 private:
   static const size_t DATA_LONGS = 2;

+ 9 - 0
lib/MiLightState/GroupStateCache.cpp

@@ -4,6 +4,15 @@ GroupStateCache::GroupStateCache(const size_t maxSize)
   : maxSize(maxSize)
 { }
 
+GroupStateCache::~GroupStateCache() {
+  ListNode<GroupCacheNode*>* cur = cache.getHead();
+
+  while (cur != NULL) {
+    delete cur->data;
+    cur = cur->next;
+  }
+}
+
 GroupState* GroupStateCache::get(const BulbId& id) {
   return getInternal(id);
 }

+ 1 - 0
lib/MiLightState/GroupStateCache.h

@@ -16,6 +16,7 @@ struct GroupCacheNode {
 class GroupStateCache {
 public:
   GroupStateCache(const size_t maxSize);
+  ~GroupStateCache();
 
   GroupState* get(const BulbId& id);
   GroupState* set(const BulbId& id, const GroupState& state);

+ 2 - 2
lib/MiLightState/GroupStateStore.h

@@ -11,9 +11,9 @@ public:
 
   /*
    * Returns the state for the given BulbId.  If accessing state for a valid device
-   * (i.e., NOT group 0) and no state exists, its state will be initialized with a 
+   * (i.e., NOT group 0) and no state exists, its state will be initialized with a
    * default.
-   * 
+   *
    * Otherwise, we return NULL.
    */
   GroupState* get(const BulbId& id);

+ 152 - 0
lib/Transitions/ColorTransition.cpp

@@ -0,0 +1,152 @@
+#include <ColorTransition.h>
+#include <Arduino.h>
+
+ColorTransition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback, const ParsedColor& start, const ParsedColor& end)
+  : Transition::Builder(id, bulbId, callback)
+  , start(start)
+  , end(end)
+{ }
+
+std::shared_ptr<Transition> ColorTransition::Builder::_build() const {
+  size_t duration = getOrComputeDuration();
+  size_t numPeriods = getOrComputeNumPeriods();
+  size_t period = getOrComputePeriod();
+
+  int16_t dr = end.r - start.r
+        , dg = end.g - start.g
+        , db = end.b - start.b;
+
+  RgbColor stepSizes(
+    calculateStepSizePart(dr, duration, period),
+    calculateStepSizePart(dg, duration, period),
+    calculateStepSizePart(db, duration, period)
+  );
+
+  return std::make_shared<ColorTransition>(
+    id,
+    bulbId,
+    start,
+    end,
+    stepSizes,
+    duration,
+    period,
+    numPeriods,
+    callback
+  );
+}
+
+ColorTransition::RgbColor::RgbColor()
+  : r(0)
+  , g(0)
+  , b(0)
+{ }
+
+ColorTransition::RgbColor::RgbColor(const ParsedColor& color)
+  : r(color.r)
+  , g(color.g)
+  , b(color.b)
+{ }
+
+ColorTransition::RgbColor::RgbColor(int16_t r, int16_t g, int16_t b)
+  : r(r)
+  , g(g)
+  , b(b)
+{ }
+
+bool ColorTransition::RgbColor::operator==(const RgbColor& other) {
+  return r == other.r && g == other.g && b == other.b;
+}
+
+ColorTransition::ColorTransition(
+  size_t id,
+  const BulbId& bulbId,
+  const ParsedColor& startColor,
+  const ParsedColor& endColor,
+  RgbColor stepSizes,
+  size_t duration,
+  size_t period,
+  size_t numPeriods,
+  TransitionFn callback
+) : Transition(id, bulbId, period, callback)
+  , endColor(endColor)
+  , currentColor(startColor)
+  , stepSizes(stepSizes)
+  , lastHue(400)         // use impossible values to force a packet send
+  , lastSaturation(200)
+  , finished(false)
+{
+  int16_t dr = endColor.r - startColor.r
+        , dg = endColor.g - startColor.g
+        , db = endColor.b - startColor.b;
+  // Calculate step sizes in terms of the period
+  stepSizes.r = calculateStepSizePart(dr, duration, period);
+  stepSizes.g = calculateStepSizePart(dg, duration, period);
+  stepSizes.b = calculateStepSizePart(db, duration, period);
+}
+
+size_t ColorTransition::calculateColorPeriod(ColorTransition* t, const ParsedColor& start, const ParsedColor& end, size_t stepSize, size_t duration) {
+  int16_t dr = end.r - start.r
+        , dg = end.g - start.g
+        , db = end.b - start.b;
+
+  int16_t max = std::max(std::max(dr, dg), db);
+  int16_t min = std::min(std::min(dr, dg), db);
+  int16_t maxAbs = std::abs(min) > std::abs(max) ? min : max;
+
+  return Transition::calculatePeriod(maxAbs, stepSize, duration);
+}
+
+int16_t ColorTransition::calculateStepSizePart(int16_t distance, size_t duration, size_t period) {
+  double stepSize = (distance / static_cast<double>(duration)) * period;
+  int16_t rounded = std::ceil(std::abs(stepSize));
+
+  if (distance < 0) {
+    rounded = -rounded;
+  }
+
+  return rounded;
+}
+
+void ColorTransition::step() {
+  ParsedColor parsedColor = ParsedColor::fromRgb(currentColor.r, currentColor.g, currentColor.b);
+
+  if (parsedColor.hue != lastHue) {
+    callback(bulbId, GroupStateField::HUE, parsedColor.hue);
+    lastHue = parsedColor.hue;
+  }
+  if (parsedColor.saturation != lastSaturation) {
+    callback(bulbId, GroupStateField::SATURATION, parsedColor.saturation);
+    lastSaturation = parsedColor.saturation;
+  }
+
+  if (currentColor == endColor) {
+    finished = true;
+  } else {
+    Transition::stepValue(currentColor.r, endColor.r, stepSizes.r);
+    Transition::stepValue(currentColor.g, endColor.g, stepSizes.g);
+    Transition::stepValue(currentColor.b, endColor.b, stepSizes.b);
+  }
+}
+
+bool ColorTransition::isFinished() {
+  return finished;
+}
+
+void ColorTransition::childSerialize(JsonObject& json) {
+  json[F("type")] = F("color");
+
+  JsonArray currentColorArr = json.createNestedArray(F("current_color"));
+  currentColorArr.add(currentColor.r);
+  currentColorArr.add(currentColor.g);
+  currentColorArr.add(currentColor.b);
+
+  JsonArray endColorArr = json.createNestedArray(F("end_color"));
+  endColorArr.add(endColor.r);
+  endColorArr.add(endColor.g);
+  endColorArr.add(endColor.b);
+
+  JsonArray stepSizesArr = json.createNestedArray(F("step_sizes"));
+  stepSizesArr.add(stepSizes.r);
+  stepSizesArr.add(stepSizes.g);
+  stepSizesArr.add(stepSizes.b);
+}

+ 58 - 0
lib/Transitions/ColorTransition.h

@@ -0,0 +1,58 @@
+#include <Transition.h>
+#include <ParsedColor.h>
+
+#pragma once
+
+class ColorTransition : public Transition {
+public:
+  struct RgbColor {
+    RgbColor();
+    RgbColor(const ParsedColor& color);
+    RgbColor(int16_t r, int16_t g, int16_t b);
+    bool operator==(const RgbColor& other);
+
+    int16_t r, g, b;
+  };
+
+  class Builder : public Transition::Builder {
+  public:
+    Builder(size_t id, const BulbId& bulbId, TransitionFn callback, const ParsedColor& start, const ParsedColor& end);
+
+    virtual std::shared_ptr<Transition> _build() const override;
+
+  private:
+    const ParsedColor& start;
+    const ParsedColor& end;
+    RgbColor stepSizes;
+  };
+
+  ColorTransition(
+    size_t id,
+    const BulbId& bulbId,
+    const ParsedColor& startColor,
+    const ParsedColor& endColor,
+    RgbColor stepSizes,
+    size_t duration,
+    size_t period,
+    size_t numPeriods,
+    TransitionFn callback
+  );
+
+  static size_t calculateColorPeriod(ColorTransition* t, const ParsedColor& start, const ParsedColor& end, size_t stepSize, size_t duration);
+  inline static int16_t calculateStepSizePart(int16_t distance, size_t duration, size_t period);
+  virtual bool isFinished() override;
+
+protected:
+  const RgbColor endColor;
+  RgbColor currentColor;
+  RgbColor stepSizes;
+
+  // Store these to avoid wasted packets
+  uint16_t lastHue;
+  uint16_t lastSaturation;
+  bool finished;
+
+  virtual void step() override;
+  virtual void childSerialize(JsonObject& json) override;
+  static inline void stepPart(uint16_t& current, uint16_t end, int16_t step);
+};

+ 76 - 0
lib/Transitions/FieldTransition.cpp

@@ -0,0 +1,76 @@
+#include <FieldTransition.h>
+#include <cmath>
+#include <algorithm>
+
+FieldTransition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end)
+  : Transition::Builder(id, bulbId, callback)
+  , stepSize(0)
+  , field(field)
+  , start(start)
+  , end(end)
+{ }
+
+std::shared_ptr<Transition> FieldTransition::Builder::_build() const {
+  size_t duration = getOrComputeDuration();
+  size_t numPeriods = getOrComputeNumPeriods();
+  size_t period = getOrComputePeriod();
+  int16_t distance = end - start;
+  int16_t stepSize = ceil(std::abs(distance / static_cast<float>(numPeriods)));
+
+  if (end < start) {
+    stepSize = -stepSize;
+  }
+  if (stepSize == 0) {
+    stepSize = end > start ? 1 : -1;
+  }
+
+  return std::make_shared<FieldTransition>(
+    id,
+    bulbId,
+    field,
+    start,
+    end,
+    stepSize,
+    period,
+    callback
+  );
+}
+
+FieldTransition::FieldTransition(
+  size_t id,
+  const BulbId& bulbId,
+  GroupStateField field,
+  uint16_t startValue,
+  uint16_t endValue,
+  int16_t stepSize,
+  size_t period,
+  TransitionFn callback
+) : Transition(id, bulbId, period, callback)
+  , field(field)
+  , currentValue(startValue)
+  , endValue(endValue)
+  , stepSize(stepSize)
+  , finished(false)
+{ }
+
+void FieldTransition::step() {
+  callback(bulbId, field, currentValue);
+
+  if (currentValue != endValue) {
+    Transition::stepValue(currentValue, endValue, stepSize);
+  } else {
+    finished = true;
+  }
+}
+
+bool FieldTransition::isFinished() {
+  return finished;
+}
+
+void FieldTransition::childSerialize(JsonObject& json) {
+  json[F("type")] = F("field");
+  json[F("field")] = GroupStateFieldHelpers::getFieldName(field);
+  json[F("current_value")] = currentValue;
+  json[F("end_value")] = endValue;
+  json[F("step_size")] = stepSize;
+}

+ 48 - 0
lib/Transitions/FieldTransition.h

@@ -0,0 +1,48 @@
+#include <GroupStateField.h>
+#include <stdint.h>
+#include <stddef.h>
+#include <Arduino.h>
+#include <functional>
+#include <Transition.h>
+
+#pragma once
+
+class FieldTransition : public Transition {
+public:
+
+  class Builder : public Transition::Builder {
+  public:
+    Builder(size_t id, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end);
+
+    virtual std::shared_ptr<Transition> _build() const override;
+
+  private:
+    size_t stepSize;
+    GroupStateField field;
+    uint16_t start;
+    uint16_t end;
+  };
+
+  FieldTransition(
+    size_t id,
+    const BulbId& bulbId,
+    GroupStateField field,
+    uint16_t startValue,
+    uint16_t endValue,
+    int16_t stepSize,
+    size_t period,
+    TransitionFn callback
+  );
+
+  virtual bool isFinished() override;
+
+private:
+  const GroupStateField field;
+  int16_t currentValue;
+  const int16_t endValue;
+  const int16_t stepSize;
+  bool finished;
+
+  virtual void step() override;
+  virtual void childSerialize(JsonObject& json) override;
+};

+ 149 - 0
lib/Transitions/Transition.cpp

@@ -0,0 +1,149 @@
+#include <Transition.h>
+#include <Arduino.h>
+#include <cmath>
+
+Transition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback)
+  : id(id)
+  , bulbId(bulbId)
+  , callback(callback)
+  , duration(0)
+  , period(0)
+  , numPeriods(0)
+{ }
+
+Transition::Builder& Transition::Builder::setDuration(float duration) {
+  this->duration = duration * DURATION_UNIT_MULTIPLIER;
+  return *this;
+}
+
+Transition::Builder& Transition::Builder::setPeriod(size_t period) {
+  this->period = period;
+  return *this;
+}
+
+Transition::Builder& Transition::Builder::setNumPeriods(size_t numPeriods) {
+  this->numPeriods = numPeriods;
+  return *this;
+}
+
+bool Transition::Builder::isSetDuration() const {
+  return this->duration > 0;
+}
+
+bool Transition::Builder::isSetPeriod() const {
+  return this->period > 0;
+}
+
+bool Transition::Builder::isSetNumPeriods() const {
+  return this->numPeriods > 0;
+}
+
+size_t Transition::Builder::numSetParams() const {
+  size_t setCount = 0;
+
+  if (isSetDuration()) { ++setCount; }
+  if (isSetPeriod()) { ++setCount; }
+  if (isSetNumPeriods()) { ++setCount; }
+
+  return setCount;
+}
+
+size_t Transition::Builder::getOrComputePeriod() const {
+  if (period > 0) {
+    return period;
+  } else if (duration > 0 && numPeriods > 0) {
+    return floor(duration / static_cast<float>(numPeriods));
+  } else {
+    return 0;
+  }
+}
+
+size_t Transition::Builder::getOrComputeDuration() const {
+  if (duration > 0) {
+    return duration;
+  } else if (period > 0 && numPeriods > 0) {
+    return period * numPeriods;
+  } else {
+    return 0;
+  }
+}
+
+size_t Transition::Builder::getOrComputeNumPeriods() const {
+  if (numPeriods > 0) {
+    return numPeriods;
+  } else if (period > 0 && duration > 0) {
+    return ceil(duration / static_cast<float>(period));
+  } else {
+    return 0;
+  }
+}
+
+std::shared_ptr<Transition> Transition::Builder::build() {
+  // Set defaults for underspecified transitions
+  size_t numSet = numSetParams();
+
+  if (numSet == 0) {
+    setPeriod(DEFAULT_PERIOD);
+    setNumPeriods(DEFAULT_NUM_PERIODS);
+  } else if (numSet == 1) {
+    if (isSetDuration() || isSetNumPeriods()) {
+      setPeriod(DEFAULT_PERIOD);
+    } else if (isSetPeriod()) {
+      setNumPeriods(DEFAULT_NUM_PERIODS);
+    }
+  }
+
+  return _build();
+}
+
+Transition::Transition(
+  size_t id,
+  const BulbId& bulbId,
+  size_t period,
+  TransitionFn callback
+) : id(id)
+  , bulbId(bulbId)
+  , period(period)
+  , callback(callback)
+  , lastSent(0)
+{ }
+
+void Transition::tick() {
+  unsigned long now = millis();
+
+  if ((lastSent + period) <= now
+    && ((!isFinished() || lastSent == 0))) { // always send at least once
+
+    step();
+    lastSent = now;
+  }
+}
+
+size_t Transition::calculatePeriod(int16_t distance, size_t stepSize, size_t duration) {
+  float fPeriod =
+    distance != 0
+      ? (duration / (distance / static_cast<float>(stepSize)))
+      : 0;
+
+  return static_cast<size_t>(round(fPeriod));
+}
+
+void Transition::stepValue(int16_t& current, int16_t end, int16_t stepSize) {
+  int16_t delta = end - current;
+  if (std::abs(delta) < std::abs(stepSize)) {
+    current += delta;
+  } else {
+    current += stepSize;
+  }
+}
+
+void Transition::serialize(JsonObject& json) {
+  json[F("id")] = id;
+  json[F("period")] = period;
+  json[F("last_sent")] = lastSent;
+
+  JsonObject bulbParams = json.createNestedObject("bulb");
+  bulbId.serialize(bulbParams);
+
+  childSerialize(json);
+}

+ 81 - 0
lib/Transitions/Transition.h

@@ -0,0 +1,81 @@
+#include <BulbId.h>
+#include <ArduinoJson.h>
+#include <GroupStateField.h>
+#include <stdint.h>
+#include <stddef.h>
+#include <functional>
+#include <memory>
+
+#pragma once
+
+class Transition {
+public:
+  using TransitionFn = std::function<void(const BulbId& bulbId, GroupStateField field, uint16_t value)>;
+
+  // transition commands are in seconds, convert to ms.
+  static const uint16_t DURATION_UNIT_MULTIPLIER = 1000;
+
+
+  class Builder {
+  public:
+    Builder(size_t id, const BulbId& bulbId, TransitionFn callback);
+
+    Builder& setDuration(float duration);
+    Builder& setPeriod(size_t period);
+    Builder& setNumPeriods(size_t numPeriods);
+
+    bool isSetDuration() const;
+    bool isSetPeriod() const;
+    bool isSetNumPeriods() const;
+
+    size_t getOrComputePeriod() const;
+    size_t getOrComputeDuration() const;
+    size_t getOrComputeNumPeriods() const;
+
+    std::shared_ptr<Transition> build();
+
+  protected:
+    size_t id;
+    const BulbId& bulbId;
+    TransitionFn callback;
+
+  private:
+    size_t duration;
+    size_t period;
+    size_t numPeriods;
+
+    virtual std::shared_ptr<Transition> _build() const = 0;
+    size_t numSetParams() const;
+  };
+
+  // Default time to wait between steps.  Do this rather than having a fixed step size because it's
+  // more capable of adapting to different situations.
+  static const size_t DEFAULT_PERIOD = 300;
+  static const size_t DEFAULT_NUM_PERIODS = 20; // works out to a duration of 6s
+  static const size_t DEFAULT_DURATION = 6000;
+
+  const size_t id;
+  const BulbId bulbId;
+
+  Transition(
+    size_t id,
+    const BulbId& bulbId,
+    size_t period,
+    TransitionFn callback
+  );
+
+  void tick();
+  virtual bool isFinished() = 0;
+  void serialize(JsonObject& doc);
+
+  static size_t calculatePeriod(int16_t distance, size_t stepSize, size_t duration);
+
+protected:
+  const size_t period;
+  const TransitionFn callback;
+  unsigned long lastSent;
+
+  virtual void step() = 0;
+  virtual void childSerialize(JsonObject& doc) = 0;
+  static void stepValue(int16_t& current, int16_t end, int16_t stepSize);
+};

+ 113 - 0
lib/Transitions/TransitionController.cpp

@@ -0,0 +1,113 @@
+#include <Transition.h>
+#include <FieldTransition.h>
+#include <ColorTransition.h>
+#include <GroupStateField.h>
+
+#include <TransitionController.h>
+#include <LinkedList.h>
+#include <functional>
+
+using namespace std::placeholders;
+
+TransitionController::TransitionController()
+  : callback(std::bind(&TransitionController::transitionCallback, this, _1, _2, _3))
+  , currentId(0)
+{ }
+
+void TransitionController::clearListeners() {
+  observers.clear();
+}
+
+void TransitionController::addListener(Transition::TransitionFn fn) {
+  observers.push_back(fn);
+}
+
+std::shared_ptr<Transition::Builder> TransitionController::buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end) {
+  return std::make_shared<ColorTransition::Builder>(
+    currentId++,
+    bulbId,
+    callback,
+    start,
+    end
+  );
+}
+
+std::shared_ptr<Transition::Builder> TransitionController::buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end) {
+  return std::make_shared<FieldTransition::Builder>(
+    currentId++,
+    bulbId,
+    callback,
+    field,
+    start,
+    end
+  );
+}
+
+void TransitionController::addTransition(std::shared_ptr<Transition> transition) {
+  activeTransitions.add(transition);
+}
+
+void TransitionController::transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg) {
+  for (auto it = observers.begin(); it != observers.end(); ++it) {
+    (*it)(bulbId, field, arg);
+  }
+}
+
+void TransitionController::clear() {
+  activeTransitions.clear();
+}
+
+void TransitionController::loop() {
+  auto current = activeTransitions.getHead();
+
+  while (current != nullptr) {
+    auto next = current->next;
+
+    Transition& t = *current->data;
+    t.tick();
+
+    if (t.isFinished()) {
+      activeTransitions.remove(current);
+    }
+
+    current = next;
+  }
+}
+
+ListNode<std::shared_ptr<Transition>>* TransitionController::getTransitions() {
+  return activeTransitions.getHead();
+}
+
+ListNode<std::shared_ptr<Transition>>* TransitionController::findTransition(size_t id) {
+  auto current = getTransitions();
+
+  while (current != nullptr) {
+    if (current->data->id == id) {
+      return current;
+    }
+    current = current->next;
+  }
+
+  return nullptr;
+}
+
+Transition* TransitionController::getTransition(size_t id) {
+  auto node = findTransition(id);
+
+  if (node == nullptr) {
+    return nullptr;
+  } else {
+    return node->data.get();
+  }
+}
+
+bool TransitionController::deleteTransition(size_t id) {
+  auto node = findTransition(id);
+
+  if (node == nullptr) {
+    return false;
+  } else {
+    activeTransitions.remove(node);
+    return true;
+  }
+}

+ 36 - 0
lib/Transitions/TransitionController.h

@@ -0,0 +1,36 @@
+#include <Transition.h>
+#include <LinkedList.h>
+#include <ParsedColor.h>
+#include <GroupStateField.h>
+#include <memory>
+#include <vector>
+
+#pragma once
+
+class TransitionController {
+public:
+  TransitionController();
+
+  void clearListeners();
+  void addListener(Transition::TransitionFn fn);
+
+  std::shared_ptr<Transition::Builder> buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end);
+  std::shared_ptr<Transition::Builder> buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end);
+
+  void addTransition(std::shared_ptr<Transition> transition);
+  void clear();
+  void loop();
+
+  ListNode<std::shared_ptr<Transition>>* getTransitions();
+  Transition* getTransition(size_t id);
+  ListNode<std::shared_ptr<Transition>>* findTransition(size_t id);
+  bool deleteTransition(size_t id);
+
+private:
+  Transition::TransitionFn callback;
+  LinkedList<std::shared_ptr<Transition>> activeTransitions;
+  std::vector<Transition::TransitionFn> observers;
+  size_t currentId;
+
+  void transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg);
+};

+ 7 - 0
lib/Types/BulbId.cpp

@@ -1,4 +1,5 @@
 #include <BulbId.h>
+#include <GroupStateField.h>
 
 BulbId::BulbId()
   : deviceId(0),
@@ -44,4 +45,10 @@ String BulbId::getHexDeviceId() const {
   char hexDeviceId[7];
   sprintf_P(hexDeviceId, PSTR("0x%X"), deviceId);
   return hexDeviceId;
+}
+
+void BulbId::serialize(JsonObject json) const {
+  json[GroupStateFieldNames::DEVICE_ID] = deviceId;
+  json[GroupStateFieldNames::GROUP_ID] = groupId;
+  json[GroupStateFieldNames::DEVICE_TYPE] = MiLightRemoteTypeHelpers::remoteTypeToString(deviceType);
 }

+ 2 - 0
lib/Types/BulbId.h

@@ -2,6 +2,7 @@
 
 #include <stdint.h>
 #include <MiLightRemoteType.h>
+#include <ArduinoJson.h>
 
 struct BulbId {
   uint16_t deviceId;
@@ -16,4 +17,5 @@ struct BulbId {
 
   uint32_t getCompactId() const;
   String getHexDeviceId() const;
+  void serialize(JsonObject json) const;
 };

+ 18 - 18
lib/Types/GroupStateField.cpp

@@ -2,24 +2,24 @@
 #include <Size.h>
 
 static const char* STATE_NAMES[] = {
-  "unknown",
-  "state",
-  "status",
-  "brightness",
-  "level",
-  "hue",
-  "saturation",
-  "color",
-  "mode",
-  "kelvin",
-  "color_temp",
-  "bulb_mode",
-  "computed_color",
-  "effect",
-  "device_id",
-  "group_id",
-  "device_type",
-  "oh_color"
+  GroupStateFieldNames::UNKNOWN,
+  GroupStateFieldNames::STATE,
+  GroupStateFieldNames::STATUS,
+  GroupStateFieldNames::BRIGHTNESS,
+  GroupStateFieldNames::LEVEL,
+  GroupStateFieldNames::HUE,
+  GroupStateFieldNames::SATURATION,
+  GroupStateFieldNames::COLOR,
+  GroupStateFieldNames::MODE,
+  GroupStateFieldNames::KELVIN,
+  GroupStateFieldNames::COLOR_TEMP,
+  GroupStateFieldNames::BULB_MODE,
+  GroupStateFieldNames::COMPUTED_COLOR,
+  GroupStateFieldNames::EFFECT,
+  GroupStateFieldNames::DEVICE_ID,
+  GroupStateFieldNames::GROUP_ID,
+  GroupStateFieldNames::DEVICE_TYPE,
+  GroupStateFieldNames::OH_COLOR
 };
 
 GroupStateField GroupStateFieldHelpers::getFieldByName(const char* name) {

+ 24 - 0
lib/Types/GroupStateField.h

@@ -1,6 +1,30 @@
 #ifndef _GROUP_STATE_FIELDS_H
 #define _GROUP_STATE_FIELDS_H
 
+namespace GroupStateFieldNames {
+  static const char UNKNOWN[] = "unknown";
+  static const char STATE[] = "state";
+  static const char STATUS[] = "status";
+  static const char BRIGHTNESS[] = "brightness";
+  static const char LEVEL[] = "level";
+  static const char HUE[] = "hue";
+  static const char SATURATION[] = "saturation";
+  static const char COLOR[] = "color";
+  static const char MODE[] = "mode";
+  static const char KELVIN[] = "kelvin";
+  static const char TEMPERATURE[] = "temperature"; //alias for kelvin
+  static const char COLOR_TEMP[] = "color_temp";
+  static const char BULB_MODE[] = "bulb_mode";
+  static const char COMPUTED_COLOR[] = "computed_color";
+  static const char EFFECT[] = "effect";
+  static const char DEVICE_ID[] = "device_id";
+  static const char GROUP_ID[] = "group_id";
+  static const char DEVICE_TYPE[] = "device_type";
+  static const char OH_COLOR[] = "oh_color";
+  static const char COMMAND[] = "command";
+  static const char COMMANDS[] = "commands";
+};
+
 enum class GroupStateField {
   UNKNOWN,
   STATE,

+ 18 - 0
lib/Types/MiLightCommands.h

@@ -0,0 +1,18 @@
+#pragma once
+
+namespace MiLightCommandNames {
+  static const char UNPAIR[] = "unpair";
+  static const char PAIR[] = "pair";
+  static const char SET_WHITE[] = "set_white";
+  static const char NIGHT_MODE[] = "night_mode";
+  static const char LEVEL_UP[] = "level_up";
+  static const char LEVEL_DOWN[] = "level_down";
+  static const char TEMPERATURE_UP[] = "temperature_up";
+  static const char TEMPERATURE_DOWN[] = "temperature_down";
+  static const char NEXT_MODE[] = "next_mode";
+  static const char PREVIOUS_MODE[] = "previous_mode";
+  static const char MODE_SPEED_DOWN[] = "mode_speed_down";
+  static const char MODE_SPEED_UP[] = "mode_speed_up";
+  static const char TOGGLE[] = "toggle";
+  static const char TRANSITION[] = "transition";
+};

+ 56 - 0
lib/Types/ParsedColor.cpp

@@ -0,0 +1,56 @@
+#include <ParsedColor.h>
+#include <RGBConverter.h>
+#include <TokenIterator.h>
+#include <GroupStateField.h>
+
+ParsedColor ParsedColor::fromRgb(uint16_t r, uint16_t g, uint16_t b) {
+  double hsv[3];
+  RGBConverter converter;
+  converter.rgbToHsv(r, g, b, hsv);
+
+  uint16_t hue = round(hsv[0]*360);
+  uint8_t saturation = round(hsv[1]*100);
+
+  return ParsedColor{
+    .success = true,
+    .hue = hue,
+    .r = r,
+    .g = g,
+    .b = b,
+    .saturation = saturation
+  };
+}
+
+ParsedColor ParsedColor::fromJson(JsonVariant json) {
+  uint16_t r, g, b;
+
+  if (json.is<JsonObject>()) {
+    JsonObject color = json.as<JsonObject>();
+
+    r = color["r"];
+    g = color["g"];
+    b = color["b"];
+  } else if (json.is<const char*>()) {
+    const char* colorStr = json.as<const char*>();
+    const size_t len = strlen(colorStr);
+
+    char colorCStr[len+1];
+    uint8_t parsedRgbColors[3] = {0, 0, 0};
+
+    strcpy(colorCStr, colorStr);
+    TokenIterator colorValueItr(colorCStr, len, ',');
+
+    for (size_t i = 0; i < 3 && colorValueItr.hasNext(); ++i) {
+      parsedRgbColors[i] = atoi(colorValueItr.nextToken());
+    }
+
+    r = parsedRgbColors[0];
+    g = parsedRgbColors[1];
+    b = parsedRgbColors[2];
+  } else {
+    Serial.println(F("GroupState::parseJsonColor - unknown format for color"));
+    return ParsedColor{ .success = false };
+  }
+
+  return ParsedColor::fromRgb(r, g, b);
+}

+ 13 - 0
lib/Types/ParsedColor.h

@@ -0,0 +1,13 @@
+#include <stdint.h>
+#include <ArduinoJson.h>
+
+#pragma once
+
+struct ParsedColor {
+  bool success;
+  uint16_t hue, r, g, b;
+  uint8_t saturation;
+
+  static ParsedColor fromRgb(uint16_t r, uint16_t g, uint16_t b);
+  static ParsedColor fromJson(JsonVariant json);
+};

+ 89 - 9
lib/WebServer/MiLightHttpServer.cpp

@@ -54,6 +54,16 @@ void MiLightHttpServer::begin() {
     .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetGroupAlias, this, _1));
 
   server
+    .buildHandler("/transitions/:id")
+    .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetTransition, this, _1))
+    .on(HTTP_DELETE, std::bind(&MiLightHttpServer::handleDeleteTransition, this, _1));
+
+  server
+    .buildHandler("/transitions")
+    .on(HTTP_GET, std::bind(&MiLightHttpServer::handleListTransitions, this, _1))
+    .on(HTTP_POST, std::bind(&MiLightHttpServer::handleCreateTransition, this, _1));
+
+  server
     .buildHandler("/raw_commands/:type")
     .on(HTTP_ANY, std::bind(&MiLightHttpServer::handleSendRaw, this, _1));
 
@@ -100,8 +110,8 @@ void MiLightHttpServer::handleSystemPost(RequestContext& request) {
 
   bool handled = false;
 
-  if (requestBody.containsKey("command")) {
-    if (requestBody["command"] == "restart") {
+  if (requestBody.containsKey(GroupStateFieldNames::COMMAND)) {
+    if (requestBody[GroupStateFieldNames::COMMAND] == "restart") {
       Serial.println(F("Restarting..."));
       server.send_P(200, TEXT_PLAIN, PSTR("true"));
 
@@ -110,7 +120,7 @@ void MiLightHttpServer::handleSystemPost(RequestContext& request) {
       ESP.restart();
 
       handled = true;
-    } else if (requestBody["command"] == "clear_wifi_config") {
+    } else if (requestBody[GroupStateFieldNames::COMMAND] == "clear_wifi_config") {
         Serial.println(F("Resetting Wifi and then Restarting..."));
         server.send_P(200, TEXT_PLAIN, PSTR("true"));
 
@@ -357,8 +367,8 @@ void MiLightHttpServer::handleGetGroupAlias(RequestContext& request) {
 }
 
 void MiLightHttpServer::handleGetGroup(RequestContext& request) {
-  const String _deviceId = request.pathVariables.get("device_id");
-  uint8_t _groupId = atoi(request.pathVariables.get("group_id"));
+  const String _deviceId = request.pathVariables.get(GroupStateFieldNames::DEVICE_ID);
+  uint8_t _groupId = atoi(request.pathVariables.get(GroupStateFieldNames::GROUP_ID));
   const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(request.pathVariables.get("type"));
 
   if (_remoteType == NULL) {
@@ -374,8 +384,8 @@ void MiLightHttpServer::handleGetGroup(RequestContext& request) {
 }
 
 void MiLightHttpServer::handleDeleteGroup(RequestContext& request) {
-  const String _deviceId = request.pathVariables.get("device_id");
-  uint8_t _groupId = atoi(request.pathVariables.get("group_id"));
+  const String _deviceId = request.pathVariables.get(GroupStateFieldNames::DEVICE_ID);
+  uint8_t _groupId = atoi(request.pathVariables.get(GroupStateFieldNames::GROUP_ID));
   const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(request.pathVariables.get("type"));
 
   if (_remoteType == NULL) {
@@ -444,8 +454,8 @@ void MiLightHttpServer::handleUpdateGroupAlias(RequestContext& request) {
 void MiLightHttpServer::handleUpdateGroup(RequestContext& request) {
   JsonObject reqObj = request.getJsonBody().as<JsonObject>();
 
-  String _deviceIds = request.pathVariables.get("device_id");
-  String _groupIds = request.pathVariables.get("group_id");
+  String _deviceIds = request.pathVariables.get(GroupStateFieldNames::DEVICE_ID);
+  String _groupIds = request.pathVariables.get(GroupStateFieldNames::GROUP_ID);
   String _remoteTypes = request.pathVariables.get("type");
   char deviceIds[_deviceIds.length()];
   char groupIds[_groupIds.length()];
@@ -583,3 +593,73 @@ void MiLightHttpServer::handleServe_P(const char* data, size_t length) {
   server.client().stop();
 }
 
+void MiLightHttpServer::handleGetTransition(RequestContext& request) {
+  size_t id = atoi(request.pathVariables.get("id"));
+  auto transition = transitions.getTransition(id);
+
+  if (transition == nullptr) {
+    request.response.setCode(404);
+    request.response.json["error"] = "Not found";
+  } else {
+    JsonObject response = request.response.json.to<JsonObject>();
+    transition->serialize(response);
+  }
+}
+
+void MiLightHttpServer::handleDeleteTransition(RequestContext& request) {
+  size_t id = atoi(request.pathVariables.get("id"));
+  bool success = transitions.deleteTransition(id);
+
+  if (success) {
+    request.response.json["success"] = true;
+  } else {
+    request.response.setCode(404);
+    request.response.json["error"] = "Not found";
+  }
+}
+
+void MiLightHttpServer::handleListTransitions(RequestContext& request) {
+  auto current = transitions.getTransitions();
+  JsonArray transitions = request.response.json.to<JsonObject>().createNestedArray(F("transitions"));
+
+  while (current != nullptr) {
+    JsonObject json = transitions.createNestedObject();
+    current->data->serialize(json);
+    current = current->next;
+  }
+}
+
+void MiLightHttpServer::handleCreateTransition(RequestContext& request) {
+  JsonObject body = request.getJsonBody().as<JsonObject>();
+
+  if (! body.containsKey(GroupStateFieldNames::DEVICE_ID)
+    || ! body.containsKey(GroupStateFieldNames::GROUP_ID)
+    || ! body.containsKey(F("remote_type"))) {
+    char buffer[200];
+    sprintf_P(buffer, PSTR("Must specify required keys: device_id, group_id, remote_type"));
+
+    request.response.setCode(400);
+    request.response.json[F("error")] = buffer;
+    return;
+  }
+
+  const String _deviceId = body[GroupStateFieldNames::DEVICE_ID];
+  uint8_t _groupId = body[GroupStateFieldNames::GROUP_ID];
+  const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(body[F("remote_type")].as<const char*>());
+
+  if (_remoteType == nullptr) {
+    char buffer[40];
+    sprintf_P(buffer, PSTR("Unknown device type\n"));
+    request.response.setCode(400);
+    request.response.json[F("error")] = buffer;
+    return;
+  }
+
+  milightClient->prepare(_remoteType, parseInt<uint16_t>(_deviceId), _groupId);
+
+  if (milightClient->handleTransition(request.getJsonBody().as<JsonObject>(), request.response.json)) {
+    request.response.json[F("success")] = true;
+  } else {
+    request.response.setCode(400);
+  }
+}

+ 10 - 1
lib/WebServer/MiLightHttpServer.h

@@ -5,6 +5,7 @@
 #include <GroupStateStore.h>
 #include <RadioSwitchboard.h>
 #include <PacketSender.h>
+#include <TransitionController.h>
 
 #ifndef _MILIGHT_HTTP_SERVER
 #define _MILIGHT_HTTP_SERVER
@@ -27,7 +28,8 @@ public:
     MiLightClient*& milightClient,
     GroupStateStore*& stateStore,
     PacketSender*& packetSender,
-    RadioSwitchboard*& radios
+    RadioSwitchboard*& radios,
+    TransitionController& transitions
   )
     : authProvider(settings)
     , server(80, authProvider)
@@ -38,6 +40,7 @@ public:
     , stateStore(stateStore)
     , packetSender(packetSender)
     , radios(radios)
+    , transitions(transitions)
   { }
 
   void begin();
@@ -79,6 +82,11 @@ protected:
   void handleDeleteGroupAlias(RequestContext& request);
   void _handleDeleteGroup(BulbId bulbId, RequestContext& request);
 
+  void handleGetTransition(RequestContext& request);
+  void handleDeleteTransition(RequestContext& request);
+  void handleCreateTransition(RequestContext& request);
+  void handleListTransitions(RequestContext& request);
+
   void handleRequest(const JsonObject& request);
   void handleWsEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length);
 
@@ -96,6 +104,7 @@ protected:
   ESP8266WebServer::THandlerFunction _handleRootPage;
   PacketSender*& packetSender;
   RadioSwitchboard*& radios;
+  TransitionController& transitions;
 
 };
 

+ 19 - 2
src/main.cpp

@@ -26,6 +26,7 @@
 #include <RadioSwitchboard.h>
 #include <PacketSender.h>
 #include <HomeAssistantDiscoveryClient.h>
+#include <TransitionController.h>
 
 #include <vector>
 #include <memory>
@@ -52,6 +53,7 @@ uint8_t currentRadioType = 0;
 // For tracking and managing group state
 GroupStateStore* stateStore = NULL;
 BulbStateUpdater* bulbStateUpdater = NULL;
+TransitionController transitions;
 
 int numUdpServers = 0;
 std::vector<std::shared_ptr<MiLightUdpServer>> udpServers;
@@ -232,7 +234,8 @@ void applySettings() {
     *radios,
     *packetSender,
     stateStore,
-    settings
+    settings,
+    transitions
   );
   milightClient->onUpdateBegin(onUpdateBegin);
   milightClient->onUpdateEnd(onUpdateEnd);
@@ -416,12 +419,24 @@ void setup() {
   SSDP.setDeviceType("upnp:rootdevice");
   SSDP.begin();
 
-  httpServer = new MiLightHttpServer(settings, milightClient, stateStore, packetSender, radios);
+  httpServer = new MiLightHttpServer(settings, milightClient, stateStore, packetSender, radios, transitions);
   httpServer->onSettingsSaved(applySettings);
   httpServer->onGroupDeleted(onGroupDeleted);
   httpServer->on("/description.xml", HTTP_GET, []() { SSDP.schema(httpServer->client()); });
   httpServer->begin();
 
+  transitions.addListener(
+    [](const BulbId& bulbId, GroupStateField field, uint16_t value) {
+      StaticJsonDocument<100> buffer;
+
+      const char* fieldName = GroupStateFieldHelpers::getFieldName(field);
+      buffer[fieldName] = value;
+
+      milightClient->prepare(bulbId.deviceType, bulbId.deviceId, bulbId.groupId);
+      milightClient->update(buffer.as<JsonObject>());
+    }
+  );
+
   Serial.printf_P(PSTR("Setup complete (version %s)\n"), QUOTE(MILIGHT_HUB_VERSION));
 }
 
@@ -449,6 +464,8 @@ void loop() {
   // update LED with status
   ledStatus->handle();
 
+  transitions.loop();
+
   if (shouldRestart()) {
     Serial.println(F("Auto-restart triggered. Restarting..."));
     ESP.restart();

+ 1 - 1
test/remote/helpers/mqtt_helpers.rb

@@ -25,7 +25,7 @@ module MqttHelpers
       .merge(overrides)
 
     MqttClient.new(
-      params[:mqtt_server],
+      ENV['ESPMH_LOCAL_MQTT_SERVER'] || params[:mqtt_server],
       params[:mqtt_username],
       params[:mqtt_password],
       params[:topic_prefix]

+ 28 - 1
test/remote/lib/api_client.rb

@@ -9,6 +9,13 @@ class ApiClient
     @current_id = Integer(base_id)
   end
 
+  def self.from_environment
+    ApiClient.new(
+      ENV.fetch('ESPMH_HOSTNAME'),
+      ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')
+    )
+  end
+
   def generate_id
     id = @current_id
     @current_id += 1
@@ -46,7 +53,13 @@ class ApiClient
       end
 
       res = http.request(req)
-      res.value
+
+      begin
+        res.value
+      rescue Exception => e
+        puts "REST Client Error: #{e}\nBody:\n#{res.body}"
+        raise e
+      end
 
       body = res.body
 
@@ -97,4 +110,18 @@ class ApiClient
   def patch_state(state, params = {})
     put(state_path(params), state.to_json)
   end
+
+  def schedule_transition(_id_params, transition_params)
+    id_params = {
+      device_id: _id_params[:id],
+      remote_type: _id_params[:type],
+      group_id: _id_params[:group_id]
+    }
+
+    post("/transitions", id_params.merge(transition_params))
+  end
+
+  def transitions
+    get('/transitions')['transitions']
+  end
 end

+ 2 - 2
test/remote/spec/mqtt_spec.rb

@@ -28,7 +28,7 @@ RSpec.describe 'MQTT' do
 
   context 'deleting' do
     it 'should remove retained state' do
-      @client.patch_state(@id_params, status: 'ON')
+      @client.patch_state({status: 'ON'}, @id_params)
 
       seen_blank = false
 
@@ -45,7 +45,7 @@ RSpec.describe 'MQTT' do
 
   context 'client status topic' do
     before(:all) do
-      @status_topic = "#{@topic_prefix}client_status"
+      @status_topic = "#{mqtt_topic_prefix()}client_status"
       @client.patch_settings(mqtt_client_status_topic: @status_topic)
     end
 

+ 3 - 3
test/remote/spec/settings_spec.rb

@@ -29,11 +29,11 @@ RSpec.describe 'Settings' do
         'simple_mqtt_client_status' => [true, false],
         'packet_repeats_per_loop' => [10],
         'home_assistant_discovery_prefix' => ['', 'abc', 'a/b/c'],
-        'wifi_force_b_mode' => [true, false]
+        'wifi_mode' => %w(b g n)
       }.each do |key, values|
         values.each do |v|
           @client.patch_settings({key => v})
-          expect(@client.get('/settings')[key]).to eq(v)
+          expect(@client.get('/settings')[key]).to eq(v), "Should persist #{key} possible value: #{v}"
         end
       end
     end
@@ -97,7 +97,7 @@ RSpec.describe 'Settings' do
 
       end_mem = @client.get('/about')['free_heap']
 
-      expect(end_mem - start_mem).to_not be < -200
+      expect(end_mem).to be > (start_mem - 200)
     end
   end
 

+ 378 - 0
test/remote/spec/transition_spec.rb

@@ -0,0 +1,378 @@
+require 'api_client'
+
+RSpec.describe 'Transitions' do
+  before(:all) do
+    @client = ApiClient.from_environment
+    @client.upload_json('/settings', 'settings.json')
+    @transition_params = {
+      field: 'level',
+      start_value: 0,
+      end_value: 100,
+      duration: 2.0,
+      period: 400
+    }
+    @num_transition_updates = (@transition_params[:duration]*1000)/@transition_params[:period]
+  end
+
+  before(:each) do
+    mqtt_params = mqtt_parameters()
+    @updates_topic = mqtt_params[:updates_topic]
+    @topic_prefix = mqtt_topic_prefix()
+
+    @client.put(
+      '/settings',
+      mqtt_params.merge(
+        mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id"
+      )
+    )
+
+    @id_params = {
+      id: @client.generate_id,
+      type: 'rgb_cct',
+      group_id: 1
+    }
+    @client.delete_state(@id_params)
+
+    @mqtt_client = create_mqtt_client()
+
+    # Delete any existing transitions
+    @client.get('/transitions')['transitions'].each do |t|
+      @client.delete("/transitions/#{t['id']}")
+    end
+  end
+
+  context 'REST routes' do
+    it 'should respond with an empty list when there are no transitions' do
+      response = @client.transitions
+      expect(response).to eq([])
+    end
+
+    it 'should respond with an error when missing parameters for POST /transitions' do
+      expect { @client.post('/transitions', {}) }.to raise_error(Net::HTTPServerException)
+    end
+
+    it 'should create a new transition with a valid POST /transitions request' do
+      response = @client.schedule_transition(@id_params, @transition_params)
+
+      expect(response['success']).to eq(true)
+    end
+
+    it 'should list active transitions' do
+      @client.schedule_transition(@id_params, @transition_params)
+
+      response = @client.transitions
+
+      expect(response.length).to be >= 1
+    end
+
+    it 'should support getting an active transition with GET /transitions/:id' do
+      @client.schedule_transition(@id_params, @transition_params)
+
+      response = @client.transitions
+      detail_response = @client.get("/transitions/#{response.last['id']}")
+
+      expect(detail_response['period']).to_not eq(nil)
+    end
+
+    it 'should support deleting active transitions with DELETE /transitions/:id' do
+      @client.schedule_transition(@id_params, @transition_params)
+
+      response = @client.transitions
+
+      response.each do |transition|
+        @client.delete("/transitions/#{transition['id']}")
+      end
+
+      after_delete_response = @client.transitions
+
+      expect(response.length).to eq(1)
+      expect(after_delete_response.length).to eq(0)
+    end
+  end
+
+  context '"transition" key in state update' do
+    it 'should create a new transition' do
+      @client.patch_state({status: 'ON', level: 0}, @id_params)
+      @client.patch_state({level: 100, transition: 2.0}, @id_params)
+
+      response = @client.transitions
+
+      expect(response.length).to be > 0
+      expect(response.last['type']).to eq('field')
+      expect(response.last['field']).to eq('level')
+      expect(response.last['end_value']).to eq(100)
+
+      @client.delete("/transitions/#{response.last['id']}")
+    end
+
+    it 'should transition field' do
+      seen_updates = 0
+      last_value = nil
+
+      @client.patch_state({status: 'ON', level: 0}, @id_params)
+
+      @mqtt_client.on_update(@id_params) do |id, msg|
+        if msg.include?('brightness')
+          seen_updates += 1
+          last_value = msg['brightness']
+        end
+
+        last_value == 255
+      end
+
+      @client.patch_state({level: 100, transition: 2.0}, @id_params)
+
+      @mqtt_client.wait_for_listeners
+
+      expect(last_value).to eq(255)
+      expect(seen_updates).to eq(8) # duration of 2000ms / 300ms period + 1 for initial packet
+    end
+
+    it 'should transition a field downwards' do
+      seen_updates = 0
+      last_value = nil
+
+      @client.patch_state({status: 'ON'}, @id_params)
+      @client.patch_state({level: 100}, @id_params)
+
+      @mqtt_client.on_update(@id_params) do |id, msg|
+        if msg.include?('brightness')
+          seen_updates += 1
+          last_value = msg['brightness']
+        end
+
+        last_value == 0
+      end
+
+      @client.patch_state({level: 0, transition: 2.0}, @id_params)
+
+      @mqtt_client.wait_for_listeners
+
+      expect(last_value).to eq(0)
+      expect(seen_updates).to eq(8) # duration of 2000ms / 300ms period + 1 for initial packet
+    end
+
+    it 'should transition two fields at once if received in the same command' do
+      updates = {}
+
+      @client.patch_state({status: 'ON', hue: 0, level: 100}, @id_params)
+
+      @mqtt_client.on_update(@id_params) do |id, msg|
+        msg.each do |k, v|
+          updates[k] ||= []
+          updates[k] << v
+        end
+
+        updates['hue'] && updates['brightness'] && updates['hue'].last == 250 && updates['brightness'].last == 0
+      end
+
+      @client.patch_state({level: 0, hue: 250, transition: 2.0}, @id_params)
+
+      @mqtt_client.wait_for_listeners
+
+      expect(updates['hue'].last).to eq(250)
+      expect(updates['brightness'].last).to eq(0)
+      expect(updates['hue'].length == updates['brightness'].length).to eq(true), "Should have the same number of updates for both fields"
+      expect(updates['hue'].length).to eq(8)
+    end
+  end
+
+  context 'transition packets' do
+    it 'should send an initial state packet' do
+      seen = false
+
+      @mqtt_client.on_update(@id_params) do |id, message|
+        seen = message['brightness'] == 0
+      end
+
+      @client.schedule_transition(@id_params, @transition_params)
+
+      @mqtt_client.wait_for_listeners
+
+      expect(seen).to be(true)
+    end
+
+    it 'should respect the period parameter' do
+      seen_updates = []
+      start_time = Time.now
+
+      @mqtt_client.on_update(@id_params) do |id, message|
+        seen_updates << message
+        message['brightness'] == 255
+      end
+
+      @client.schedule_transition(@id_params, @transition_params.merge(duration: 2.0, period: 500))
+
+      @mqtt_client.wait_for_listeners
+
+      expect(seen_updates.map { |x| x['brightness'] }).to eq([0, 64, 128, 191, 255])
+      expect((Time.now - start_time)/4).to be >= 0.5 # Don't count the first update
+    end
+
+    it 'should support two transitions for different devices at the same time' do
+      id1 = @id_params
+      id2 = @id_params.merge(type: 'fut089')
+
+      @client.schedule_transition(id1, @transition_params)
+      @client.schedule_transition(id2, @transition_params)
+
+      id1_updates = []
+      id2_updates = []
+
+      @mqtt_client.on_update do |id, msg|
+        if id[:type] == id1[:type]
+          id1_updates << msg
+        else
+          id2_updates << msg
+        end
+        id1_updates.length == @num_transition_updates && id2_updates.length == @num_transition_updates
+      end
+
+      @mqtt_client.wait_for_listeners
+
+      expect(id1_updates.length).to eq(@num_transition_updates)
+      expect(id2_updates.length).to eq(@num_transition_updates)
+    end
+  end
+
+  context 'field support' do
+    {
+      'level' => {range: [0, 100], update_field: 'brightness', update_max: 255},
+      'brightness' => {range: [0, 255]},
+      'kelvin' => {range: [0, 100], update_field: 'color_temp', update_min: 153, update_max: 370},
+      'color_temp' => {range: [153, 370]},
+      'hue' => {range: [0, 359]},
+      'saturation' => {range: [0, 100]}
+    }.each do |field, params|
+      min, max = params[:range]
+      update_min = params[:update_min] || min
+      update_max = params[:update_max] || max
+      update_field = params[:update_field] || field
+
+      it "should support field '#{field}' min --> max" do
+        seen_updates = []
+
+        @client.patch_state({'status' => 'ON', field => min}, @id_params)
+
+        @mqtt_client.on_update(@id_params) do |id, message|
+          seen_updates << message
+          message[update_field] == update_max
+        end
+
+        @client.patch_state({field => max, 'transition' => 1.0}, @id_params)
+
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_updates.length).to eq(5)
+        expect(seen_updates.last[update_field]).to eq(update_max)
+      end
+
+      it "should support field '#{field}' max --> min" do
+        seen_updates = []
+
+        @client.patch_state({'status' => 'ON', field => max}, @id_params)
+
+        @mqtt_client.on_update(@id_params) do |id, message|
+          seen_updates << message
+          message[update_field] == update_min
+        end
+
+        @client.patch_state({field => min, 'transition' => 1.0}, @id_params)
+
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_updates.length).to eq(5)
+        expect(seen_updates.last[update_field]).to eq(update_min)
+      end
+    end
+  end
+
+  context 'color support' do
+    it 'should support color transitions' do
+      response = @client.schedule_transition(@id_params, {
+        field: 'color',
+        start_value: '255,0,0',
+        end_value: '0,255,0',
+        duration: 1.0,
+        period: 500
+      })
+      expect(response['success']).to eq(true)
+    end
+
+    it 'should smoothly transition from one color to another' do
+      seen_updates = []
+
+      fields = @client.get('/settings')['group_state_fields']
+      @client.put(
+        '/settings',
+        group_state_fields: fields + %w(oh_color),
+        mqtt_state_rate_limit: 1000
+      )
+
+      @mqtt_client.on_state(@id_params) do |id, message|
+        color = message['color']
+        seen_updates << color
+        color == '0,255,0'
+      end
+
+      response = @client.schedule_transition(@id_params, {
+        field: 'color',
+        start_value: '255,0,0',
+        end_value: '0,255,0',
+        duration: 4.0,
+        period: 1000
+      })
+
+      @mqtt_client.wait_for_listeners
+
+      parts = seen_updates.map { |x| x.split(',').map(&:to_i) }
+
+      # This is less even than you'd expect because RGB -> Hue/Sat is lossy.
+      # Raw logs show that the right thing is happening:
+      #
+      #     >>> stepSizes = (-64,64,0)
+      #     >>> start = (255,0,0)
+      #     >>> end = (0,255,0)
+      #     >>> current color = (191,64,0)
+      #     >>> current color = (127,128,0)
+      #     >>> current color = (63,192,0)
+      #     >>> current color = (0,255,0)
+      expect(parts).to eq([
+        [255, 0, 0],
+        [255, 84, 0],
+        [250, 255, 0],
+        [84, 255, 0],
+        [0, 255, 0]
+      ])
+    end
+
+    it 'should handle color transitions from known state' do
+      seen_updates = []
+
+      fields = @client.get('/settings')['group_state_fields']
+      @client.put(
+        '/settings',
+        group_state_fields: fields + %w(oh_color),
+        mqtt_state_rate_limit: 1000
+      )
+      @client.patch_state({status: 'ON', color: '255,0,0'}, @id_params)
+
+      @mqtt_client.on_state(@id_params) do |id, message|
+        color = message['color']
+        seen_updates << color if color
+        color == '0,0,255'
+      end
+
+      @client.patch_state({color: '0,0,255', transition: 2.0}, @id_params)
+      @mqtt_client.wait_for_listeners
+
+      parts = seen_updates.map { |x| x.split(',').map(&:to_i) }
+
+      expect(parts).to eq([
+        [255,0,0],
+        [161,0,255],
+        [0,0,255]
+      ])
+    end
+  end
+end