Explorar el Código

Merge pull request #93 from sidoh/v1.4.0

v1.4.0 -- passive listening, MQTT updates
Chris Mullins hace 8 años
padre
commit
5832c6a6c3

+ 24 - 1
README.md

@@ -11,6 +11,7 @@ This is a replacement for a Milight/LimitlessLED remote/gateway hosted on an ESP
 2. This project exposes a nice REST API to control your bulbs.
 3. You can secure the ESP8266 with a username/password, which is more than you can say for the Milight gateway! (The 2.4 GHz protocol is still totally insecure, so this doesn't accomplish much :).
 4. Official hubs connect to remote servers to enable WAN access, and this behavior is not disableable.
+5. This project is capable of passively listening for Milight packets sent from other devices (like remotes). It can publish data from intercepted packets to MQTT. This could, for example, allow the use of Milight remotes while keeping your home automation platform's state in sync. See the MQTT section for more detail.
 
 ## Supported bulbs
 
@@ -89,7 +90,7 @@ 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.
+1. `GET /gateway_traffic(/:device_type)?`. Starts an HTTP long poll. Returns any Milight traffic it hears. Useful if you need to know what your Milight gateway/remote ID is. Since protocols for RGBW/CCT are different, specify one of `rgbw`, `cct`, or `rgb_cct` as `:device_type.  The path `/gateway_traffic` without a `:device_type` will sniff for all protocols simultaneously.
 1. `PUT /gateways/:device_id/:device_type/:group_id`. Controls or sends commands to `:group_id` from `:device_id`. Accepts a JSON blob. The schema is documented below in the _Bulb commands_ section.
 1. `POST /raw_commands/:device_type`. Sends a raw RF packet with radio configs associated with `:device_type`. Example body:
     ```
@@ -174,6 +175,28 @@ irb(main):004:0> client.publish('milight/0x118D/rgb_cct/1', '{"status":"ON","col
 
 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.
 
+#### Updates
+
+To enable passive listening, make sure that `listen_repeats` is set to something larger than 0 (the default value of 3 is a good choice).
+
+To publish data from intercepted packets to an MQTT topic, configure MQTT server settings, and set the `mqtt_update_topic_pattern` to something of your choice. As with `mqtt_topic_pattern`, the tokens `:device_id`, `:device_type`, and `:group_id` will be substituted with the values from the relevant packet.
+
+The published message is a JSON blob containing the following keys:
+
+* `device_id`
+* `device_type` (rgb_cct, rgbw, etc.)
+* `group_id`
+* Any number of: `status`, `level`, `hue`, `saturation`, `kelvin`
+
+As an example, if `mqtt_update_topic_pattern` is set to `milight/updates/:device_id/:device_type/:group_id`, and the group 1 on button of a Milight remote is pressed, the following update will be dispatched:
+
+```ruby
+irb(main):005:0> client.subscribe('milight/updates/+/+/+')
+=> 27
+irb(main):006:0> puts client.get.inspect
+["lights/updates/0x1C8E/rgb_cct/1", "{\"device_id\":7310,\"group_id\":1,\"device_type\":\"rgb_cct\",\"status\":\"on\"}"]
+```
+
 ## 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.).

+ 31 - 29
data/web/index.html

@@ -31,11 +31,22 @@
     .error-info:before { content: '('; }
     .error-info:after { content: ')'; }
     .header-btn { margin: 20px; }
+    #sniffed-traffic { max-height: 50em; overflow-y: auto; }
     .btn-secondary {
       background-color: #fff;
       border: 1px solid #ccc;
     }
     .inline { display: inline-block; }
+    .white-temp-picker {
+      height: 2em;
+      background: linear-gradient(to right,
+        rgb(166, 209, 255) 0%,
+        rgb(255, 255, 255) 50%,
+        rgb(255, 160, 0) 100%
+      );
+      display: inline-block;
+      padding: 3px 0;
+    }
     .hue-picker {
       height: 2em;
       width: 100%;
@@ -124,8 +135,9 @@
   <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", "discovery_port", "mqtt_server", 
-      "mqtt_topic_pattern", "mqtt_username", "mqtt_password", "radio_interface_type"
+      "http_repeat_factor", "auto_restart_period", "discovery_port", "mqtt_server",
+      "mqtt_topic_pattern", "mqtt_update_topic_pattern", "mqtt_username", "mqtt_password",
+      "radio_interface_type", "listen_repeats"
     ];
 
     var FORM_SETTINGS_HELP = {
@@ -141,9 +153,14 @@
       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.",
+        "lights/:device_id/:device_type/:group_id. See README for further details.",
+      mqtt_update_topic_pattern : "Pattern to publish MQTT updates. Packets that " +
+        "are received from other devices, and packets that are sent from this device will " +
+        "result in updates being sent.",
       discovery_port : "UDP port to listen for discovery packets on. Defaults to " +
-        "the same port used by MiLight devices, 48899. Use 0 to disable."
+        "the same port used by MiLight devices, 48899. Use 0 to disable.",
+      listen_repeats : "Increasing this increases the amount of time spent listening for " +
+        "packets. Set to 0 to disable listening. Default is 3."
     }
 
     var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
@@ -207,10 +224,8 @@
     var sniffRequest;
     var sniffing = false;
     var getTraffic = function() {
-      var sniffType = $('#sniff-type input:checked').data('value');
-
-      sniffRequest = $.get('/gateway_traffic/' + sniffType, function(data) {
-        $('#sniffed-traffic').html(data + $('#sniffed-traffic').html());
+      sniffRequest = $.get('/gateway_traffic', function(data) {
+        $('#sniffed-traffic').prepend('<pre>' + data + '</pre>');
         getTraffic();
       });
     };
@@ -855,11 +870,13 @@
       </div>
       <div class="row">
         <div class="col-sm-6">
-          <input class="slider raw-update" name="temperature"
-              data-slider-min="0"
-              data-slider-max="100"
-              data-slider-value="100"
-          />
+          <div class="white-temp-picker">
+            <input class="slider raw-update" name="temperature"
+                data-slider-min="0"
+                data-slider-max="100"
+                data-slider-value="100"
+            />
+          </div>
         </div>
       </div>
     </div>
@@ -1006,24 +1023,9 @@
       <div class="col-sm-12">
         <button type="button" id="sniff" class="btn btn-primary">Start Sniffing</button>
 
-        <div class="btn-group" id="sniff-type" data-toggle="buttons">
-          <label class="btn btn-secondary active">
-            <input type="radio" name="options" autocomplete="off" data-value="rgbw" checked> RGBW
-          </label>
-          <label class="btn btn-secondary">
-            <input type="radio" name="options" autocomplete="off" data-value="cct"> CCT
-          </label>
-          <label class="btn btn-secondary">
-            <input type="radio" name="options" autocomplete="off" data-value="rgb_cct"> RGB+CCT
-          </label>
-          <label class="btn btn-secondary">
-            <input type="radio" name="options" autocomplete="off" data-value="rgb"> RGB
-          </label>
-        </div>
-
         <div> &nbsp; </div>
 
-        <pre id="sniffed-traffic"></pre>
+        <div id="sniffed-traffic"></div>
       </div>
     </div>
 

+ 39 - 0
lib/Helpers/Units.h

@@ -0,0 +1,39 @@
+#include <Arduino.h>
+#include <inttypes.h>
+
+#ifndef _UNITS_H
+#define _UNITS_H
+
+// MiLight CCT bulbs range from 2700K-6500K, or ~370.3-153.8 mireds.
+#define COLOR_TEMP_MAX_MIREDS 370
+#define COLOR_TEMP_MIN_MIREDS 153
+
+class Units {
+public:
+  template <typename T, typename V>
+  static T rescale(T value, V newMax, float oldMax = 255.0) {
+    return round(value * (newMax / oldMax));
+  }
+
+  static uint8_t miredsToWhiteVal(uint16_t mireds, uint8_t maxValue = 255) {
+      uint32_t tempMireds = constrain(mireds, COLOR_TEMP_MIN_MIREDS, COLOR_TEMP_MAX_MIREDS);
+
+      uint8_t scaledTemp = round(
+        maxValue*
+        (tempMireds - COLOR_TEMP_MIN_MIREDS)
+          /
+        static_cast<double>(COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS)
+      );
+
+      return scaledTemp;
+  }
+
+  static uint16_t whiteValToMireds(uint8_t value, uint8_t maxValue = 255) {
+    uint8_t reverseValue = maxValue - value;
+    uint16_t scaled = rescale<uint16_t, uint16_t>(reverseValue, (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS), maxValue);
+
+    return COLOR_TEMP_MIN_MIREDS + scaled;
+  }
+};
+
+#endif

+ 21 - 0
lib/MQTT/MqttClient.cpp

@@ -85,6 +85,27 @@ void MqttClient::handleClient() {
   mqttClient->loop();
 }
 
+void MqttClient::sendUpdate(MiLightRadioType type, uint16_t deviceId, uint16_t groupId, const char* update) {
+  String topic = settings.mqttUpdateTopicPattern;
+
+  if (topic.length() == 0) {
+    return;
+  }
+
+  String deviceIdStr = String(deviceId, 16);
+  deviceIdStr.toUpperCase();
+
+  topic.replace(":device_id", String("0x") + deviceIdStr);
+  topic.replace(":group_id", String(groupId));
+  topic.replace(":device_type", MiLightRadioConfig::fromType(type)->name);
+
+#ifdef MQTT_DEBUG
+  printf_P(PSTR("MqttClient - publishing update to %s: %s\n"), topic.c_str(), update);
+#endif
+
+  mqttClient->publish(topic.c_str(), update);
+}
+
 void MqttClient::subscribe() {
   String topic = settings.mqttTopicPattern;
 

+ 1 - 0
lib/MQTT/MqttClient.h

@@ -18,6 +18,7 @@ public:
   void begin();
   void handleClient();
   void reconnect();
+  void sendUpdate(MiLightRadioType type, uint16_t deviceId, uint16_t groupId, const char* update);
 
 private:
   WiFiClient tcpClient;

+ 56 - 0
lib/MiLight/CctPacketFormatter.cpp

@@ -107,6 +107,62 @@ uint8_t CctPacketFormatter::getCctStatusButton(uint8_t groupId, MiLightStatus st
   return button;
 }
 
+uint8_t CctPacketFormatter::cctCommandIdToGroup(uint8_t command) {
+  switch (command & 0xF) {
+    case CCT_GROUP_1_ON:
+    case CCT_GROUP_1_OFF:
+      return 1;
+    case CCT_GROUP_2_ON:
+    case CCT_GROUP_2_OFF:
+      return 2;
+    case CCT_GROUP_3_ON:
+    case CCT_GROUP_3_OFF:
+      return 3;
+    case CCT_GROUP_4_ON:
+    case CCT_GROUP_4_OFF:
+      return 4;
+    case CCT_ALL_ON:
+    case CCT_ALL_OFF:
+      return 0;
+  }
+
+  return 255;
+}
+
+MiLightStatus CctPacketFormatter::cctCommandToStatus(uint8_t command) {
+  switch (command & 0xF) {
+    case CCT_GROUP_1_ON:
+    case CCT_GROUP_2_ON:
+    case CCT_GROUP_3_ON:
+    case CCT_GROUP_4_ON:
+    case CCT_ALL_ON:
+      return ON;
+    case CCT_GROUP_1_OFF:
+    case CCT_GROUP_2_OFF:
+    case CCT_GROUP_3_OFF:
+    case CCT_GROUP_4_OFF:
+    case CCT_ALL_OFF:
+      return OFF;
+  }
+}
+
+void CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
+  uint8_t command = packet[CCT_COMMAND_INDEX] & 0x7F;
+
+  result["device_id"] = (packet[1] << 8) | packet[2];
+  result["device_type"] = "cct";
+  result["group_id"] = packet[3];
+
+  uint8_t onOffGroupId = cctCommandIdToGroup(command);
+  if (onOffGroupId < 255) {
+    result["state"] = cctCommandToStatus(command) == ON ? "ON" : "OFF";
+  }
+
+  if (! result.containsKey("state")) {
+    result["state"] = "ON";
+  }
+}
+
 void CctPacketFormatter::format(uint8_t const* packet, char* buffer) {
   PacketFormatter::formatV1Packet(packet, buffer);
 }

+ 3 - 0
lib/MiLight/CctPacketFormatter.h

@@ -43,8 +43,11 @@ public:
 
   virtual void format(uint8_t const* packet, char* buffer);
   virtual void initializePacket(uint8_t* packet);
+  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
 
   static uint8_t getCctStatusButton(uint8_t groupId, MiLightStatus status);
+  static uint8_t cctCommandIdToGroup(uint8_t command);
+  static MiLightStatus cctCommandToStatus(uint8_t command);
 };
 
 #endif

+ 22 - 19
lib/MiLight/MiLightClient.cpp

@@ -2,14 +2,13 @@
 #include <MiLightRadioConfig.h>
 #include <Arduino.h>
 #include <RGBConverter.h>
-
-#define COLOR_TEMP_MAX_MIREDS 370
-#define COLOR_TEMP_MIN_MIREDS 153
+#include <Units.h>
 
 MiLightClient::MiLightClient(MiLightRadioFactory* radioFactory)
   : resendCount(MILIGHT_DEFAULT_RESEND_COUNT),
     currentRadio(NULL),
-    numRadios(MiLightRadioConfig::NUM_CONFIGS)
+    numRadios(MiLightRadioConfig::NUM_CONFIGS),
+    packetSentHandler(NULL)
 {
   radios = new MiLightRadio*[numRadios];
 
@@ -63,7 +62,14 @@ void MiLightClient::prepare(MiLightRadioConfig& config,
   const uint16_t deviceId,
   const uint8_t groupId) {
 
-  switchRadio(config.type);
+  prepare(config.type, deviceId, groupId);
+}
+
+void MiLightClient::prepare(MiLightRadioType type,
+  const uint16_t deviceId,
+  const uint8_t groupId) {
+
+  switchRadio(type);
 
   if (deviceId >= 0 && groupId >= 0) {
     formatter->prepare(deviceId, groupId);
@@ -110,6 +116,10 @@ void MiLightClient::write(uint8_t packet[]) {
     currentRadio->write(packet, currentRadio->config().getPacketLength());
   }
 
+  if (this->packetSentHandler) {
+    this->packetSentHandler(packet, currentRadio->config());
+  }
+
 #ifdef DEBUG_PRINTF
   int iElapsed = millis() - iStart;
   Serial.print("Elapsed: ");
@@ -274,7 +284,7 @@ void MiLightClient::update(const JsonObject& request) {
   }
   // HomeAssistant
   if (request.containsKey("brightness")) {
-    uint8_t scaledBrightness = round(request.get<uint8_t>("brightness") * (100/255.0));
+    uint8_t scaledBrightness = Units::rescale(request.get<uint8_t>("brightness"), 100, 255);
     this->updateBrightness(scaledBrightness);
   }
 
@@ -283,20 +293,9 @@ void MiLightClient::update(const JsonObject& request) {
   }
   // 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(
+      Units::miredsToWhiteVal(request["color_temp"], 100)
     );
-
-    this->updateTemperature(100 - scaledTemp);
   }
 
   if (request.containsKey("mode")) {
@@ -375,3 +374,7 @@ void MiLightClient::flushPacket() {
   setResendCount(prevNumRepeats);
   formatter->reset();
 }
+
+void MiLightClient::onPacketSent(PacketSentHandler handler) {
+  this->packetSentHandler = handler;
+}

+ 8 - 1
lib/MiLight/MiLightClient.h

@@ -1,3 +1,4 @@
+#include <functional>
 #include <Arduino.h>
 #include <MiLightRadio.h>
 #include <MiLightRadioFactory.h>
@@ -11,6 +12,7 @@
 
 #define MILIGHT_DEFAULT_RESEND_COUNT 10
 
+
 class MiLightClient {
 public:
   MiLightClient(MiLightRadioFactory* radioFactory);
@@ -19,8 +21,11 @@ public:
     delete[] radios;
   }
 
+  typedef std::function<void(uint8_t* packet, const MiLightRadioConfig& config)> PacketSentHandler;
+
   void begin();
   void prepare(MiLightRadioConfig& config, const uint16_t deviceId = -1, const uint8_t groupId = -1);
+  void prepare(MiLightRadioType config, const uint16_t deviceId = -1, const uint8_t groupId = -1);
 
   void setResendCount(const unsigned int resendCount);
   bool available();
@@ -62,14 +67,16 @@ public:
   void update(const JsonObject& object);
   void handleCommand(const String& command);
 
+  void onPacketSent(PacketSentHandler handler);
+
 protected:
 
   MiLightRadio** radios;
   MiLightRadio* currentRadio;
   PacketFormatter* formatter;
   const size_t numRadios;
-
   unsigned int resendCount;
+  PacketSentHandler packetSentHandler;
 
   MiLightRadio* switchRadio(const MiLightRadioType type);
   uint8_t parseStatus(const JsonObject& object);

+ 18 - 12
lib/MiLight/MiLightRadioConfig.cpp

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

+ 8 - 7
lib/MiLight/MiLightRadioConfig.h

@@ -7,12 +7,12 @@
 #include <MiLightButtons.h>
 
 #ifndef _MILIGHT_RADIO_CONFIG
-#define _MILIGHT_RADIO_CONFIG 
+#define _MILIGHT_RADIO_CONFIG
 
 class MiLightRadioConfig {
 public:
   static const size_t NUM_CHANNELS = 3;
-  
+
   MiLightRadioConfig(const uint16_t syncword0,
   const uint16_t syncword3,
   PacketFormatter* packetFormatter,
@@ -20,7 +20,7 @@ public:
   const char* name,
   const uint8_t channel0,
   const uint8_t channel1,
-  const uint8_t channel2) 
+  const uint8_t channel2)
     : syncword0(syncword0),
       syncword3(syncword3),
       packetFormatter(packetFormatter),
@@ -31,18 +31,19 @@ public:
     channels[1] = channel1;
     channels[2] = channel2;
   }
-    
+
   const uint16_t syncword0;
   const uint16_t syncword3;
   uint8_t channels[3];
   PacketFormatter* packetFormatter;
   const MiLightRadioType type;
   const char* name;
-  
+
   static const size_t NUM_CONFIGS = 4;
-  static const MiLightRadioConfig* ALL_CONFIGS[NUM_CONFIGS];
-  
+  static MiLightRadioConfig* ALL_CONFIGS[NUM_CONFIGS];
+
   static MiLightRadioConfig* fromString(const String& s);
+  static MiLightRadioConfig* fromType(MiLightRadioType type);
   size_t getPacketLength() const;
 };
 

+ 2 - 0
lib/MiLight/PacketFormatter.cpp

@@ -61,6 +61,8 @@ void PacketFormatter::enableNightMode() { }
 void PacketFormatter::updateTemperature(uint8_t value) { }
 void PacketFormatter::updateSaturation(uint8_t value) { }
 
+void PacketFormatter::parsePacket(const uint8_t *packet, JsonObject &result) { }
+
 void PacketFormatter::pair() {
   for (size_t i = 0; i < 5; i++) {
     updateStatus(ON);

+ 3 - 5
lib/MiLight/PacketFormatter.h

@@ -2,6 +2,7 @@
 #include <inttypes.h>
 #include <functional>
 #include <MiLightButtons.h>
+#include <ArduinoJson.h>
 
 #define PACKET_FORMATTER_BUFFER_SIZE 48
 
@@ -66,12 +67,9 @@ public:
   virtual void prepare(uint16_t deviceId, uint8_t groupId);
   virtual void format(uint8_t const* packet, char* buffer);
 
-  static void formatV1Packet(uint8_t const* packet, char* buffer);
+  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
 
-  template <typename T>
-  static T rescale(T value, uint8_t newMax, float oldMax = 255.0) {
-    return round(value * (newMax / oldMax));
-  }
+  static void formatV1Packet(uint8_t const* packet, char* buffer);
 
   size_t getPacketLength() const;
 

+ 60 - 5
lib/MiLight/RgbCctPacketFormatter.cpp

@@ -1,4 +1,5 @@
 #include <RgbCctPacketFormatter.h>
+#include <Units.h>
 
 #define V2_OFFSET(byte, key, jumpStart) ( \
   pgm_read_byte(&V2_OFFSETS[byte-1][key%4]) \
@@ -76,24 +77,24 @@ void RgbCctPacketFormatter::previousMode() {
 }
 
 void RgbCctPacketFormatter::updateBrightness(uint8_t brightness) {
-  command(RGB_CCT_BRIGHTNESS, 0x8F + brightness);
+  command(RGB_CCT_BRIGHTNESS, RGB_CCT_BRIGHTNESS_OFFSET + brightness);
 }
 
 void RgbCctPacketFormatter::updateHue(uint16_t value) {
-  uint8_t remapped = rescale(value, 255, 360);
+  uint8_t remapped = Units::rescale(value, 255, 360);
   updateColorRaw(remapped);
 }
 
 void RgbCctPacketFormatter::updateColorRaw(uint8_t value) {
-  command(RGB_CCT_COLOR, 0x5F + value);
+  command(RGB_CCT_COLOR, RGB_CCT_COLOR_OFFSET + value);
 }
 
 void RgbCctPacketFormatter::updateTemperature(uint8_t value) {
-  command(RGB_CCT_KELVIN, 0x94 - (value*2));
+  command(RGB_CCT_KELVIN, RGB_CCT_KELVIN_OFFSET - (value*2));
 }
 
 void RgbCctPacketFormatter::updateSaturation(uint8_t value) {
-  uint8_t remapped = value + 0xD;
+  uint8_t remapped = value + RGB_CCT_SATURATION_OFFSET;
   command(RGB_CCT_SATURATION, remapped);
 }
 
@@ -110,6 +111,60 @@ void RgbCctPacketFormatter::finalizePacket(uint8_t* packet) {
   encodeV2Packet(packet);
 }
 
+void RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) {
+  uint8_t packetCopy[RGB_CCT_PACKET_LEN];
+  memcpy(packetCopy, packet, RGB_CCT_PACKET_LEN);
+  decodeV2Packet(packetCopy);
+
+  result["device_id"] = (packetCopy[2] << 8) | packetCopy[3];
+  result["group_id"] = packetCopy[7];
+  result["device_type"] = "rgb_cct";
+
+  uint8_t command = (packetCopy[RGB_CCT_COMMAND_INDEX] & 0x7F);
+  uint8_t arg = packetCopy[RGB_CCT_ARGUMENT_INDEX];
+
+  if (command == RGB_CCT_ON) {
+    // Group is not reliably encoded in group byte. Extract from arg byte
+    if (arg < 5) {
+      result["state"] = "ON";
+      result["group_id"] = arg;
+    } else {
+      result["state"] = "OFF";
+      result["group_id"] = arg-5;
+    }
+  } else if (command == RGB_CCT_COLOR) {
+    uint8_t rescaledColor = (arg - RGB_CCT_COLOR_OFFSET) % 0x100;
+    uint16_t hue = Units::rescale<uint16_t, uint16_t>(rescaledColor, 360, 255.0);
+    result["hue"] = hue;
+  } else if (command == RGB_CCT_KELVIN) {
+    uint8_t temperature =
+        static_cast<uint8_t>(
+          // Range in packets is 180 - 220 or something like that. Shift to
+          // 0..224. Then strip out values out of range [0..24), and (224..255]
+          constrain(
+            static_cast<uint8_t>(arg + RGB_CCT_KELVIN_REMOTE_OFFSET),
+            24,
+            224
+          )
+            +
+          // Shift 24 down to 0
+          RGB_CCT_KELVIN_REMOTE_START
+        )/2; // values are in increments of 2
+
+    result["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);
+  } else if (command == RGB_CCT_SATURATION) {
+    result["saturation"] = constrain(arg - RGB_CCT_SATURATION_OFFSET, 0, 100);
+  }
+
+  if (! result.containsKey("state")) {
+    result["state"] = "ON";
+  }
+}
+
 uint8_t RgbCctPacketFormatter::xorKey(uint8_t key) {
   // Generate most significant nibble
   const uint8_t shift = (key & 0x0F) < 0x04 ? 0 : 1;

+ 12 - 1
lib/MiLight/RgbCctPacketFormatter.h

@@ -4,6 +4,16 @@
 #define RGB_CCT_ARGUMENT_INDEX 5
 #define RGB_CCT_NUM_MODES 9
 #define V2_OFFSET_JUMP_START 0x54
+#define RGB_CCT_PACKET_LEN 9
+
+#define RGB_CCT_COLOR_OFFSET 0x5F
+#define RGB_CCT_BRIGHTNESS_OFFSET 0x8F
+#define RGB_CCT_SATURATION_OFFSET 0xD
+#define RGB_CCT_KELVIN_OFFSET 0x94
+
+// Remotes have a larger range
+#define RGB_CCT_KELVIN_REMOTE_OFFSET 0x4C
+#define RGB_CCT_KELVIN_REMOTE_START  0xE8
 
 #ifndef _RGB_CCT_PACKET_FORMATTER_H
 #define _RGB_CCT_PACKET_FORMATTER_H
@@ -28,7 +38,7 @@ public:
   static uint8_t const V2_OFFSETS[][4] PROGMEM;
 
   RgbCctPacketFormatter()
-    : PacketFormatter(9),
+    : PacketFormatter(RGB_CCT_PACKET_LEN),
       lastMode(0)
   { }
 
@@ -53,6 +63,7 @@ public:
   virtual void previousMode();
 
   virtual void finalizePacket(uint8_t* packet);
+  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
 
   static void encodeV2Packet(uint8_t* packet);
   static void decodeV2Packet(uint8_t* packet);

+ 24 - 1
lib/MiLight/RgbPacketFormatter.cpp

@@ -1,4 +1,5 @@
 #include <RgbPacketFormatter.h>
+#include <Units.h>
 
 void RgbPacketFormatter::initializePacket(uint8_t *packet) {
   size_t packetPtr = 0;
@@ -37,7 +38,7 @@ void RgbPacketFormatter::command(uint8_t command, uint8_t arg) {
 
 void RgbPacketFormatter::updateHue(uint16_t value) {
   const int16_t remappedColor = (value + 40) % 360;
-  updateColorRaw(rescale(remappedColor, 255, 360));
+  updateColorRaw(Units::rescale(remappedColor, 255, 360));
 }
 
 void RgbPacketFormatter::updateColorRaw(uint8_t value) {
@@ -78,6 +79,28 @@ void RgbPacketFormatter::previousMode() {
   command(RGB_MODE_DOWN, 0);
 }
 
+void RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
+  uint8_t command = packet[RGB_COMMAND_INDEX] & 0x7F;
+
+  result["group_id"] = 0;
+  result["device_id"] = (packet[1] << 8) | packet[2];
+  result["device_type"] = "rgb";
+
+  if (command == RGB_ON) {
+    result["state"] = "ON";
+  } else if (command == RGB_OFF) {
+    result["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;
+  }
+
+  if (! result.containsKey("state")) {
+    result["state"] = "ON";
+  }
+}
+
 void RgbPacketFormatter::format(uint8_t const* packet, char* buffer) {
   buffer += sprintf_P(buffer, "b0       : %02X\n", packet[0]);
   buffer += sprintf_P(buffer, "ID       : %02X%02X\n", packet[1], packet[2]);

+ 1 - 0
lib/MiLight/RgbPacketFormatter.h

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

+ 29 - 2
lib/MiLight/RgbwPacketFormatter.cpp

@@ -1,5 +1,6 @@
 #include <RgbwPacketFormatter.h>
 #include <MiLightButtons.h>
+#include <Units.h>
 
 #define STATUS_COMMAND(status, groupId) ( RGBW_GROUP_1_ON + ((groupId - 1)*2) + status )
 
@@ -49,7 +50,7 @@ void RgbwPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) {
 
 void RgbwPacketFormatter::updateBrightness(uint8_t value) {
   // Expect an input value in [0, 100]. Map it down to [0, 25].
-  const uint8_t adjustedBrightness = rescale(value, 25, 100);
+  const uint8_t adjustedBrightness = Units::rescale(value, 25, 100);
 
   // The actual protocol uses a bizarre range where min is 16, max is 23:
   // [16, 15, ..., 0, 31, ..., 23]
@@ -71,7 +72,7 @@ void RgbwPacketFormatter::command(uint8_t command, uint8_t arg) {
 
 void RgbwPacketFormatter::updateHue(uint16_t value) {
   const int16_t remappedColor = (value + 40) % 360;
-  updateColorRaw(rescale(remappedColor, 255, 360));
+  updateColorRaw(Units::rescale(remappedColor, 255, 360));
 }
 
 void RgbwPacketFormatter::updateColorRaw(uint8_t value) {
@@ -91,6 +92,32 @@ void RgbwPacketFormatter::enableNightMode() {
   command(button | 0x10, 0);
 }
 
+void RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) {
+  uint8_t command = packet[RGBW_COMMAND_INDEX] & 0x7F;
+
+  result["device_id"] = (packet[1] << 8) | packet[2];
+  result["device_type"] = "rgbw";
+  result["group_id"] = packet[RGBW_BRIGHTNESS_GROUP_INDEX] & 0x7;
+
+  if (command >= RGBW_ALL_ON && command <= RGBW_GROUP_4_OFF) {
+    result["state"] = (command % 2) ? "ON" : "OFF";
+  } else if (command == RGBW_BRIGHTNESS) {
+    uint8_t brightness = 31;
+    brightness -= packet[RGBW_BRIGHTNESS_GROUP_INDEX] >> 3;
+    brightness += 17;
+    brightness %= 32;
+    result["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;
+  }
+
+  if (! result.containsKey("state")) {
+    result["state"] = "ON";
+  }
+}
+
 void RgbwPacketFormatter::format(uint8_t const* packet, char* buffer) {
   PacketFormatter::formatV1Packet(packet, buffer);
 }

+ 1 - 0
lib/MiLight/RgbwPacketFormatter.h

@@ -60,6 +60,7 @@ public:
   virtual void previousMode();
   virtual void updateMode(uint8_t mode);
   virtual void enableNightMode();
+  virtual void parsePacket(const uint8_t* packet, JsonObject& result);
 
   virtual void initializePacket(uint8_t* packet);
 

+ 4 - 0
lib/Settings/Settings.cpp

@@ -76,7 +76,9 @@ void Settings::patch(JsonObject& parsedSettings) {
     this->setIfPresent(parsedSettings, "mqtt_username", mqttUsername);
     this->setIfPresent(parsedSettings, "mqtt_password", mqttPassword);
     this->setIfPresent(parsedSettings, "mqtt_topic_pattern", mqttTopicPattern);
+    this->setIfPresent(parsedSettings, "mqtt_update_topic_pattern", mqttUpdateTopicPattern);
     this->setIfPresent(parsedSettings, "discovery_port", discoveryPort);
+    this->setIfPresent(parsedSettings, "listen_repeats", listenRepeats);
 
     if (parsedSettings.containsKey("radio_interface_type")) {
       this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]);
@@ -140,7 +142,9 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) {
   root["mqtt_username"] = this->mqttUsername;
   root["mqtt_password"] = this->mqttPassword;
   root["mqtt_topic_pattern"] = this->mqttTopicPattern;
+  root["mqtt_update_topic_pattern"] = this->mqttUpdateTopicPattern;
   root["discovery_port"] = this->discoveryPort;
+  root["listen_repeats"] = this->listenRepeats;
 
   if (this->deviceIds) {
     JsonArray& arr = jsonBuffer.createArray();

+ 3 - 0
lib/Settings/Settings.h

@@ -62,6 +62,7 @@ public:
     numGatewayConfigs(0),
     packetRepeats(10),
     httpRepeatFactor(5),
+    listenRepeats(3),
     _autoRestartPeriod(0),
     discoveryPort(48899)
   { }
@@ -107,7 +108,9 @@ public:
   String mqttUsername;
   String mqttPassword;
   String mqttTopicPattern;
+  String mqttUpdateTopicPattern;
   uint16_t discoveryPort;
+  uint8_t listenRepeats;
 
 protected:
   size_t _autoRestartPeriod;

+ 5 - 43
lib/Udp/V5MiLightUdpServer.cpp

@@ -1,4 +1,5 @@
 #include <V5MiLightUdpServer.h>
+#include <CctPacketFormatter.h>
 
 void V5MiLightUdpServer::handlePacket(uint8_t* packet, size_t packetSize) {
   if (packetSize == 2 || packetSize == 3) {
@@ -25,9 +26,9 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
     client->prepare(MilightRgbwConfig, deviceId, groupId);
     client->updateColorWhite();
     this->lastGroup = groupId;
-  // On/off for CCT
-  } else if (cctCommandIdToGroup(command) != 255) {
-    uint8_t cctGroup = cctCommandIdToGroup(command);
+    // On/off for CCT
+  } else if (CctPacketFormatter::cctCommandIdToGroup(command) != 255) {
+    uint8_t cctGroup = CctPacketFormatter::cctCommandIdToGroup(command);
     client->prepare(MilightCctConfig, deviceId, cctGroup);
     this->lastGroup = cctGroup;
 
@@ -35,7 +36,7 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
     if ((command & 0x80) == 0x80) {
       client->enableNightMode();
     } else {
-      client->updateStatus(cctCommandToStatus(command));
+      client->updateStatus(CctPacketFormatter::cctCommandToStatus(command));
     }
   } else {
     client->prepare(MilightRgbwConfig, deviceId, lastGroup);
@@ -118,42 +119,3 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) {
 void V5MiLightUdpServer::pressButton(uint8_t button) {
   client->command(button, 0);
 }
-
-uint8_t V5MiLightUdpServer::cctCommandIdToGroup(uint8_t command) {
-  switch (command & 0x7F) {
-    case UDP_CCT_GROUP_1_ON:
-    case UDP_CCT_GROUP_1_OFF:
-      return 1;
-    case UDP_CCT_GROUP_2_ON:
-    case UDP_CCT_GROUP_2_OFF:
-      return 2;
-    case UDP_CCT_GROUP_3_ON:
-    case UDP_CCT_GROUP_3_OFF:
-      return 3;
-    case UDP_CCT_GROUP_4_ON:
-    case UDP_CCT_GROUP_4_OFF:
-      return 4;
-    case UDP_CCT_ALL_ON:
-    case UDP_CCT_ALL_OFF:
-      return 0;
-  }
-
-  return 255;
-}
-
-MiLightStatus V5MiLightUdpServer::cctCommandToStatus(uint8_t command) {
-  switch (command & 0x7F) {
-    case UDP_CCT_GROUP_1_ON:
-    case UDP_CCT_GROUP_2_ON:
-    case UDP_CCT_GROUP_3_ON:
-    case UDP_CCT_GROUP_4_ON:
-    case UDP_CCT_ALL_ON:
-      return ON;
-    case UDP_CCT_GROUP_1_OFF:
-    case UDP_CCT_GROUP_2_OFF:
-    case UDP_CCT_GROUP_3_OFF:
-    case UDP_CCT_GROUP_4_OFF:
-    case UDP_CCT_ALL_OFF:
-      return OFF;
-  }
-}

+ 0 - 2
lib/Udp/V5MiLightUdpServer.h

@@ -60,8 +60,6 @@ public:
 protected:
   void handleCommand(uint8_t command, uint8_t commandArg);
   void pressButton(uint8_t button);
-  uint8_t cctCommandIdToGroup(uint8_t command);
-  MiLightStatus cctCommandToStatus(uint8_t command);
 };
 
 #endif

+ 25 - 7
lib/WebServer/MiLightHttpServer.cpp

@@ -16,7 +16,10 @@ void MiLightHttpServer::begin() {
   server.on("/settings", HTTP_PUT, [this]() { handleUpdateSettings(); });
   server.on("/settings", HTTP_POST, [this]() { server.send_P(200, TEXT_PLAIN, PSTR("success. rebooting")); ESP.restart(); }, handleUpdateFile(SETTINGS_FILE));
   server.on("/radio_configs", HTTP_GET, [this]() { handleGetRadioConfigs(); });
+
+  server.on("/gateway_traffic", HTTP_GET, [this]() { handleListenGateway(NULL); });
   server.onPattern("/gateway_traffic/:type", HTTP_GET, [this](const UrlTokenBindings* b) { handleListenGateway(b); });
+
   server.onPattern("/gateways/:device_id/:type/:group_id", HTTP_ANY, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
   server.onPattern("/raw_commands/:type", HTTP_ANY, [this](const UrlTokenBindings* b) { handleSendRaw(b); });
   server.onPattern("/download_update/:component", HTTP_GET, [this](const UrlTokenBindings* b) { handleDownloadUpdate(b); });
@@ -293,9 +296,14 @@ void MiLightHttpServer::handleUpdateSettings() {
 
 void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
   bool available = false;
-  MiLightRadioConfig* config = MiLightRadioConfig::fromString(bindings->get("type"));
-
-  if (config == NULL) {
+  bool listenAll = bindings == NULL;
+  uint8_t configIx = 0;
+  MiLightRadioConfig* currentConfig =
+    listenAll
+      ? MiLightRadioConfig::ALL_CONFIGS[0]
+      : MiLightRadioConfig::fromString(bindings->get("type"));
+
+  if (currentConfig == NULL && bindings != NULL) {
     String body = "Unknown device type: ";
     body += bindings->get("type");
 
@@ -303,13 +311,18 @@ void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
     return;
   }
 
-  milightClient->prepare(*config, 0, 0);
-
   while (!available) {
     if (!server.clientConnected()) {
       return;
     }
 
+    if (listenAll) {
+      currentConfig = MiLightRadioConfig::ALL_CONFIGS[
+        configIx++ % MiLightRadioConfig::NUM_CONFIGS
+      ];
+    }
+    milightClient->prepare(*currentConfig, 0, 0);
+
     if (milightClient->available()) {
       available = true;
     }
@@ -317,13 +330,18 @@ void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) {
     yield();
   }
 
-  uint8_t packet[config->getPacketLength()];
+  uint8_t packet[currentConfig->getPacketLength()];
   milightClient->read(packet);
 
   char response[200];
   char* responseBuffer = response;
 
-  responseBuffer += sprintf_P(responseBuffer, PSTR("\nPacket received (%d bytes):\n"), sizeof(packet));
+  responseBuffer += sprintf_P(
+    responseBuffer,
+    PSTR("\n%s packet received (%d bytes):\n"),
+    currentConfig->name,
+    sizeof(packet)
+  );
   milightClient->formatPacket(packet, responseBuffer);
 
   server.send(200, TEXT_PLAIN, response);

+ 9 - 9
lib/WebServer/WebServer.h

@@ -16,30 +16,30 @@
 class WebServer : public ESP8266WebServer {
 public:
   WebServer(int port) : ESP8266WebServer(port) { }
-  
+
   bool matchesPattern(const String& pattern, const String& url);
   void onPattern(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn fn);
   void requireAuthentication(const String& username, const String& password);
   void disableAuthentication();
-  
-  inline bool clientConnected() { 
+
+  inline bool clientConnected() {
     return _currentClient && _currentClient.connected();
   }
-  
-  // These are copied / patched from ESP8266WebServer because they aren't 
+
+  // These are copied / patched from ESP8266WebServer because they aren't
   // virtual. (*barf*)
   void handleClient();
   void _handleRequest();
-  
+
   bool authenticationRequired() {
     return authEnabled;
   }
-  
+
 protected:
-  
+
   bool authEnabled;
   String username;
   String password;
 };
 
-#endif
+#endif

+ 44 - 1
src/main.cpp

@@ -6,7 +6,6 @@
 #include <GithubClient.h>
 #include <IntParsing.h>
 #include <Size.h>
-#include <MiLightClient.h>
 #include <MiLightRadioConfig.h>
 #include <MiLightHttpServer.h>
 #include <Settings.h>
@@ -16,6 +15,7 @@
 #include <MqttClient.h>
 #include <RGBConverter.h>
 #include <MiLightDiscoveryServer.h>
+#include <MiLightClient.h>
 
 WiFiManager wifiManager;
 
@@ -26,9 +26,11 @@ MiLightRadioFactory* radioFactory = NULL;
 MiLightHttpServer *httpServer = NULL;
 MqttClient* mqttClient = NULL;
 MiLightDiscoveryServer* discoveryServer = NULL;
+uint8_t currentRadioType = 0;
 
 int numUdpServers = 0;
 MiLightUdpServer** udpServers;
+WiFiUDP udpSeder;
 
 void initMilightUdpServers() {
   if (udpServers) {
@@ -63,6 +65,42 @@ void initMilightUdpServers() {
   }
 }
 
+void onPacketSentHandler(uint8_t* packet, const MiLightRadioConfig& config) {
+  StaticJsonBuffer<200> buffer;
+  JsonObject& result = buffer.createObject();
+  config.packetFormatter->parsePacket(packet, result);
+
+  uint16_t deviceId = result["device_id"];
+  uint16_t groupId = result["group_id"];
+  MiLightRadioType type = MiLightRadioConfig::fromString(result["device_type"])->type;
+
+  char output[200];
+  result.printTo(output);
+
+  if (mqttClient) {
+    mqttClient->sendUpdate(type, deviceId, groupId, output);
+  }
+}
+
+void handleListen() {
+  if (! settings.listenRepeats) {
+    return;
+  }
+
+  MiLightRadioConfig* config = MiLightRadioConfig::ALL_CONFIGS[
+    currentRadioType++ % MiLightRadioConfig::NUM_CONFIGS
+  ];
+  milightClient->prepare(*config);
+
+  for (size_t i = 0; i < settings.listenRepeats; i++) {
+    if (milightClient->available()) {
+      uint8_t readPacket[9];
+      milightClient->read(readPacket);
+
+      onPacketSentHandler(readPacket, *config);
+    }
+  }
+}
 
 void applySettings() {
   if (milightClient) {
@@ -83,6 +121,7 @@ void applySettings() {
 
   milightClient = new MiLightClient(radioFactory);
   milightClient->begin();
+  milightClient->onPacketSent(onPacketSentHandler);
 
   if (settings.mqttServer().length() > 0) {
     mqttClient = new MqttClient(settings, milightClient);
@@ -134,6 +173,8 @@ void setup() {
   httpServer->onSettingsSaved(applySettings);
   httpServer->on("/description.xml", HTTP_GET, []() { SSDP.schema(httpServer->client()); });
   httpServer->begin();
+
+  Serial.println(F("Setup complete"));
 }
 
 void loop() {
@@ -153,6 +194,8 @@ void loop() {
     discoveryServer->handleClient();
   }
 
+  handleListen();
+
   if (shouldRestart()) {
     Serial.println(F("Auto-restart triggered. Restarting..."));
     ESP.restart();