소스 검색

Merge pull request #481 from sidoh/feature/group_labels

* Use <> include

* Split out types libraries, adjust includes

* Split out BulbId

* Add setting for group labels

* Add REST route for device aliases

* Add field to UI to deal with device aliases

* Add test for bad input

* Add tests for MQTT device alias functionality

* Implement MQTT device alias support

* Update README

* Add test for MQTT updates using device aliases

* Set flag which fixes build with v2.1.4 of WebSockets library

* Restructure ruby MQTT client for tests.  Listen on wildcard topic, match on parsed parameters rather than listening on bound topic

* Don't need sleep anymore since packets are queued

* Gaps can be a bit longer now that packets are queued
Chris Mullins 6 년 전
부모
커밋
f176ee153b

+ 9 - 0
README.md

@@ -134,6 +134,14 @@ You can configure the LED pin from the web console.  Note that pin means the GPI
 
 If you want to wire up your own LED on a pin, such as on D2/GPIO4, put a wire from D2 to one side of a 220 ohm resister.  On the other side, connect it to the positive side (the longer wire) of a 3.3V LED.  Then connect the negative side of the LED (the shorter wire) to ground.  If you use a different voltage LED, or a high current LED, you will need to add a driver circuit.
 
+## Device Aliases
+
+You can configure aliases or labels for a given _(Device Type, Device ID, Group ID)_ tuple.  For example, you might want to call the RGB+CCT remote with the ID `0x1111` and the Group ID `1` to be called `living_room`.  Aliases are useful in a couple of different ways:
+
+* **In the UI**: the aliases dropdown shows all previously set aliases.  When one is selected, the corresponding Device ID, Device Type, and Group ID are selected.  This allows you to not need to memorize the ID parameters for each lighting device if you're controlling them through the UI.
+* **In the REST API**: standard CRUD verbs (`GET`, `PUT`, and `DELETE`) allow you to interact with aliases via the `/gateways/:device_alias` route.
+* **MQTT**: you can configure topics to listen for commands and publish updates/state using aliases rather than IDs.
+
 ## REST endpoints
 
 1. `GET /`. Opens web UI.
@@ -147,6 +155,7 @@ If you want to wire up your own LED on a pin, such as on D2/GPIO4, put a wire fr
 1. `PUT /gateways/:device_id/:device_type/:group_id`. Controls or sends commands to `:group_id` from `:device_id`. Accepts a JSON blob. The schema is documented below in the _Bulb commands_ section.
 1. `GET /gateways/:device_id/:device_type/:group_id`. Returns a JSON blob describing the state of the the provided group.
 1. `DELETE /gateways/:device_id/:device_type/:group_id`. Deletes state associated with the provided group.
+1. `(GET|PUT|DELETE) /gateways/:device_alias`.  Same as the previous three routes except acting on aliases instead of IDs.  404 is returned if the alias does not exist.
 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}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 2 - 2
dist/index.html.gz.h


+ 3 - 3
lib/LEDStatus/LEDStatus.cpp

@@ -1,4 +1,4 @@
-#include "LEDStatus.h"
+#include <LEDStatus.h>
 
 // constructor defines which pin the LED is attached to
 LEDStatus::LEDStatus(int8_t ledPin) {
@@ -80,7 +80,7 @@ void LEDStatus::oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count) {
   _timer = millis();
 }
 
-// call this function in your loop - it will return quickly after calculating if any changes need to 
+// call this function in your loop - it will return quickly after calculating if any changes need to
 // be made to the pin to flash the LED
 void LEDStatus::LEDStatus::handle() {
   // is a pin defined?
@@ -109,7 +109,7 @@ void LEDStatus::LEDStatus::handle() {
             }
             _oneshotCurrentlyOn = true;
             _timer += _oneshotOffMs;
-          }            
+          }
       }
   } else {
     // operate using continuous

+ 41 - 17
lib/MQTT/MqttClient.cpp

@@ -135,6 +135,7 @@ void MqttClient::subscribe() {
   topic.replace(":dec_device_id", "+");
   topic.replace(":group_id", "+");
   topic.replace(":device_type", "+");
+  topic.replace(":device_alias", "+");
 
 #ifdef MQTT_DEBUG
   printf_P(PSTR("MqttClient - subscribing to topic: %s\n"), topic.c_str());
@@ -184,27 +185,43 @@ void MqttClient::publishCallback(char* topic, byte* payload, int length) {
   TokenIterator topicIterator(topic, strlen(topic), '/');
   UrlTokenBindings tokenBindings(patternIterator, topicIterator);
 
-  if (tokenBindings.hasBinding("device_id")) {
-    deviceId = parseInt<uint16_t>(tokenBindings.get("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("device_alias")) {
+    String alias = tokenBindings.get("device_alias");
+    auto itr = settings.groupIdAliases.find(alias);
 
-  if (tokenBindings.hasBinding("device_type")) {
-    config = MiLightRemoteConfig::fromType(tokenBindings.get("device_type"));
-
-    if (config == NULL) {
-      Serial.println(F("MqttClient - ERROR: could not extract device_type from topic"));
+    if (itr == settings.groupIdAliases.end()) {
+      Serial.printf_P(PSTR("MqttClient - WARNING: could not find device alias: `%s'. Ignoring packet.\n"), alias.c_str());
       return;
+    } else {
+      BulbId bulbId = itr->second;
+
+      deviceId = bulbId.deviceId;
+      config = MiLightRemoteConfig::fromType(bulbId.deviceType);
+      groupId = bulbId.groupId;
     }
   } else {
-    Serial.println(F("MqttClient - WARNING: could not find device_type token.  Defaulting to FUT092.\n"));
+    if (tokenBindings.hasBinding("device_id")) {
+      deviceId = parseInt<uint16_t>(tokenBindings.get("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("device_type")) {
+      config = MiLightRemoteConfig::fromType(tokenBindings.get("device_type"));
+    } else {
+      Serial.println(F("MqttClient - WARNING: could not find device_type token.  Defaulting to FUT092.\n"));
+    }
+  }
+
+  if (config == NULL) {
+    Serial.println(F("MqttClient - ERROR: unknown device_type specified"));
+    return;
   }
 
   StaticJsonDocument<400> buffer;
@@ -234,6 +251,13 @@ inline void MqttClient::bindTopicString(
   topicPattern.replace(":dec_device_id", String(deviceId));
   topicPattern.replace(":group_id", String(groupId));
   topicPattern.replace(":device_type", remoteConfig.name);
+
+  auto it = settings.findAlias(remoteConfig.type, deviceId, groupId);
+  if (it != settings.groupIdAliases.end()) {
+    topicPattern.replace(":device_alias", it->first);
+  } else {
+    topicPattern.replace(":device_alias", "__unnamed_group");
+  }
 }
 
 String MqttClient::generateConnectionStatusMessage(const char* connectionStatus) {

+ 1 - 1
lib/MQTT/MqttClient.h

@@ -43,7 +43,7 @@ private:
     const bool retain = false
   );
 
-  inline static void bindTopicString(
+  inline void bindTopicString(
     String& topicPattern,
     const MiLightRemoteConfig& remoteConfig,
     const uint16_t deviceId,

+ 2 - 28
lib/MiLight/MiLightRemoteConfig.cpp

@@ -1,4 +1,5 @@
 #include <MiLightRemoteConfig.h>
+#include <MiLightRemoteType.h>
 
 /**
  * IMPORTANT NOTE: These should be in the same order as MiLightRemoteType.
@@ -13,34 +14,7 @@ const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = {
 };
 
 const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) {
-  if (type.equalsIgnoreCase("rgbw") || type.equalsIgnoreCase("fut096")) {
-    return &FUT096Config;
-  }
-
-  if (type.equalsIgnoreCase("cct") || type.equalsIgnoreCase("fut007")) {
-    return &FUT007Config;
-  }
-
-  if (type.equalsIgnoreCase("rgb_cct") || type.equalsIgnoreCase("fut092")) {
-    return &FUT092Config;
-  }
-
-  if (type.equalsIgnoreCase("fut089")) {
-    return &FUT089Config;
-  }
-
-  if (type.equalsIgnoreCase("rgb") || type.equalsIgnoreCase("fut098")) {
-    return &FUT098Config;
-  }
-
-  if (type.equalsIgnoreCase("v2_cct") || type.equalsIgnoreCase("fut091")) {
-    return &FUT091Config;
-  }
-
-  Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for type: "));
-  Serial.println(type);
-
-  return NULL;
+  return fromType(MiLightRemoteTypeHelpers::remoteTypeFromString(type));
 }
 
 const MiLightRemoteConfig* MiLightRemoteConfig::fromType(MiLightRemoteType type) {

+ 1 - 1
lib/MiLight/PacketFormatter.h

@@ -1,7 +1,7 @@
 #include <Arduino.h>
 #include <inttypes.h>
 #include <functional>
-#include <MiLightConstants.h>
+#include <MiLightRemoteType.h>
 #include <ArduinoJson.h>
 #include <GroupState.h>
 #include <GroupStateStore.h>

+ 2 - 35
lib/MiLightState/GroupState.cpp

@@ -2,6 +2,7 @@
 #include <Units.h>
 #include <MiLightRemoteConfig.h>
 #include <RGBConverter.h>
+#include <BulbId.h>
 
 static const char* BULB_MODE_NAMES[] = {
   "white",
@@ -11,6 +12,7 @@ static const char* BULB_MODE_NAMES[] = {
 };
 
 const BulbId DEFAULT_BULB_ID;
+
 static const GroupStateField ALL_PHYSICAL_FIELDS[] = {
   GroupStateField::BULB_MODE,
   GroupStateField::HUE,
@@ -49,41 +51,6 @@ const GroupState& GroupState::defaultState(MiLightRemoteType remoteType) {
   return state;
 }
 
-BulbId::BulbId()
-  : deviceId(0),
-    groupId(0),
-    deviceType(REMOTE_TYPE_UNKNOWN)
-{ }
-
-BulbId::BulbId(const BulbId &other)
-  : deviceId(other.deviceId),
-    groupId(other.groupId),
-    deviceType(other.deviceType)
-{ }
-
-BulbId::BulbId(
-  const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType
-)
-  : deviceId(deviceId),
-    groupId(groupId),
-    deviceType(deviceType)
-{ }
-
-void BulbId::operator=(const BulbId &other) {
-  deviceId = other.deviceId;
-  groupId = other.groupId;
-  deviceType = other.deviceType;
-}
-
-// determine if now BulbId's are the same.  This compared deviceID (the controller/remote ID) and
-// groupId (the group number on the controller, 1-4 or 1-8 depending), but ignores the deviceType
-// (type of controller/remote) as this doesn't directly affect the identity of the bulb
-bool BulbId::operator==(const BulbId &other) {
-  return deviceId == other.deviceId
-    && groupId == other.groupId
-    && deviceType == other.deviceType;
-}
-
 void GroupState::initFields() {
   state.fields._state                = 0;
   state.fields._brightness           = 0;

+ 3 - 13
lib/MiLightState/GroupState.h

@@ -1,9 +1,11 @@
 #include <stddef.h>
 #include <inttypes.h>
-#include <MiLightConstants.h>
+#include <MiLightRemoteType.h>
+#include <MiLightStatus.h>
 #include <MiLightRadioConfig.h>
 #include <GroupStateField.h>
 #include <ArduinoJson.h>
+#include <BulbId.h>
 
 #ifndef _GROUP_STATE_H
 #define _GROUP_STATE_H
@@ -11,18 +13,6 @@
 // enable to add debugging on state
 // #define DEBUG_STATE
 
-struct BulbId {
-  uint16_t deviceId;
-  uint8_t groupId;
-  MiLightRemoteType deviceType;
-
-  BulbId();
-  BulbId(const BulbId& other);
-  BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType);
-  bool operator==(const BulbId& other);
-  void operator=(const BulbId& other);
-};
-
 enum BulbMode {
   BULB_MODE_WHITE,
   BULB_MODE_COLOR,

+ 0 - 19
lib/Radio/MiLightConstants.h

@@ -1,19 +0,0 @@
-#ifndef _MILIGHT_BUTTONS
-#define _MILIGHT_BUTTONS
-
-enum MiLightRemoteType {
-  REMOTE_TYPE_UNKNOWN = 255,
-  REMOTE_TYPE_RGBW    = 0,
-  REMOTE_TYPE_CCT     = 1,
-  REMOTE_TYPE_RGB_CCT = 2,
-  REMOTE_TYPE_RGB     = 3,
-  REMOTE_TYPE_FUT089  = 4,
-  REMOTE_TYPE_FUT091  = 5
-};
-
-enum MiLightStatus {
-  ON = 0,
-  OFF = 1
-};
-
-#endif

+ 1 - 1
lib/Radio/MiLightRadioConfig.h

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

+ 45 - 0
lib/Settings/Settings.cpp

@@ -144,6 +144,49 @@ void Settings::patch(JsonObject parsedSettings) {
     JsonArray arr = parsedSettings["group_state_fields"];
     groupStateFields = JsonHelpers::jsonArrToVector<GroupStateField, const char*>(arr, GroupStateFieldHelpers::getFieldByName);
   }
+
+  if (parsedSettings.containsKey("group_id_aliases")) {
+    parseGroupIdAliases(parsedSettings);
+  }
+}
+
+std::map<String, BulbId>::const_iterator Settings::findAlias(MiLightRemoteType deviceType, uint16_t deviceId, uint8_t groupId) {
+  BulbId searchId{ deviceId, groupId, deviceType };
+
+  for (auto it = groupIdAliases.begin(); it != groupIdAliases.end(); ++it) {
+    if (searchId == it->second) {
+      return it;
+    }
+  }
+
+  return groupIdAliases.end();
+}
+
+void Settings::parseGroupIdAliases(JsonObject json) {
+  JsonObject aliases = json["group_id_aliases"];
+  groupIdAliases.clear();
+
+  for (JsonPair kv : aliases) {
+    JsonArray bulbIdProps = kv.value();
+    BulbId bulbId = {
+      bulbIdProps[1].as<uint16_t>(),
+      bulbIdProps[2].as<uint8_t>(),
+      MiLightRemoteTypeHelpers::remoteTypeFromString(bulbIdProps[0].as<String>())
+    };
+    groupIdAliases[kv.key().c_str()] = bulbId;
+  }
+}
+
+void Settings::dumpGroupIdAliases(JsonObject json) {
+  JsonObject aliases = json.createNestedObject("group_id_aliases");
+
+  for (std::map<String, BulbId>::iterator itr = groupIdAliases.begin(); itr != groupIdAliases.end(); ++itr) {
+    JsonArray bulbProps = aliases.createNestedArray(itr->first);
+    BulbId bulbId = itr->second;
+    bulbProps.add(MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType));
+    bulbProps.add(bulbId.deviceId);
+    bulbProps.add(bulbId.groupId);
+  }
 }
 
 void Settings::load(Settings& settings) {
@@ -246,6 +289,8 @@ void Settings::serialize(Print& stream, const bool prettyPrint) {
   JsonArray groupStateFieldArr = root.createNestedArray("group_state_fields");
   JsonHelpers::vectorToJsonArr<GroupStateField, const char*>(groupStateFieldArr, groupStateFields, GroupStateFieldHelpers::getFieldName);
 
+  dumpGroupIdAliases(root.as<JsonObject>());
+
   if (prettyPrint) {
     serializeJsonPretty(root, stream);
   } else {

+ 10 - 0
lib/Settings/Settings.h

@@ -7,8 +7,13 @@
 #include <Size.h>
 #include <LEDStatus.h>
 #include <AuthProviders.h>
+
+#include <MiLightRemoteType.h>
+#include <BulbId.h>
+
 #include <vector>
 #include <memory>
+#include <map>
 
 #ifndef _SETTINGS_H_INCLUDED
 #define _SETTINGS_H_INCLUDED
@@ -134,6 +139,7 @@ public:
   void patch(JsonObject obj);
   String mqttServer();
   uint16_t mqttPort();
+  std::map<String, BulbId>::const_iterator findAlias(MiLightRemoteType deviceType, uint16_t deviceId, uint8_t groupId);
 
   String adminUsername;
   String adminPassword;
@@ -176,10 +182,14 @@ public:
   String wifiStaticIPNetmask;
   String wifiStaticIPGateway;
   size_t packetRepeatsPerLoop;
+  std::map<String, BulbId> groupIdAliases;
 
 protected:
   size_t _autoRestartPeriod;
 
+  void parseGroupIdAliases(JsonObject json);
+  void dumpGroupIdAliases(JsonObject json);
+
   template <typename T>
   void setIfPresent(JsonObject obj, const char* key, T& var) {
     if (obj.containsKey(key)) {

+ 36 - 0
lib/Types/BulbId.cpp

@@ -0,0 +1,36 @@
+#include <BulbId.h>
+
+BulbId::BulbId()
+  : deviceId(0),
+    groupId(0),
+    deviceType(REMOTE_TYPE_UNKNOWN)
+{ }
+
+BulbId::BulbId(const BulbId &other)
+  : deviceId(other.deviceId),
+    groupId(other.groupId),
+    deviceType(other.deviceType)
+{ }
+
+BulbId::BulbId(
+  const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType
+)
+  : deviceId(deviceId),
+    groupId(groupId),
+    deviceType(deviceType)
+{ }
+
+void BulbId::operator=(const BulbId &other) {
+  deviceId = other.deviceId;
+  groupId = other.groupId;
+  deviceType = other.deviceType;
+}
+
+// determine if now BulbId's are the same.  This compared deviceID (the controller/remote ID) and
+// groupId (the group number on the controller, 1-4 or 1-8 depending), but ignores the deviceType
+// (type of controller/remote) as this doesn't directly affect the identity of the bulb
+bool BulbId::operator==(const BulbId &other) {
+  return deviceId == other.deviceId
+    && groupId == other.groupId
+    && deviceType == other.deviceType;
+}

+ 16 - 0
lib/Types/BulbId.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include <stdint.h>
+#include <MiLightRemoteType.h>
+
+struct BulbId {
+  uint16_t deviceId;
+  uint8_t groupId;
+  MiLightRemoteType deviceType;
+
+  BulbId();
+  BulbId(const BulbId& other);
+  BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType);
+  bool operator==(const BulbId& other);
+  void operator=(const BulbId& other);
+};

+ 0 - 19
lib/Types/MiLightConstants.h

@@ -1,19 +0,0 @@
-#ifndef _MILIGHT_BUTTONS
-#define _MILIGHT_BUTTONS
-
-enum MiLightRemoteType {
-  REMOTE_TYPE_UNKNOWN = 255,
-  REMOTE_TYPE_RGBW    = 0,
-  REMOTE_TYPE_CCT     = 1,
-  REMOTE_TYPE_RGB_CCT = 2,
-  REMOTE_TYPE_RGB     = 3,
-  REMOTE_TYPE_FUT089  = 4,
-  REMOTE_TYPE_FUT091  = 5
-};
-
-enum MiLightStatus {
-  ON = 0,
-  OFF = 1
-};
-
-#endif

+ 54 - 0
lib/Types/MiLightRemoteType.cpp

@@ -0,0 +1,54 @@
+#include <MiLightRemoteType.h>
+#include <Arduino.h>
+
+const MiLightRemoteType MiLightRemoteTypeHelpers::remoteTypeFromString(const String& type) {
+  if (type.equalsIgnoreCase("rgbw") || type.equalsIgnoreCase("fut096")) {
+    return REMOTE_TYPE_RGBW;
+  }
+
+  if (type.equalsIgnoreCase("cct") || type.equalsIgnoreCase("fut007")) {
+    return REMOTE_TYPE_CCT;
+  }
+
+  if (type.equalsIgnoreCase("rgb_cct") || type.equalsIgnoreCase("fut092")) {
+    return REMOTE_TYPE_RGB_CCT;
+  }
+
+  if (type.equalsIgnoreCase("fut089")) {
+    return REMOTE_TYPE_FUT089;
+  }
+
+  if (type.equalsIgnoreCase("rgb") || type.equalsIgnoreCase("fut098")) {
+    return REMOTE_TYPE_RGB;
+  }
+
+  if (type.equalsIgnoreCase("v2_cct") || type.equalsIgnoreCase("fut091")) {
+    return REMOTE_TYPE_FUT091;
+  }
+
+  Serial.print(F("remoteTypeFromString: ERROR - tried to fetch remote config for type: "));
+  Serial.println(type);
+
+  return REMOTE_TYPE_UNKNOWN;
+}
+
+const String MiLightRemoteTypeHelpers::remoteTypeToString(const MiLightRemoteType type) {
+  switch (type) {
+    case REMOTE_TYPE_RGBW:
+      return "rgbw";
+    case REMOTE_TYPE_CCT:
+      return "cct";
+    case REMOTE_TYPE_RGB_CCT:
+      return "rgb_cct";
+    case REMOTE_TYPE_FUT089:
+      return "fut089";
+    case REMOTE_TYPE_RGB:
+      return "rgb";
+    case REMOTE_TYPE_FUT091:
+      return "fut091";
+    default:
+      Serial.print(F("remoteTypeToString: ERROR - tried to fetch remote config name for unknown type: "));
+      Serial.println(type);
+      return "unknown";
+  }
+}

+ 19 - 0
lib/Types/MiLightRemoteType.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include <Arduino.h>
+
+enum MiLightRemoteType {
+  REMOTE_TYPE_UNKNOWN = 255,
+  REMOTE_TYPE_RGBW    = 0,
+  REMOTE_TYPE_CCT     = 1,
+  REMOTE_TYPE_RGB_CCT = 2,
+  REMOTE_TYPE_RGB     = 3,
+  REMOTE_TYPE_FUT089  = 4,
+  REMOTE_TYPE_FUT091  = 5
+};
+
+class MiLightRemoteTypeHelpers {
+public:
+  static const MiLightRemoteType remoteTypeFromString(const String& type);
+  static const String remoteTypeToString(const MiLightRemoteType type);
+};

+ 6 - 0
lib/Types/MiLightStatus.h

@@ -0,0 +1,6 @@
+#pragma once
+
+enum MiLightStatus {
+  ON = 0,
+  OFF = 1
+};

+ 81 - 13
lib/WebServer/MiLightHttpServer.cpp

@@ -47,6 +47,13 @@ void MiLightHttpServer::begin() {
     .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetGroup, this, _1));
 
   server
+    .buildHandler("/gateways/:device_alias")
+    .on(HTTP_PUT, std::bind(&MiLightHttpServer::handleUpdateGroupAlias, this, _1))
+    .on(HTTP_POST, std::bind(&MiLightHttpServer::handleUpdateGroupAlias, this, _1))
+    .on(HTTP_DELETE, std::bind(&MiLightHttpServer::handleDeleteGroupAlias, this, _1))
+    .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetGroupAlias, this, _1));
+
+  server
     .buildHandler("/raw_commands/:type")
     .on(HTTP_ANY, std::bind(&MiLightHttpServer::handleSendRaw, this, _1));
 
@@ -311,6 +318,12 @@ void MiLightHttpServer::handleListenGateway(RequestContext& request) {
 }
 
 void MiLightHttpServer::sendGroupState(BulbId& bulbId, GroupState* state, RichHttp::Response& response) {
+  // Wait for packet queue to flush out.  State will not have been updated before that.
+  // Bit hacky to call loop outside of main loop, but should be fine.
+  while (packetSender->isSending()) {
+    packetSender->loop();
+  }
+
   JsonObject obj = response.json.to<JsonObject>();
 
   if (state != NULL) {
@@ -319,6 +332,24 @@ void MiLightHttpServer::sendGroupState(BulbId& bulbId, GroupState* state, RichHt
   }
 }
 
+void MiLightHttpServer::_handleGetGroup(BulbId bulbId, RequestContext& request) {
+  sendGroupState(bulbId, stateStore->get(bulbId), request.response);
+}
+
+void MiLightHttpServer::handleGetGroupAlias(RequestContext& request) {
+  const String alias = request.pathVariables.get("device_alias");
+
+  std::map<String, BulbId>::iterator it = settings.groupIdAliases.find(alias);
+
+  if (it == settings.groupIdAliases.end()) {
+    request.response.setCode(404);
+    request.response.json[F("error")] = F("Device alias not found");
+    return;
+  }
+
+  _handleGetGroup(it->second, request);
+}
+
 void MiLightHttpServer::handleGetGroup(RequestContext& request) {
   const String _deviceId = request.pathVariables.get("device_id");
   uint8_t _groupId = atoi(request.pathVariables.get("group_id"));
@@ -333,7 +364,7 @@ void MiLightHttpServer::handleGetGroup(RequestContext& request) {
   }
 
   BulbId bulbId(parseInt<uint16_t>(_deviceId), _groupId, _remoteType->type);
-  sendGroupState(bulbId, stateStore->get(bulbId), request.response);
+  _handleGetGroup(bulbId, request);
 }
 
 void MiLightHttpServer::handleDeleteGroup(RequestContext& request) {
@@ -350,6 +381,24 @@ void MiLightHttpServer::handleDeleteGroup(RequestContext& request) {
   }
 
   BulbId bulbId(parseInt<uint16_t>(_deviceId), _groupId, _remoteType->type);
+  _handleDeleteGroup(bulbId, request);
+}
+
+void MiLightHttpServer::handleDeleteGroupAlias(RequestContext& request) {
+  const String alias = request.pathVariables.get("device_alias");
+
+  std::map<String, BulbId>::iterator it = settings.groupIdAliases.find(alias);
+
+  if (it == settings.groupIdAliases.end()) {
+    request.response.setCode(404);
+    request.response.json[F("error")] = F("Device alias not found");
+    return;
+  }
+
+  _handleDeleteGroup(it->second, request);
+}
+
+void MiLightHttpServer::_handleDeleteGroup(BulbId bulbId, RequestContext& request) {
   stateStore->clear(bulbId);
 
   if (groupDeletedHandler != NULL) {
@@ -359,13 +408,36 @@ void MiLightHttpServer::handleDeleteGroup(RequestContext& request) {
   request.response.json["success"] = true;
 }
 
+void MiLightHttpServer::handleUpdateGroupAlias(RequestContext& request) {
+  const String alias = request.pathVariables.get("device_alias");
+
+  std::map<String, BulbId>::iterator it = settings.groupIdAliases.find(alias);
+
+  if (it == settings.groupIdAliases.end()) {
+    request.response.setCode(404);
+    request.response.json[F("error")] = F("Device alias not found");
+    return;
+  }
+
+  BulbId& bulbId = it->second;
+  const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(bulbId.deviceType);
+
+  if (config == NULL) {
+    char buffer[40];
+    sprintf_P(buffer, PSTR("Unknown device type: %s"), bulbId.deviceType);
+    request.response.setCode(400);
+    request.response.json["error"] = buffer;
+    return;
+  }
+
+  milightClient->prepare(config, bulbId.deviceId, bulbId.groupId);
+  handleRequest(request.getJsonBody().as<JsonObject>());
+  sendGroupState(bulbId, stateStore->get(bulbId), request.response);
+}
+
 void MiLightHttpServer::handleUpdateGroup(RequestContext& request) {
   JsonObject reqObj = request.getJsonBody().as<JsonObject>();
 
-  milightClient->setRepeatsOverride(
-    settings.httpRepeatFactor * settings.packetRepeats
-  );
-
   String _deviceIds = request.pathVariables.get("device_id");
   String _groupIds = request.pathVariables.get("group_id");
   String _remoteTypes = request.pathVariables.get("type");
@@ -411,15 +483,7 @@ void MiLightHttpServer::handleUpdateGroup(RequestContext& request) {
     }
   }
 
-  milightClient->clearRepeatsOverride();
-
   if (groupCount == 1) {
-    // Wait for packet queue to flush out.  State will not have been updated before that.
-    // Bit hacky to call loop outside of main loop, but should be fine.
-    while (packetSender->isSending()) {
-      packetSender->loop();
-    }
-
     sendGroupState(foundBulbId, stateStore->get(foundBulbId), request.response);
   } else {
     request.response.json["success"] = true;
@@ -427,7 +491,11 @@ void MiLightHttpServer::handleUpdateGroup(RequestContext& request) {
 }
 
 void MiLightHttpServer::handleRequest(const JsonObject& request) {
+  milightClient->setRepeatsOverride(
+    settings.httpRepeatFactor * settings.packetRepeats
+  );
   milightClient->update(request);
+  milightClient->clearRepeatsOverride();
 }
 
 void MiLightHttpServer::handleSendRaw(RequestContext& request) {

+ 9 - 1
lib/WebServer/MiLightHttpServer.h

@@ -67,9 +67,17 @@ protected:
   void handleFirmwarePost();
   void handleListenGateway(RequestContext& request);
   void handleSendRaw(RequestContext& request);
+
   void handleUpdateGroup(RequestContext& request);
-  void handleDeleteGroup(RequestContext& request);
+  void handleUpdateGroupAlias(RequestContext& request);
+
   void handleGetGroup(RequestContext& request);
+  void handleGetGroupAlias(RequestContext& request);
+  void _handleGetGroup(BulbId bulbId, RequestContext& request);
+
+  void handleDeleteGroup(RequestContext& request);
+  void handleDeleteGroupAlias(RequestContext& request);
+  void _handleDeleteGroup(BulbId bulbId, RequestContext& request);
 
   void handleRequest(const JsonObject& request);
   void handleWsEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length);

+ 1 - 0
platformio.ini

@@ -30,6 +30,7 @@ test_ignore = remote
 upload_speed = 460800
 build_flags =
   !python .get_version.py
+  -D USING_AXTLS
   -D MQTT_MAX_PACKET_SIZE=250
   -D HTTP_UPLOAD_BUFLEN=128
   -D FIRMWARE_NAME=milight-hub

+ 2 - 1
src/main.cpp

@@ -8,10 +8,12 @@
 #include <IntParsing.h>
 #include <Size.h>
 #include <LinkedList.h>
+#include <LEDStatus.h>
 #include <GroupStateStore.h>
 #include <MiLightRadioConfig.h>
 #include <MiLightRemoteConfig.h>
 #include <MiLightHttpServer.h>
+#include <MiLightRemoteType.h>
 #include <Settings.h>
 #include <MiLightUdpServer.h>
 #include <ESP8266mDNS.h>
@@ -21,7 +23,6 @@
 #include <MiLightDiscoveryServer.h>
 #include <MiLightClient.h>
 #include <BulbStateUpdater.h>
-#include <LEDStatus.h>
 #include <RadioSwitchboard.h>
 #include <PacketSender.h>
 

+ 8 - 5
test/remote/helpers/mqtt_helpers.rb

@@ -5,7 +5,7 @@ module MqttHelpers
     ENV.fetch('ESPMH_MQTT_TOPIC_PREFIX')
   end
 
-  def mqtt_parameters
+  def mqtt_parameters(overrides = {})
     topic_prefix = mqtt_topic_prefix()
 
     {
@@ -15,17 +15,20 @@ module MqttHelpers
       mqtt_topic_pattern: "#{topic_prefix}commands/:device_id/:device_type/:group_id",
       mqtt_state_topic_pattern: "#{topic_prefix}state/:device_id/:device_type/:group_id",
       mqtt_update_topic_pattern: "#{topic_prefix}updates/:device_id/:device_type/:group_id"
-    }
+    }.merge(overrides)
   end
 
-  def create_mqtt_client
-    params = mqtt_parameters
+  def create_mqtt_client(overrides = {})
+    params =
+      mqtt_parameters
+      .merge({topic_prefix: mqtt_topic_prefix()})
+      .merge(overrides)
 
     MqttClient.new(
       params[:mqtt_server],
       params[:mqtt_username],
       params[:mqtt_password],
-      mqtt_topic_prefix()
+      params[:topic_prefix]
     )
   end
 end

+ 14 - 13
test/remote/lib/mqtt_client.rb

@@ -48,24 +48,25 @@ class MqttClient
   end
 
   def on_id_message(path, id_params, timeout, &block)
-    sub_topic = "#{@topic_prefix}#{path}/#{id_topic_suffix(id_params)}"
+    sub_topic = "#{@topic_prefix}#{path}/#{id_topic_suffix(nil)}"
 
     on_message(sub_topic, timeout) do |topic, message|
       topic_parts = topic.split('/')
+      topic_id_params = {
+        id: topic_parts[2].to_i(16),
+        type: topic_parts[3],
+        group_id: topic_parts[4].to_i,
+        unparsed_id: topic_parts[2]
+      }
+
+      if !id_params || %w(id type group_id).all? { |k| k=k.to_sym; topic_id_params[k] == id_params[k] }
+        begin
+          message = JSON.parse(message)
+        rescue JSON::ParserError => e
+        end
 
-      begin
-        message = JSON.parse(message)
-      rescue JSON::ParserError => e
+        yield( topic_id_params, message )
       end
-
-      yield(
-        {
-          id: topic_parts[2].to_i(16),
-          type: topic_parts[3],
-          group_id: topic_parts[4].to_i
-        },
-        message
-      )
     end
   end
 

+ 143 - 44
test/remote/spec/mqtt_spec.rb

@@ -4,7 +4,9 @@ RSpec.describe 'MQTT' do
   before(:all) do
     @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE'))
     @client.upload_json('/settings', 'settings.json')
+  end
 
+  before(:each) do
     mqtt_params = mqtt_parameters()
     @updates_topic = mqtt_params[:updates_topic]
     @topic_prefix = mqtt_topic_prefix()
@@ -13,9 +15,7 @@ RSpec.describe 'MQTT' do
       '/settings',
       mqtt_params
     )
-  end
 
-  before(:each) do
     @id_params = {
       id: @client.generate_id,
       type: 'rgb_cct',
@@ -122,7 +122,7 @@ RSpec.describe 'MQTT' do
       @client.patch_state({status: 'off'}, @id_params)
 
       @mqtt_client.on_state(@id_params) do |id, message|
-        seen_state = (id == @id_params && desired_state.all? { |k,v| v == message[k] })
+        seen_state = desired_state.all? { |k,v| v == message[k] }
       end
 
       @mqtt_client.patch_state(@id_params, desired_state)
@@ -182,7 +182,6 @@ RSpec.describe 'MQTT' do
 
       (1..num_updates).each do |i|
         @mqtt_client.patch_state(@id_params, level: i)
-        sleep 0.1
       end
 
       @mqtt_client.wait_for_listeners
@@ -191,7 +190,7 @@ RSpec.describe 'MQTT' do
       avg = update_timestamp_gaps.sum / update_timestamp_gaps.length
 
       expect(update_timestamp_gaps.length).to be >= 3
-      expect((avg - 0.5).abs).to be < 0.02
+      expect((avg - 0.5).abs).to be < 0.15, "Should be within margin of error of rate limit"
     end
   end
 
@@ -287,7 +286,7 @@ RSpec.describe 'MQTT' do
     end
   end
 
-  context ':hex_device_id for update/state topics' do
+  describe ':hex_device_id for update/state topics' do
     before(:all) do
       @client.put(
         '/settings',
@@ -304,36 +303,38 @@ RSpec.describe 'MQTT' do
       )
     end
 
-    it 'should publish updates with hexadecimal device ID' do
-      seen_update = false
+    context 'state and updates' do
+      it 'should publish updates with hexadecimal device ID' do
+        seen_update = false
 
-      @mqtt_client.on_update(@id_params) do |id, message|
-        seen_update = (message['state'] == 'ON')
-      end
+        @mqtt_client.on_update(@id_params) do |id, message|
+          seen_update = (message['state'] == 'ON')
+        end
 
-      # Will use hex by default
-      @mqtt_client.patch_state(@id_params, status: 'ON')
-      @mqtt_client.wait_for_listeners
+        # Will use hex by default
+        @mqtt_client.patch_state(@id_params, status: 'ON')
+        @mqtt_client.wait_for_listeners
 
-      expect(seen_update).to eq(true)
-    end
+        expect(seen_update).to eq(true)
+      end
 
-    it 'should publish state with hexadecimal device ID' do
-      seen_state = false
+      it 'should publish state with hexadecimal device ID' do
+        seen_state = false
 
-      @mqtt_client.on_state(@id_params) do |id, message|
-        seen_state = (message['status'] == 'ON')
-      end
+        @mqtt_client.on_state(@id_params) do |id, message|
+          seen_state = (message['status'] == 'ON')
+        end
 
-      # Will use hex by default
-      @mqtt_client.patch_state(@id_params, status: 'ON')
-      @mqtt_client.wait_for_listeners
+        # Will use hex by default
+        @mqtt_client.patch_state(@id_params, status: 'ON')
+        @mqtt_client.wait_for_listeners
 
-      expect(seen_state).to eq(true)
+        expect(seen_state).to eq(true)
+      end
     end
   end
 
-  context ':dec_device_id for update/state topics' do
+  describe ':dec_device_id for update/state topics' do
     before(:all) do
       @client.put(
         '/settings',
@@ -350,34 +351,132 @@ RSpec.describe 'MQTT' do
       )
     end
 
-    it 'should publish updates with hexadecimal device ID' do
-      seen_update = false
-      @id_params = @id_params.merge(id_format: 'decimal')
+    context 'state and updates' do
+      it 'should publish updates with hexadecimal device ID' do
+        seen_update = false
+        @id_params = @id_params.merge(id_format: 'decimal')
 
-      @mqtt_client.on_update(@id_params) do |id, message|
-        seen_update = (message['state'] == 'ON')
+        @mqtt_client.on_update(@id_params) do |id, message|
+          seen_update = (message['state'] == 'ON')
+        end
+
+        # Will use hex by default
+        @mqtt_client.patch_state(@id_params, status: 'ON')
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_update).to eq(true)
       end
 
-      # Will use hex by default
-      @mqtt_client.patch_state(@id_params, status: 'ON')
-      @mqtt_client.wait_for_listeners
+      it 'should publish state with hexadecimal device ID' do
+        seen_state = false
+        @id_params = @id_params.merge(id_format: 'decimal')
+
+        @mqtt_client.on_state(@id_params) do |id, message|
+          seen_state = (message['status'] == 'ON')
+        end
+
+        sleep 1
 
-      expect(seen_update).to eq(true)
+        # Will use hex by default
+        @mqtt_client.patch_state(@id_params, status: 'ON')
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_state).to eq(true)
+      end
     end
+  end
 
-    it 'should publish state with hexadecimal device ID' do
-      seen_state = false
-      @id_params = @id_params.merge(id_format: 'decimal')
+  describe 'device aliases' do
+    before(:each) do
+      @aliases_topic = "#{mqtt_topic_prefix()}commands/:device_alias"
+      @client.patch_settings(
+        mqtt_topic_pattern: @aliases_topic,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        }
+      )
+      @client.delete_state(@id_params)
+    end
 
-      @mqtt_client.on_state(@id_params) do |id, message|
-        seen_state = (message['status'] == 'ON')
+    context ':device_alias token' do
+      it 'should accept it for command topic' do
+        @client.patch_settings(mqtt_topic_pattern: @aliases_topic)
+
+        @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
+
+        sleep(1)
+
+        state = @client.get_state(@id_params)
+        expect(state['status']).to eq('ON')
       end
 
-      # Will use hex by default
-      @mqtt_client.patch_state(@id_params, status: 'ON')
-      @mqtt_client.wait_for_listeners
+      it 'should support publishing state to device alias topic' do
+        @client.patch_settings(
+          mqtt_topic_pattern: @aliases_topic,
+          mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias"
+        )
+
+        seen_alias = nil
+        seen_state = nil
+
+        @mqtt_client.on_message("#{mqtt_topic_prefix()}state/+") do |topic, message|
+          parts = topic.split('/')
+
+          seen_alias = parts.last
+          seen_state = JSON.parse(message)
+
+          seen_alias == 'test_group'
+        end
+        @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
+
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_alias).to eq('test_group')
+        expect(seen_state['status']).to eq('ON')
+      end
+
+      it 'should support publishing updates to device alias topic' do
+        @client.patch_settings(
+          mqtt_topic_pattern: @aliases_topic,
+          mqtt_update_topic_pattern: "#{mqtt_topic_prefix()}updates/:device_alias"
+        )
+
+        seen_alias = nil
+        seen_state = nil
+
+        @mqtt_client.on_message("#{mqtt_topic_prefix()}updates/+") do |topic, message|
+          parts = topic.split('/')
+
+          seen_alias = parts.last
+          seen_state = JSON.parse(message)
 
-      expect(seen_state).to eq(true)
+          seen_alias == 'test_group'
+        end
+        @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON')
+
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_alias).to eq('test_group')
+        expect(seen_state['state']).to eq('ON')
+      end
+
+      it 'should delete retained alias messages' do
+        seen_empty_message = false
+
+        @client.patch_settings(mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias")
+        @client.patch_state(@id_params, status: 'ON')
+
+        @mqtt_client.on_message("#{mqtt_topic_prefix()}state/test_group") do |topic, message|
+          seen_empty_message = message.empty?
+        end
+
+        @client.patch_state(@id_params, hue: 100)
+        @client.delete_state(@id_params)
+
+        @mqtt_client.wait_for_listeners
+
+        expect(seen_empty_message).to eq(true)
+      end
     end
   end
 end

+ 63 - 1
test/remote/spec/rest_spec.rb

@@ -106,8 +106,70 @@ RSpec.describe 'REST Server' do
       sleep(1)
 
       state = @client.get_state(id)
-      puts state.inspect
       expect(state['status']).to eq('ON')
     end
   end
+
+  context 'device aliases' do
+    before(:all) do
+      @device_id = {
+        id: @client.generate_id,
+        type: 'rgb_cct',
+        group_id: 1
+      }
+      @alias = 'test'
+
+      @client.patch_settings(
+        group_id_aliases: {
+          @alias => [
+            @device_id[:type],
+            @device_id[:id],
+            @device_id[:group_id]
+          ]
+        }
+      )
+
+      @client.delete_state(@device_id)
+    end
+
+    it 'should respond with a 404 for an alias that doesn\'t exist' do
+      expect {
+        @client.put("/gateways/__#{@alias}", status: 'on')
+      }.to raise_error(Net::HTTPServerException)
+    end
+
+    it 'should update state for known alias' do
+      path = "/gateways/#{@alias}"
+
+      @client.put(path, status: 'ON', hue: 100)
+      state = @client.get(path)
+
+      expect(state['status']).to eq('ON')
+      expect(state['hue']).to eq(100)
+
+      # ensure state for the non-aliased ID is the same
+      state = @client.get_state(@device_id)
+
+      expect(state['status']).to eq('ON')
+      expect(state['hue']).to eq(100)
+    end
+
+    it 'should handle saving bad input gracefully' do
+      values_to_try = [
+        'string',
+        123,
+        [ ],
+        { 'test' => [ 'rgb_cct' ] },
+        { 'test' => [ 'rgb_cct', 1 ] },
+        { 'test' => [ 'rgb_cct', '1', 2 ] },
+        { 'test' => [ 'abc' ] }
+      ]
+
+      values_to_try.each do |v|
+        expect {
+          @client.patch_settings(group_id_aliases: v)
+        }.to_not raise_error
+      end
+    end
+  end
 end

+ 17 - 0
test/remote/spec/settings_spec.rb

@@ -128,6 +128,23 @@ RSpec.describe 'Settings' do
     end
   end
 
+  context 'group id labels' do
+    it 'should store ID labels' do
+      id = 1
+
+      aliases = Hash[
+        StateHelpers::ALL_REMOTE_TYPES.map do |remote_type|
+          ["test_#{id += 1}", [remote_type, id, 1]]
+        end
+      ]
+
+      @client.patch_settings(group_id_aliases: aliases)
+      settings = @client.get('/settings')
+
+      expect(settings['group_id_aliases']).to eq(aliases)
+    end
+  end
+
   context 'static ip' do
     it 'should boot with static IP when applied' do
       static_ip = ENV.fetch('ESPMH_STATIC_IP')

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

@@ -73,7 +73,7 @@ RSpec.describe 'UDP servers' do
       seen_state = false
 
       @mqtt_client.on_state(@id_params) do |id, message|
-        seen_state = (id == @id_params && desired_state.all? { |k,v| v == message[k] })
+        seen_state = desired_state.all? { |k,v| v == message[k] }
       end
       @udp_client.group(@id_params[:group_id]).on.brightness(48)
       @mqtt_client.wait_for_listeners
@@ -92,7 +92,7 @@ RSpec.describe 'UDP servers' do
       seen_state = false
 
       @mqtt_client.on_state(@id_params) do |id, message|
-        seen_state = (id == @id_params && desired_state.all? { |k,v| v == message[k] })
+        seen_state = desired_state.all? { |k,v| v == message[k] }
       end
 
       @udp_client.group(@id_params[:group_id])

+ 11 - 0
web/src/index.html

@@ -66,6 +66,17 @@
   </nav>
 
   <div class="container" id="content">
+    <div class="row" id="device-id-aliases-row">
+      <div class="col-sm-3">
+        <label for="deviceAliases" id="device-id-aliases">
+          Device Name
+        </label>
+        <select id="deviceAliases" placeholder="Create alias...">
+				</select>
+      </div>
+    </div>
+    <div></div>
+
     <div class="row">
       <div class="col-sm-3">
         <label for="deviceId" id="device-id-label">

+ 192 - 18
web/src/js/script.js

@@ -362,9 +362,16 @@ var UDP_PROTOCOL_VERSIONS = [ 5, 6 ];
 var DEFAULT_UDP_PROTOCL_VERSION = 5;
 
 var selectize;
+var aliasesSelectize;
 var sniffing = false;
 var loadingSettings = false;
 
+// When true, will not attempt to load group parameters
+var updatingGroupId = false;
+
+// When true, will not attempt to update group parameters
+var updatingAlias = false;
+
 // don't attempt websocket if we are debugging locally
 if (location.hostname != "") {
   var webSocket = new WebSocket("ws://" + location.hostname + ":81");
@@ -380,9 +387,60 @@ var toHex = function(v) {
   return "0x" + (v).toString(16).toUpperCase();
 }
 
+var updateGroupId = function(params) {
+  updatingGroupId = true;
+
+  selectize.setValue(params.deviceId);
+  setGroupId(params.groupId);
+  setMode(params.deviceType);
+
+  updatingGroupId = false;
+
+  refreshGroupState();
+}
+
+var setGroupId = function(value) {
+  $('#groupId input[data-value="' + value + '"]').click();
+}
+
+var setMode = function(value) {
+  $('#mode li[data-value="' + value + '"]').click();
+}
+
+var getCurrentDeviceId = function() {
+  // return $('#deviceId option:selected').val();
+  return parseInt(selectize.getValue());
+};
+
+var getCurrentGroupId = function() {
+  return $('#groupId .active input').data('value');
+}
+
+var findAndSelectAlias = function() {
+  if (!updatingGroupId) {
+    var params = {
+      deviceType: getCurrentMode(),
+      deviceId: getCurrentDeviceId(),
+      groupId: getCurrentGroupId()
+    };
+
+    var foundAlias = Object.entries(aliasesSelectize.options).filter(function(x) {
+      return _.isEqual(x[1].savedGroupParams, params);
+    });
+
+    updatingAlias = true;
+    if (foundAlias.length > 0) {
+      aliasesSelectize.setValue(foundAlias[0]);
+    } else {
+      aliasesSelectize.clear();
+    }
+    updatingAlias = false;
+  }
+}
+
 var activeUrl = function() {
-  var deviceId = $('#deviceId option:selected').val()
-    , groupId = $('#groupId .active input').data('value')
+  var deviceId = getCurrentDeviceId()
+    , groupId = getCurrentGroupId()
     , mode = getCurrentMode();
 
   if (deviceId == "") {
@@ -400,6 +458,17 @@ var activeUrl = function() {
   return "/gateways/" + deviceId + "/" + mode + "/" + groupId;
 }
 
+var refreshGroupState = function() {
+  if (! updatingGroupId) {
+    $.getJSON(
+      activeUrl(),
+      function(e) {
+        handleStateUpdate(e);
+      }
+    );
+  }
+}
+
 var getCurrentMode = function() {
   return $('#mode li.active').data('value');
 };
@@ -498,12 +567,34 @@ var loadSettings = function() {
       }
     });
 
+    if (val.group_id_aliases) {
+      aliasesSelectize.clearOptions();
+      Object.entries(val.group_id_aliases).forEach(function(entry) {
+        var label = entry[0]
+          , groupParams = entry[1]
+          , savedParams = {
+                deviceType: groupParams[0],
+                deviceId: groupParams[1],
+                groupId: groupParams[2]
+            }
+          ;
+
+        aliasesSelectize.addOption({
+          text: label,
+          value: label,
+          savedGroupParams: savedParams
+        });
+
+        aliasesSelectize.refreshOptions(false);
+      });
+    }
+
     if (val.device_ids) {
       selectize.clearOptions();
       val.device_ids.forEach(function(v) {
         selectize.addOption({text: toHex(v), value: v});
       });
-      selectize.refreshOptions();
+      selectize.refreshOptions(false);
     }
 
     if (val.group_state_fields) {
@@ -592,6 +683,19 @@ var saveGatewayConfigs = function() {
   }
 };
 
+var patchSettings = function(patch) {
+  if (!loadingSettings) {
+    $.ajax(
+      "/settings",
+      {
+        method: 'put',
+        contentType: 'application/json',
+        data: JSON.stringify(patch)
+      }
+    );
+  }
+};
+
 var saveDeviceIds = function() {
   if (!loadingSettings) {
     var deviceIds = _.map(
@@ -601,14 +705,28 @@ var saveDeviceIds = function() {
       }
     );
 
-    $.ajax(
-      "/settings",
-      {
-        method: 'put',
-        contentType: 'application/json',
-        data: JSON.stringify({device_ids: deviceIds})
-      }
+    patchSettings({device_ids: deviceIds});
+  }
+};
+
+var saveDeviceAliases = function() {
+  if (!loadingSettings) {
+    var deviceAliases = Object.entries(aliasesSelectize.options).reduce(
+      function(aggregate, x) {
+        var params = x[1].savedGroupParams;
+
+        aggregate[x[0]] = [
+          params.deviceType,
+          params.deviceId,
+          params.groupId
+        ]
+
+        return aggregate;
+      },
+      {}
     );
+
+    patchSettings({group_id_aliases: deviceAliases});
   }
 };
 
@@ -618,6 +736,12 @@ var deleteDeviceId = function() {
   saveDeviceIds();
 };
 
+var deleteDeviceAlias = function() {
+  aliasesSelectize.removeOption($(this).data('value'));
+  aliasesSelectize.refreshOptions();
+  saveDeviceAliases();
+};
+
 var deviceIdError = function(v) {
   if (!v) {
     $('#device-id-label').removeClass('error');
@@ -913,23 +1037,63 @@ $(function() {
     return false;
   });
 
-  $('input[name="mode"],input[name="options"],#deviceId').change(function(e) {
+  var onGroupParamsChange = function(e) {
+    findAndSelectAlias();
     try {
-      $.getJSON(
-        activeUrl(),
-        function(e) {
-          handleStateUpdate(e);
-        }
-      );
+      refreshGroupState();
     } catch (e) {
       // Skip
     }
-  });
+  };
+
+  $('input[name="options"],#deviceId').change(onGroupParamsChange);
+  $('#mode li').click(onGroupParamsChange);
+
+  aliasesSelectize = $('#deviceAliases').selectize({
+    create: true,
+    allowEmptyOption: true,
+    openOnFocus: true,
+    createOnBlur: true,
+    render: {
+      option: function(data, escape) {
+        // Mousedown selects an option -- prevent event from bubbling up to select option
+        // when delete button is clicked.
+        var deleteBtn = $('<span class="selectize-delete"><a href="#"><i class="glyphicon glyphicon-trash"></i></a></span>')
+          .mousedown(function(e) {
+            e.preventDefault();
+            return false;
+          })
+          .click(function(e) {
+            deleteDeviceAlias.call($(this).closest('.c-selectize-item'));
+            e.preventDefault();
+            return false;
+          });
+
+        var elmt = $('<div class="c-selectize-item"></div>');
+        elmt.append('<span>' + data.text + '</span>');
+        elmt.append(deleteBtn);
+
+        return elmt;
+      }
+    },
+    onOptionAdd: function(v, item) {
+      if (!item.savedGroupParams) {
+        item.savedGroupParams = {
+          deviceId: getCurrentDeviceId(),
+          groupId: getCurrentGroupId(),
+          deviceType: getCurrentMode()
+        };
+      }
+
+      saveDeviceAliases();
+    }
+  })[0].selectize;
 
   selectize = $('#deviceId').selectize({
     create: true,
     sortField: 'value',
     allowEmptyOption: true,
+    createOnBlur: true,
     render: {
       option: function(data, escape) {
         // Mousedown selects an option -- prevent event from bubbling up to select option
@@ -1120,6 +1284,16 @@ $(function() {
     input.trigger('fileselect', [label]);
   });
 
+  $(document).on('change', '#deviceAliases', function() {
+    var selectedValue = aliasesSelectize.getValue()
+      , selectizeItem = aliasesSelectize.options[selectedValue]
+      ;
+
+    if (selectizeItem && !updatingAlias) {
+      updateGroupId(selectizeItem.savedGroupParams);
+    }
+  });
+
   $(document).ready( function() {
     $(':file').on('fileselect', function(event, label) {