瀏覽代碼

Merge pull request #60 from sidoh/mqtt

Add MQTT support
Chris Mullins 8 年之前
父節點
當前提交
8447747e3e

+ 30 - 2
README.md

@@ -78,8 +78,8 @@ The HTTP endpoints (shown below) will be fully functional at this point. You sho
 1. `GET /settings`. Gets current settings as JSON.
 1. `PUT /settings`. Patches settings (e.g., doesn't overwrite keys that aren't present). Accepts a JSON blob in the body.
 1. `GET /radio_configs`. Get a list of supported radio configs (aka `device_type`s).
-1. `GET /gateway_traffic/:device_type`. Starts an HTTP long poll. Returns any Milight traffic it hears. Useful if you need to know what your Milight gateway/remote ID is. Since protocols for RGBW/CCT are different, specify one of `rgbw`, `cct`, or `rgb_cct` as `:device_type. Accepts a JSON blob.
-1. `PUT /gateways/:device_id/:device_type/:group_id`. Controls or sends commands to `:group_id` from `:device_id`.
+1. `GET /gateway_traffic/:device_type`. Starts an HTTP long poll. Returns any Milight traffic it hears. Useful if you need to know what your Milight gateway/remote ID is. Since protocols for RGBW/CCT are different, specify one of `rgbw`, `cct`, or `rgb_cct` as `:device_type.
+1. `PUT /gateways/:device_id/:device_type/:group_id`. Controls or sends commands to `:group_id` from `:device_id`. Accepts a JSON blob. The schema is documented below in the _Bulb commands_ section.
 1. `POST /raw_commands/:device_type`. Sends a raw RF packet with radio configs associated with `:device_type`. Example body:
     ```
     {"packet": "01 02 03 04 05 06 07 08 09", "num_repeats": 10}
@@ -127,6 +127,34 @@ $ curl --data-binary '{"command":"set_white"}' -X PUT http://esp8266/gateways/0x
 true%
 ```
 
+## MQTT
+
+To configure your ESP to integrate with MQTT, fill out the following settings:
+
+1. `mqtt_server`- IP or hostname should work. Specify a port with standard syntax (e.g., "mymqttbroker.com:1884").
+1. `mqtt_topic_pattern` - you can control arbitrary configurations of device ID, device type, and group ID with this. A good default choice is something like `milight/:device_id/:device_type/:group_id`. More detail is provided below.
+1. (optionally) `mqtt_username`
+1. (optionally) `mqtt_password`
+
+#### More detail on `mqtt_topic_pattern`
+
+`mqtt_topic_pattern` leverages single-level wildcards (documented [here](https://mosquitto.org/man/mqtt-7.html)). For example, specifying `milight/:device_id/:device_type/:group_id` will cause the ESP to subscribe to the topic `milight/+/+/+`. It will then interpret the second, third, and fourth tokens in topics it receives messages on as `:device_id`, `:device_type`, and `:group_id`, respectively.
+
+Messages should be JSON objects using exactly the same schema that the REST gateway uses for the `/gateways/:device_id/:device_type/:group_id` endpoint. Documented above in the _Bulb commands_ section.
+
+##### Example:
+
+If `mqtt_topic_pattern` is set to `milight/:device_id/:device_type/:group_id`, you could send the following message to it (the below example uses a ruby MQTT client):
+
+```ruby
+irb(main):001:0> require 'mqtt'
+irb(main):002:0> client = MQTT::Client.new('10.133.8.11',1883)
+irb(main):003:0> client.connect
+irb(main):004:0> client.publish('milight/0x118D/rgb_cct/1', '{"status":"ON","color":{"r":255,"g":200,"b":255},"brightness":100}')
+```
+
+This will instruct the ESP to send messages to RGB+CCT bulbs with device ID `0x118D` in group 1 to turn on, set color to RGB(255,200,255), and brightness to 100.
+
 ## UDP Gateways
 
 You can add an arbitrary number of UDP gateways through the REST API or through the web UI. Each gateway server listens on a port and responds to the standard set of commands supported by the Milight protocol. This should allow you to use one of these with standard Milight integrations (SmartThings, Home Assistant, OpenHAB, etc.).

+ 7 - 2
data/web/index.html

@@ -124,7 +124,8 @@
   <script lang="text/javascript">
     var FORM_SETTINGS = [
       "admin_username", "admin_password", "ce_pin", "csn_pin", "reset_pin","packet_repeats",
-      "http_repeat_factor", "auto_restart_period", "radio_interface_type"
+      "http_repeat_factor", "auto_restart_period", "mqtt_server", "mqtt_topic_pattern",
+      "mqtt_username", "mqtt_password", "radio_interface_type"
     ];
 
     var FORM_SETTINGS_HELP = {
@@ -136,7 +137,11 @@
       auto_restart_period : "Automatically restart the device every number of " +
         "minutes specified. Use 0 for disabled.",
       radio_interface_type : "2.4 GHz radio model. Only change this if you know " +
-        "You're not using an NRF24L01!"
+        "You're not using an NRF24L01!",
+      mqtt_server : "Domain or IP address of MQTT broker. Optionally specify a port " +
+        "with (example) mymqqtbroker.com:1884.",
+      mqtt_topic_pattern : "Pattern for MQTT topics to listen on. Example: " +
+        "lights/:device_id/:type/:group. See README for further details."
     }
 
     var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];

lib/WebServer/TokenIterator.cpp → lib/Helpers/TokenIterator.cpp


+ 2 - 1
lib/WebServer/TokenIterator.h

@@ -1,7 +1,8 @@
 #include <Arduino.h>
 
 #ifndef _TOKEN_ITERATOR_H
-#define _TOKEN_ITERATOR_H value
+#define _TOKEN_ITERATOR_H
+
 class TokenIterator {
 public:
   TokenIterator(char* data, size_t length, char sep = ',');

+ 35 - 0
lib/Helpers/UrlTokenBindings.cpp

@@ -0,0 +1,35 @@
+#include <UrlTokenBindings.h>
+
+UrlTokenBindings::UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens)
+  : patternTokens(patternTokens),
+    requestTokens(requestTokens)
+{ }
+
+bool UrlTokenBindings::hasBinding(const char* searchToken) const {
+  patternTokens.reset();
+  while (patternTokens.hasNext()) {
+    const char* token = patternTokens.nextToken();
+
+    if (token[0] == ':' && strcmp(token+1, searchToken) == 0) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+const char* UrlTokenBindings::get(const char* searchToken) const {
+  patternTokens.reset();
+  requestTokens.reset();
+
+  while (patternTokens.hasNext() && requestTokens.hasNext()) {
+    const char* token = patternTokens.nextToken();
+    const char* binding = requestTokens.nextToken();
+
+    if (token[0] == ':' && strcmp(token+1, searchToken) == 0) {
+      return binding;
+    }
+  }
+
+  return NULL;
+}

+ 18 - 0
lib/Helpers/UrlTokenBindings.h

@@ -0,0 +1,18 @@
+#include <TokenIterator.h>
+
+#ifndef _URL_TOKEN_BINDINGS_H
+#define _URL_TOKEN_BINDINGS_H
+
+class UrlTokenBindings {
+public:
+  UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens);
+
+  bool hasBinding(const char* key) const;
+  const char* get(const char* key) const;
+
+private:
+  TokenIterator& patternTokens;
+  TokenIterator& requestTokens;
+};
+
+#endif

+ 127 - 0
lib/MQTT/MqttClient.cpp

@@ -0,0 +1,127 @@
+#include <MqttClient.h>
+#include <TokenIterator.h>
+#include <UrlTokenBindings.h>
+#include <IntParsing.h>
+#include <ArduinoJson.h>
+#include <WiFiClient.h>
+
+MqttClient::MqttClient(Settings& settings, MiLightClient*& milightClient)
+  : milightClient(milightClient),
+    settings(settings)
+{
+  String strDomain = settings.mqttServer();
+  this->domain = new char[strDomain.length() + 1];
+  strcpy(this->domain, strDomain.c_str());
+
+  this->mqttClient = new PubSubClient(tcpClient);
+}
+
+MqttClient::~MqttClient() {
+  mqttClient->disconnect();
+  delete this->domain;
+}
+
+void MqttClient::begin() {
+#ifdef MQTT_DEBUG
+  printf_P(
+    PSTR("MqttClient - Connecting to: %s\nparsed:%s:%u\n"),
+    settings._mqttServer.c_str(),
+    settings.mqttServer().c_str(),
+    settings.mqttPort()
+  );
+#endif
+
+  mqttClient->setServer(this->domain, settings.mqttPort());
+  mqttClient->setCallback(
+    [this](char* topic, byte* payload, int length) {
+      this->publishCallback(topic, payload, length);
+    }
+  );
+  reconnect();
+}
+
+bool MqttClient::connect() {
+  char nameBuffer[30];
+  sprintf_P(nameBuffer, PSTR("milight-hub-%u"), ESP.getChipId());
+
+#ifdef MQTT_DEBUG
+    Serial.println(F("MqttClient - connecting"));
+#endif
+
+  if (settings.mqttUsername.length() > 0) {
+    return mqttClient->connect(
+      nameBuffer,
+      settings.mqttUsername.c_str(),
+      settings.mqttPassword.c_str()
+    );
+  } else {
+    return mqttClient->connect(nameBuffer);
+  }
+}
+
+void MqttClient::reconnect() {
+  if (! mqttClient->connected()) {
+    if (connect()) {
+      subscribe();
+    } else {
+      Serial.println(F("ERROR: Failed to connect to MQTT server"));
+    }
+  }
+}
+
+void MqttClient::handleClient() {
+  reconnect();
+  mqttClient->loop();
+}
+
+void MqttClient::subscribe() {
+  String topic = settings.mqttTopicPattern;
+
+  topic.replace(":device_id", "+");
+  topic.replace(":group_id", "+");
+  topic.replace(":device_type", "+");
+
+  mqttClient->subscribe(topic.c_str());
+}
+
+void MqttClient::publishCallback(char* topic, byte* payload, int length) {
+  uint16_t deviceId = 0;
+  uint8_t groupId = 0;
+  MiLightRadioConfig* config = &MilightRgbCctConfig;
+  char cstrPayload[length + 1];
+  cstrPayload[length] = 0;
+  memcpy(cstrPayload, payload, sizeof(byte)*length);
+
+#ifdef MQTT_DEBUG
+  printf_P(PSTR("MqttClient - Got message on topic: %s\n%s\n"), topic, cstrPayload);
+#endif
+
+  char topicPattern[settings.mqttTopicPattern.length()];
+  strcpy(topicPattern, settings.mqttTopicPattern.c_str());
+
+  TokenIterator patternIterator(topicPattern, settings.mqttTopicPattern.length(), '/');
+  TokenIterator topicIterator(topic, strlen(topic), '/');
+  UrlTokenBindings tokenBindings(patternIterator, topicIterator);
+
+  if (tokenBindings.hasBinding("device_id")) {
+    deviceId = parseInt<uint16_t>(tokenBindings.get("device_id"));
+  }
+
+  if (tokenBindings.hasBinding("group_id")) {
+    groupId = parseInt<uint16_t>(tokenBindings.get("group_id"));
+  }
+
+  if (tokenBindings.hasBinding("device_type")) {
+    config = MiLightRadioConfig::fromString(tokenBindings.get("device_type"));
+  }
+
+  StaticJsonBuffer<400> buffer;
+  JsonObject& obj = buffer.parseObject(cstrPayload);
+
+#ifdef MQTT_DEBUG
+  printf_P(PSTR("MqttClient - device %04X, group %u\n"), deviceId, groupId);
+#endif
+
+  milightClient->prepare(*config, deviceId, groupId);
+  milightClient->update(obj);
+}

+ 30 - 0
lib/MQTT/MqttClient.h

@@ -0,0 +1,30 @@
+#include <MiLightClient.h>
+#include <Settings.h>
+#include <PubSubClient.h>
+#include <WiFiClient.h>
+
+#ifndef _MQTT_CLIENT_H
+#define _MQTT_CLIENT_H
+
+class MqttClient {
+public:
+  MqttClient(Settings& settings, MiLightClient*& milightClient);
+  ~MqttClient();
+
+  void begin();
+  void handleClient();
+  void reconnect();
+
+private:
+  WiFiClient tcpClient;
+  PubSubClient* mqttClient;
+  MiLightClient*& milightClient;
+  Settings& settings;
+  char* domain;
+
+  bool connect();
+  void subscribe();
+  void publishCallback(char* topic, byte* payload, int length);
+};
+
+#endif

+ 129 - 0
lib/MiLight/MiLightClient.cpp

@@ -1,6 +1,10 @@
 #include <MiLightClient.h>
 #include <MiLightRadioConfig.h>
 #include <Arduino.h>
+#include <RGBConverter.h>
+
+#define COLOR_TEMP_MAX_MIREDS 370
+#define COLOR_TEMP_MIN_MIREDS 153
 
 MiLightClient::MiLightClient(MiLightRadioFactory* radioFactory)
   : resendCount(MILIGHT_DEFAULT_RESEND_COUNT),
@@ -217,6 +221,131 @@ void MiLightClient::command(uint8_t command, uint8_t arg) {
   flushPacket();
 }
 
+void MiLightClient::update(const JsonObject& request) {
+  if (request.containsKey("status") || request.containsKey("state")) {
+    String strStatus;
+
+    if (request.containsKey("status")) {
+      strStatus = request.get<char*>("status");
+    } else {
+      strStatus = request.get<char*>("state");
+    }
+
+    MiLightStatus status = (strStatus.equalsIgnoreCase("on") || strStatus.equalsIgnoreCase("true")) ? ON : OFF;
+    this->updateStatus(status);
+  }
+
+  if (request.containsKey("command")) {
+    if (request["command"] == "unpair") {
+      this->unpair();
+    }
+
+    if (request["command"] == "pair") {
+      this->pair();
+    }
+
+    if (request["command"] == "set_white") {
+      this->updateColorWhite();
+    }
+
+    if (request["command"] == "night_mode") {
+      this->enableNightMode();
+    }
+
+    if (request["command"] == "level_up") {
+      this->increaseBrightness();
+    }
+
+    if (request["command"] == "level_down") {
+      this->decreaseBrightness();
+    }
+
+    if (request["command"] == "temperature_up") {
+      this->increaseTemperature();
+    }
+
+    if (request["command"] == "temperature_down") {
+      this->decreaseTemperature();
+    }
+
+    if (request["command"] == "next_mode") {
+      this->nextMode();
+    }
+
+    if (request["command"] == "previous_mode") {
+      this->previousMode();
+    }
+
+    if (request["command"] == "mode_speed_down") {
+      this->modeSpeedDown();
+    }
+
+    if (request["command"] == "mode_speed_up") {
+      this->modeSpeedUp();
+    }
+  }
+
+  if (request.containsKey("hue")) {
+    this->updateHue(request["hue"]);
+  }
+  if (request.containsKey("saturation")) {
+    this->updateSaturation(request["saturation"]);
+  }
+
+  // Convert RGB to HSV
+  if (request.containsKey("color")) {
+    JsonObject& color = request["color"];
+
+    uint8_t r = color["r"];
+    uint8_t g = color["g"];
+    uint8_t b = color["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);
+
+    this->updateHue(hue);
+    this->updateSaturation(saturation);
+  }
+
+  if (request.containsKey("level")) {
+    this->updateBrightness(request["level"]);
+  }
+  // HomeAssistant
+  if (request.containsKey("brightness")) {
+    uint8_t scaledBrightness = round(request.get<uint8_t>("brightness") * (100/255.0));
+    this->updateBrightness(scaledBrightness);
+  }
+
+  if (request.containsKey("temperature")) {
+    this->updateTemperature(request["temperature"]);
+  }
+  // HomeAssistant
+  if (request.containsKey("color_temp")) {
+    // MiLight CCT bulbs range from 2700K-6500K, or ~370.3-153.8 mireds. Note
+    // that mireds are inversely correlated with color temperature.
+    uint32_t tempMireds = request["color_temp"];
+    tempMireds = tempMireds > COLOR_TEMP_MAX_MIREDS ? COLOR_TEMP_MAX_MIREDS : tempMireds;
+    tempMireds = tempMireds < COLOR_TEMP_MIN_MIREDS ? COLOR_TEMP_MIN_MIREDS : tempMireds;
+
+    uint8_t scaledTemp = round(
+      100*
+      (tempMireds - COLOR_TEMP_MIN_MIREDS)
+        /
+      static_cast<double>(COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS)
+    );
+
+    this->updateTemperature(100 - scaledTemp);
+  }
+
+  if (request.containsKey("mode")) {
+    this->updateMode(request["mode"]);
+  }
+}
+
 void MiLightClient::formatPacket(uint8_t* packet, char* buffer) {
   formatter->format(packet, buffer);
 }

+ 2 - 0
lib/MiLight/MiLightClient.h

@@ -59,6 +59,8 @@ public:
 
   void formatPacket(uint8_t* packet, char* buffer);
 
+  void update(const JsonObject& object);
+
 
 protected:
 

+ 42 - 72
lib/Settings/Settings.cpp

@@ -4,6 +4,8 @@
 #include <IntParsing.h>
 #include <algorithm>
 
+#define PORT_POSITION(s) ( s.indexOf(':') )
+
 bool Settings::hasAuthSettings() {
   return adminUsername.length() > 0 && adminPassword.length() > 0;
 }
@@ -23,53 +25,7 @@ size_t Settings::getAutoRestartPeriod() {
 void Settings::deserialize(Settings& settings, String json) {
   DynamicJsonBuffer jsonBuffer;
   JsonObject& parsedSettings = jsonBuffer.parseObject(json);
-  deserialize(settings, parsedSettings);
-}
-
-void Settings::deserialize(Settings& settings, JsonObject& parsedSettings) {
-  if (parsedSettings.success()) {
-    if (parsedSettings.containsKey("admin_username")) {
-      settings.adminUsername = parsedSettings.get<String>("admin_username");
-    }
-
-    if (parsedSettings.containsKey("admin_password")) {
-      settings.adminPassword = parsedSettings.get<String>("admin_password");
-    }
-
-    if (parsedSettings.containsKey("ce_pin")) {
-      settings.cePin = parsedSettings["ce_pin"];
-    }
-
-    if (parsedSettings.containsKey("csn_pin")) {
-      settings.csnPin = parsedSettings["csn_pin"];
-    }
-
-    if (parsedSettings.containsKey("reset_pin")) {
-      settings.resetPin = parsedSettings["reset_pin"];
-    }
-
-    if (parsedSettings.containsKey("radio_interface_type")) {
-      settings.radioInterfaceType = typeFromString(parsedSettings["radio_interface_type"]);
-    }
-
-    if (parsedSettings.containsKey("packet_repeats")) {
-      settings.packetRepeats = parsedSettings["packet_repeats"];
-    }
-
-    if (parsedSettings.containsKey("http_repeat_factor")) {
-      settings.httpRepeatFactor = parsedSettings["http_repeat_factor"];
-    }
-
-    if (parsedSettings.containsKey("auto_restart_period")) {
-      settings._autoRestartPeriod = parsedSettings["auto_restart_period"];
-    }
-
-    JsonArray& arr = parsedSettings["device_ids"];
-    settings.updateDeviceIds(arr);
-
-    JsonArray& gatewayArr = parsedSettings["gateway_configs"];
-    settings.updateGatewayConfigs(gatewayArr);
-  }
+  settings.patch(parsedSettings);
 }
 
 void Settings::updateDeviceIds(JsonArray& arr) {
@@ -108,33 +64,23 @@ void Settings::updateGatewayConfigs(JsonArray& arr) {
 
 void Settings::patch(JsonObject& parsedSettings) {
   if (parsedSettings.success()) {
-    if (parsedSettings.containsKey("admin_username")) {
-      this->adminUsername = parsedSettings.get<String>("admin_username");
-    }
-    if (parsedSettings.containsKey("admin_password")) {
-      this->adminPassword = parsedSettings.get<String>("admin_password");
-    }
-    if (parsedSettings.containsKey("ce_pin")) {
-      this->cePin = parsedSettings["ce_pin"];
-    }
-    if (parsedSettings.containsKey("csn_pin")) {
-      this->csnPin = parsedSettings["csn_pin"];
-    }
-    if (parsedSettings.containsKey("reset_pin")) {
-      this->resetPin = parsedSettings["reset_pin"];
-    }
+    this->setIfPresent<String>(parsedSettings, "admin_username", adminUsername);
+    this->setIfPresent(parsedSettings, "admin_password", adminPassword);
+    this->setIfPresent(parsedSettings, "ce_pin", cePin);
+    this->setIfPresent(parsedSettings, "csn_pin", csnPin);
+    this->setIfPresent(parsedSettings, "reset_pin", resetPin);
+    this->setIfPresent(parsedSettings, "packet_repeats", packetRepeats);
+    this->setIfPresent(parsedSettings, "http_repeat_factor", httpRepeatFactor);
+    this->setIfPresent(parsedSettings, "auto_restart_period", _autoRestartPeriod);
+    this->setIfPresent(parsedSettings, "mqtt_server", _mqttServer);
+    this->setIfPresent(parsedSettings, "mqtt_username", mqttUsername);
+    this->setIfPresent(parsedSettings, "mqtt_password", mqttPassword);
+    this->setIfPresent(parsedSettings, "mqtt_topic_pattern", mqttTopicPattern);
+
     if (parsedSettings.containsKey("radio_interface_type")) {
-      this->radioInterfaceType = typeFromString(parsedSettings["radio_interface_type"]);
-    }
-    if (parsedSettings.containsKey("packet_repeats")) {
-      this->packetRepeats = parsedSettings["packet_repeats"];
-    }
-    if (parsedSettings.containsKey("http_repeat_factor")) {
-      this->httpRepeatFactor = parsedSettings["http_repeat_factor"];
-    }
-    if (parsedSettings.containsKey("auto_restart_period")) {
-      this->_autoRestartPeriod = parsedSettings["auto_restart_period"];
+      this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]);
     }
+
     if (parsedSettings.containsKey("device_ids")) {
       JsonArray& arr = parsedSettings["device_ids"];
       updateDeviceIds(arr);
@@ -189,6 +135,10 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
   root["packet_repeats"] = this->packetRepeats;
   root["http_repeat_factor"] = this->httpRepeatFactor;
   root["auto_restart_period"] = this->_autoRestartPeriod;
+  root["mqtt_server"] = this->_mqttServer;
+  root["mqtt_username"] = this->mqttUsername;
+  root["mqtt_password"] = this->mqttPassword;
+  root["mqtt_topic_pattern"] = this->mqttTopicPattern;
 
   if (this->deviceIds) {
     JsonArray& arr = jsonBuffer.createArray();
@@ -216,6 +166,26 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
   }
 }
 
+String Settings::mqttServer() {
+  int pos = PORT_POSITION(_mqttServer);
+
+  if (pos == -1) {
+    return _mqttServer;
+  } else {
+    return _mqttServer.substring(0, pos);
+  }
+}
+
+uint16_t Settings::mqttPort() {
+  int pos = PORT_POSITION(_mqttServer);
+
+  if (pos == -1) {
+    return DEFAULT_MQTT_PORT;
+  } else {
+    return atoi(_mqttServer.c_str() + pos + 1);
+  }
+}
+
 RadioInterfaceType Settings::typeFromString(const String& s) {
   if (s.equalsIgnoreCase("lt8900")) {
     return LT8900;

+ 14 - 1
lib/Settings/Settings.h

@@ -26,6 +26,7 @@
 #define MILIGHT_REPO_WEB_PATH "/data/web/index.html"
 
 #define MINIMUM_RESTART_PERIOD 1
+#define DEFAULT_MQTT_PORT 1883
 
 enum RadioInterfaceType {
   nRF24 = 0,
@@ -75,7 +76,6 @@ public:
   size_t getAutoRestartPeriod();
 
   static void deserialize(Settings& settings, String json);
-  static void deserialize(Settings& settings, JsonObject& json);
   static void load(Settings& settings);
 
   static RadioInterfaceType typeFromString(const String& s);
@@ -87,6 +87,8 @@ public:
   void updateDeviceIds(JsonArray& arr);
   void updateGatewayConfigs(JsonArray& arr);
   void patch(JsonObject& obj);
+  String mqttServer();
+  uint16_t mqttPort();
 
   String adminUsername;
   String adminPassword;
@@ -100,9 +102,20 @@ public:
   size_t numDeviceIds;
   size_t packetRepeats;
   size_t httpRepeatFactor;
+  String _mqttServer;
+  String mqttUsername;
+  String mqttPassword;
+  String mqttTopicPattern;
 
 protected:
   size_t _autoRestartPeriod;
+
+  template <typename T>
+  void setIfPresent(JsonObject& obj, const char* key, T& var) {
+    if (obj.containsKey(key)) {
+      var = obj.get<T>(key);
+    }
+  }
 };
 
 #endif

+ 1 - 77
lib/WebServer/MiLightHttpServer.cpp

@@ -373,83 +373,7 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
 }
 
 void MiLightHttpServer::handleRequest(const JsonObject& request) {
-  if (request.containsKey("status")) {
-    const String& statusStr = request.get<String>("status");
-    MiLightStatus status = (statusStr == "on" || statusStr == "true") ? ON : OFF;
-    milightClient->updateStatus(status);
-  }
-
-  if (request.containsKey("command")) {
-    if (request["command"] == "unpair") {
-      milightClient->unpair();
-    }
-
-    if (request["command"] == "pair") {
-      milightClient->pair();
-    }
-
-    if (request["command"] == "set_white") {
-      milightClient->updateColorWhite();
-    }
-
-    if (request["command"] == "night_mode") {
-      milightClient->enableNightMode();
-    }
-
-    if (request["command"] == "level_up") {
-      milightClient->increaseBrightness();
-    }
-
-    if (request["command"] == "level_down") {
-      milightClient->decreaseBrightness();
-    }
-
-    if (request["command"] == "temperature_up") {
-      milightClient->increaseTemperature();
-    }
-
-    if (request["command"] == "temperature_down") {
-      milightClient->decreaseTemperature();
-    }
-
-    if (request["command"] == "next_mode") {
-      milightClient->nextMode();
-    }
-
-    if (request["command"] == "previous_mode") {
-      milightClient->previousMode();
-    }
-
-    if (request["command"] == "mode_speed_down") {
-      milightClient->modeSpeedDown();
-    }
-
-    if (request["command"] == "mode_speed_up") {
-      milightClient->modeSpeedUp();
-    }
-  }
-
-  if (request.containsKey("hue")) {
-    milightClient->updateHue(request["hue"]);
-  }
-
-  if (request.containsKey("level")) {
-    milightClient->updateBrightness(request["level"]);
-  }
-
-  if (request.containsKey("temperature")) {
-    milightClient->updateTemperature(request["temperature"]);
-  }
-
-  if (request.containsKey("saturation")) {
-    milightClient->updateSaturation(request["saturation"]);
-  }
-
-  if (request.containsKey("mode")) {
-    milightClient->updateMode(request["mode"]);
-  }
-
-  milightClient->setResendCount(settings.packetRepeats);
+  milightClient->update(request);
 }
 
 void MiLightHttpServer::handleSendRaw(const UrlTokenBindings* bindings) {

+ 0 - 34
lib/WebServer/PatternHandler.cpp

@@ -1,39 +1,5 @@
 #include <PatternHandler.h>
 
-UrlTokenBindings::UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens)
-  : patternTokens(patternTokens),
-    requestTokens(requestTokens)
-{ }
-
-bool UrlTokenBindings::hasBinding(const char* searchToken) const {
-  patternTokens.reset();
-  while (patternTokens.hasNext()) {
-    const char* token = patternTokens.nextToken();
-
-    if (strcmp(token, searchToken) == 0) {
-      return true;
-    }
-  }
-
-  return false;
-}
-
-const char* UrlTokenBindings::get(const char* searchToken) const {
-  patternTokens.reset();
-  requestTokens.reset();
-
-  while (patternTokens.hasNext() && requestTokens.hasNext()) {
-    const char* token = patternTokens.nextToken();
-    const char* binding = requestTokens.nextToken();
-
-    if (token[0] == ':' && strcmp(token+1, searchToken) == 0) {
-      return binding;
-    }
-  }
-
-  return NULL;
-}
-
 PatternHandler::PatternHandler(
     const String& pattern,
     const HTTPMethod method,

+ 1 - 12
lib/WebServer/PatternHandler.h

@@ -5,18 +5,7 @@
 #include <ESP8266WebServer.h>
 #include <functional>
 #include <TokenIterator.h>
-
-class UrlTokenBindings {
-public:
-  UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens);
-
-  bool hasBinding(const char* key) const;
-  const char* get(const char* key) const;
-
-private:
-  TokenIterator& patternTokens;
-  TokenIterator& requestTokens;
-};
+#include <UrlTokenBindings.h>
 
 class PatternHandler : public RequestHandler {
 public:

+ 3 - 0
platformio.ini

@@ -16,7 +16,10 @@ lib_deps_external =
   RF24
   WiFiManager
   ArduinoJson
+  PubSubClient
+  https://github.com/ratkins/RGBConverter
 build_flags = !python .get_version.py
+# -D MQTT_DEBUG
 # -D MILIGHT_UDP_DEBUG
 # -D DEBUG_PRINTF
 

+ 15 - 0
src/main.cpp

@@ -13,6 +13,8 @@
 #include <MiLightUdpServer.h>
 #include <ESP8266mDNS.h>
 #include <ESP8266SSDP.h>
+#include <MqttClient.h>
+#include <RGBConverter.h>
 
 WiFiManager wifiManager;
 
@@ -21,6 +23,7 @@ Settings settings;
 MiLightClient* milightClient;
 MiLightRadioFactory* radioFactory;
 MiLightHttpServer *httpServer;
+MqttClient* mqttClient;
 
 int numUdpServers = 0;
 MiLightUdpServer** udpServers;
@@ -66,6 +69,9 @@ void applySettings() {
   if (radioFactory) {
     delete radioFactory;
   }
+  if (mqttClient) {
+    delete mqttClient;
+  }
 
   radioFactory = MiLightRadioFactory::fromSettings(settings);
 
@@ -76,6 +82,11 @@ void applySettings() {
   milightClient = new MiLightClient(radioFactory);
   milightClient->begin();
 
+  if (settings.mqttServer().length() > 0) {
+    mqttClient = new MqttClient(settings, milightClient);
+    mqttClient->begin();
+  }
+
   initMilightUdpServers();
 }
 
@@ -117,6 +128,10 @@ void setup() {
 void loop() {
   httpServer->handleClient();
 
+  if (mqttClient) {
+    mqttClient->handleClient();
+  }
+
   if (udpServers) {
     for (size_t i = 0; i < settings.numGatewayConfigs; i++) {
       udpServers[i]->handleClient();