Quellcode durchsuchen

Merge pull request #482 from sidoh/feature/homeassistant_discovery

New feature: HomeAssistant MQTT discovery
Chris Mullins vor 6 Jahren
Ursprung
Commit
58979b589c

Datei-Diff unterdrückt, da er zu groß ist
+ 2 - 2
dist/index.html.gz.h


+ 146 - 0
lib/MQTT/HomeAssistantDiscoveryClient.cpp

@@ -0,0 +1,146 @@
+#include <HomeAssistantDiscoveryClient.h>
+
+HomeAssistantDiscoveryClient::HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient)
+  : settings(settings)
+  , mqttClient(mqttClient)
+{ }
+
+void HomeAssistantDiscoveryClient::sendDiscoverableDevices(const std::map<String, BulbId>& aliases) {
+#ifdef MQTT_DEBUG
+  Serial.println(F("HomeAssistantDiscoveryClient: Sending discoverable devices..."));
+#endif
+
+  for (auto itr = aliases.begin(); itr != aliases.end(); ++itr) {
+    addConfig(itr->first.c_str(), itr->second);
+  }
+}
+
+void HomeAssistantDiscoveryClient::removeOldDevices(const std::map<uint32_t, BulbId>& aliases) {
+#ifdef MQTT_DEBUG
+  Serial.println(F("HomeAssistantDiscoveryClient: Removing discoverable devices..."));
+#endif
+
+  for (auto itr = aliases.begin(); itr != aliases.end(); ++itr) {
+    removeConfig(itr->second);
+  }
+}
+
+void HomeAssistantDiscoveryClient::removeConfig(const BulbId& bulbId) {
+  // Remove by publishing an empty message
+  String topic = buildTopic(bulbId);
+  mqttClient->send(topic.c_str(), "", true);
+}
+
+void HomeAssistantDiscoveryClient::addConfig(const char* alias, const BulbId& bulbId) {
+  String topic = buildTopic(bulbId);
+  DynamicJsonDocument config(1024);
+
+  config[F("schema")] = F("json");
+  config[F("name")] = alias;
+  config[F("command_topic")] = mqttClient->bindTopicString(settings.mqttTopicPattern, bulbId);
+  config[F("state_topic")] = mqttClient->bindTopicString(settings.mqttStateTopicPattern, bulbId);
+
+  // HomeAssistant only supports simple client availability
+  if (settings.mqttClientStatusTopic.length() > 0 && settings.simpleMqttClientStatus) {
+    config[F("availability_topic")] = settings.mqttClientStatusTopic;
+    config[F("payload_available")] = F("connected");
+    config[F("payload_not_available")] = F("disconnected");
+  }
+
+  // Configure supported commands based on the bulb type
+
+  // All supported bulbs support brightness and night mode
+  config[F("brightness")] = true;
+  config[F("effect")] = true;
+
+  JsonArray effects = config.createNestedArray(F("effect_list"));
+  effects.add(F("night_mode"));
+
+  // These bulbs support RGB color
+  switch (bulbId.deviceType) {
+    case REMOTE_TYPE_FUT089:
+    case REMOTE_TYPE_RGB:
+    case REMOTE_TYPE_RGB_CCT:
+    case REMOTE_TYPE_RGBW:
+      config[F("rgb")] = true;
+      break;
+    default:
+      break; //nothing
+  }
+
+  // These bulbs support adjustable white values
+  switch (bulbId.deviceType) {
+    case REMOTE_TYPE_CCT:
+    case REMOTE_TYPE_FUT089:
+    case REMOTE_TYPE_FUT091:
+    case REMOTE_TYPE_RGB_CCT:
+      config[F("color_temp")] = true;
+      break;
+    default:
+      break; //nothing
+  }
+
+  // These bulbs support switching between rgb/white, and have a "white_mode" command
+  switch (bulbId.deviceType) {
+    case REMOTE_TYPE_FUT089:
+    case REMOTE_TYPE_RGB_CCT:
+    case REMOTE_TYPE_RGBW:
+      effects.add(F("white_mode"));
+      break;
+    default:
+      break; //nothing
+  }
+
+  String message;
+  serializeJson(config, message);
+
+#ifdef MQTT_DEBUG
+  Serial.printf_P(PSTR("HomeAssistantDiscoveryClient: adding discoverable device: %s...\n"), alias);
+  Serial.printf_P(PSTR("  topic: %s\nconfig: %s\n"), topic.c_str(), message.c_str());
+#endif
+
+
+  mqttClient->send(topic.c_str(), message.c_str(), true);
+}
+
+// Topic syntax:
+//   <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
+//
+// source: https://www.home-assistant.io/docs/mqtt/discovery/
+String HomeAssistantDiscoveryClient::buildTopic(const BulbId& bulbId) {
+  String topic = settings.homeAssistantDiscoveryPrefix;
+
+  // Don't require the user to entier a "/" (or break things if they do)
+  if (! topic.endsWith("/")) {
+    topic += "/";
+  }
+
+  topic += "light/";
+  // Use a static ID that doesn't depend on configuration.
+  topic += "milight_hub_" + String(ESP.getChipId());
+
+  // make the object ID based on the actual parameters rather than the alias.
+  topic += "/";
+  topic += MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType);
+  topic += "_";
+  topic += bulbId.getHexDeviceId();
+  topic += "_";
+  topic += bulbId.groupId;
+  topic += "/config";
+
+  return topic;
+}
+
+String HomeAssistantDiscoveryClient::bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId) {
+  String boundTopic = topic;
+  String hexDeviceId = bulbId.getHexDeviceId();
+
+  boundTopic.replace(":device_alias", alias);
+  boundTopic.replace(":device_id", hexDeviceId);
+  boundTopic.replace(":hex_device_id", hexDeviceId);
+  boundTopic.replace(":dec_device_id", String(bulbId.deviceId));
+  boundTopic.replace(":device_type", MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType));
+  boundTopic.replace(":group_id", String(bulbId.groupId));
+
+  return boundTopic;
+}

+ 23 - 0
lib/MQTT/HomeAssistantDiscoveryClient.h

@@ -0,0 +1,23 @@
+#pragma once
+
+#include <BulbId.h>
+#include <MqttClient.h>
+#include <map>
+
+class HomeAssistantDiscoveryClient {
+public:
+  HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient);
+
+  void addConfig(const char* alias, const BulbId& bulbId);
+  void removeConfig(const BulbId& bulbId);
+
+  void sendDiscoverableDevices(const std::map<String, BulbId>& aliases);
+  void removeOldDevices(const std::map<uint32_t, BulbId>& aliases);
+
+private:
+  Settings& settings;
+  MqttClient* mqttClient;
+
+  String buildTopic(const BulbId& bulbId);
+  String bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId);
+};

+ 55 - 21
lib/MQTT/MqttClient.cpp

@@ -16,7 +16,8 @@ MqttClient::MqttClient(Settings& settings, MiLightClient*& milightClient)
   : mqttClient(tcpClient),
     milightClient(milightClient),
     settings(settings),
-    lastConnectAttempt(0)
+    lastConnectAttempt(0),
+    connected(false)
 {
   String strDomain = settings.mqttServer();
   this->domain = new char[strDomain.length() + 1];
@@ -30,6 +31,10 @@ MqttClient::~MqttClient() {
   delete this->domain;
 }
 
+void MqttClient::onConnect(OnConnectFn fn) {
+  this->onConnectFn = fn;
+}
+
 void MqttClient::begin() {
 #ifdef MQTT_DEBUG
   printf_P(
@@ -117,6 +122,13 @@ void MqttClient::reconnect() {
 void MqttClient::handleClient() {
   reconnect();
   mqttClient.loop();
+
+  if (!connected && mqttClient.connected()) {
+    this->connected = true;
+    this->onConnectFn();
+  } else if (!mqttClient.connected()) {
+    this->connected = false;
+  }
 }
 
 void MqttClient::sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update) {
@@ -144,6 +156,32 @@ void MqttClient::subscribe() {
   mqttClient.subscribe(topic.c_str());
 }
 
+void MqttClient::send(const char* topic, const char* message, const bool retain) {
+  size_t len = strlen(message);
+  size_t topicLen = strlen(topic);
+
+  if ((topicLen + len + 10) < MQTT_MAX_PACKET_SIZE ) {
+    mqttClient.publish(topic, message, retain);
+  } else {
+    const uint8_t* messageBuffer = reinterpret_cast<const uint8_t*>(message);
+    mqttClient.beginPublish(topic, len, retain);
+
+#ifdef MQTT_DEBUG
+    Serial.printf_P(PSTR("Printing message in parts because it's too large for the packet buffer (%d bytes)"), len);
+#endif
+
+    for (size_t i = 0; i < len; i += MQTT_PACKET_CHUNK_SIZE) {
+      size_t toWrite = std::min(static_cast<size_t>(MQTT_PACKET_CHUNK_SIZE), len - i);
+      mqttClient.write(messageBuffer+i, toWrite);
+#ifdef MQTT_DEBUG
+      Serial.printf_P(PSTR("  Wrote %d bytes\n"), toWrite);
+#endif
+    }
+
+    mqttClient.endPublish();
+  }
+}
+
 void MqttClient::publish(
   const String& _topic,
   const MiLightRemoteConfig &remoteConfig,
@@ -156,14 +194,14 @@ void MqttClient::publish(
     return;
   }
 
-  String topic = _topic;
-  MqttClient::bindTopicString(topic, remoteConfig, deviceId, groupId);
+  BulbId bulbId(deviceId, groupId, remoteConfig.type);
+  String topic = bindTopicString(_topic, bulbId);
 
 #ifdef MQTT_DEBUG
   printf("MqttClient - publishing update to %s\n", topic.c_str());
 #endif
 
-  mqttClient.publish(topic.c_str(), message, retain);
+  send(topic.c_str(), message, retain);
 }
 
 void MqttClient::publishCallback(char* topic, byte* payload, int length) {
@@ -236,28 +274,24 @@ void MqttClient::publishCallback(char* topic, byte* payload, int length) {
   milightClient->update(obj);
 }
 
-inline void MqttClient::bindTopicString(
-  String& topicPattern,
-  const MiLightRemoteConfig& remoteConfig,
-  const uint16_t deviceId,
-  const uint16_t groupId
-) {
-  String deviceIdHex = String(deviceId, 16);
-  deviceIdHex.toUpperCase();
-  deviceIdHex = String("0x") + deviceIdHex;
+String MqttClient::bindTopicString(const String& topicPattern, const BulbId& bulbId) {
+  String boundTopic = topicPattern;
+  String deviceIdHex = bulbId.getHexDeviceId();
 
-  topicPattern.replace(":device_id", deviceIdHex);
-  topicPattern.replace(":hex_device_id", deviceIdHex);
-  topicPattern.replace(":dec_device_id", String(deviceId));
-  topicPattern.replace(":group_id", String(groupId));
-  topicPattern.replace(":device_type", remoteConfig.name);
+  boundTopic.replace(":device_id", deviceIdHex);
+  boundTopic.replace(":hex_device_id", deviceIdHex);
+  boundTopic.replace(":dec_device_id", String(bulbId.deviceId));
+  boundTopic.replace(":group_id", String(bulbId.groupId));
+  boundTopic.replace(":device_type", MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType));
 
-  auto it = settings.findAlias(remoteConfig.type, deviceId, groupId);
+  auto it = settings.findAlias(bulbId.deviceType, bulbId.deviceId, bulbId.groupId);
   if (it != settings.groupIdAliases.end()) {
-    topicPattern.replace(":device_alias", it->first);
+    boundTopic.replace(":device_alias", it->first);
   } else {
-    topicPattern.replace(":device_alias", "__unnamed_group");
+    boundTopic.replace(":device_alias", "__unnamed_group");
   }
+
+  return boundTopic;
 }
 
 String MqttClient::generateConnectionStatusMessage(const char* connectionStatus) {

+ 12 - 7
lib/MQTT/MqttClient.h

@@ -8,11 +8,17 @@
 #define MQTT_CONNECTION_ATTEMPT_FREQUENCY 5000
 #endif
 
+#ifndef MQTT_PACKET_CHUNK_SIZE
+#define MQTT_PACKET_CHUNK_SIZE 128
+#endif
+
 #ifndef _MQTT_CLIENT_H
 #define _MQTT_CLIENT_H
 
 class MqttClient {
 public:
+  using OnConnectFn = std::function<void()>;
+
   MqttClient(Settings& settings, MiLightClient*& milightClient);
   ~MqttClient();
 
@@ -21,6 +27,10 @@ public:
   void reconnect();
   void sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update);
   void sendState(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update);
+  void send(const char* topic, const char* message, const bool retain = false);
+  void onConnect(OnConnectFn fn);
+
+  String bindTopicString(const String& topicPattern, const BulbId& bulbId);
 
 private:
   WiFiClient tcpClient;
@@ -29,6 +39,8 @@ private:
   Settings& settings;
   char* domain;
   unsigned long lastConnectAttempt;
+  OnConnectFn onConnectFn;
+  bool connected;
 
   void sendBirthMessage();
   bool connect();
@@ -43,13 +55,6 @@ private:
     const bool retain = false
   );
 
-  inline void bindTopicString(
-    String& topicPattern,
-    const MiLightRemoteConfig& remoteConfig,
-    const uint16_t deviceId,
-    const uint16_t groupId
-  );
-
   String generateConnectionStatusMessage(const char* status);
 };
 

+ 1 - 1
lib/MiLight/PacketSender.cpp

@@ -109,7 +109,7 @@ void PacketSender::updateResendCount() {
 
   if (signedResends < static_cast<int>(settings.packetRepeatMinimum)) {
     signedResends = settings.packetRepeatMinimum;
-  } else if (signedResends > settings.packetRepeats) {
+  } else if (signedResends > static_cast<int>(settings.packetRepeats)) {
     signedResends = settings.packetRepeats;
   }
 

+ 1 - 1
lib/MiLightState/GroupStatePersistence.cpp

@@ -35,6 +35,6 @@ void GroupStatePersistence::clear(const BulbId &id) {
 }
 
 char* GroupStatePersistence::buildFilename(const BulbId &id, char *buffer) {
-  uint32_t compactId = (id.deviceId << 24) | (id.deviceType << 8) | id.groupId;
+  uint32_t compactId = id.getCompactId();
   return buffer + sprintf(buffer, "%s%x", FILE_PREFIX, compactId);
 }

+ 12 - 0
lib/Settings/Settings.cpp

@@ -98,6 +98,7 @@ void Settings::patch(JsonObject parsedSettings) {
   this->setIfPresent(parsedSettings, "wifi_static_ip_gateway", wifiStaticIPGateway);
   this->setIfPresent(parsedSettings, "wifi_static_ip_netmask", wifiStaticIPNetmask);
   this->setIfPresent(parsedSettings, "packet_repeats_per_loop", packetRepeatsPerLoop);
+  this->setIfPresent(parsedSettings, "home_assistant_discovery_prefix", homeAssistantDiscoveryPrefix);
 
   if (parsedSettings.containsKey("rf24_channels")) {
     JsonArray arr = parsedSettings["rf24_channels"];
@@ -164,6 +165,13 @@ std::map<String, BulbId>::const_iterator Settings::findAlias(MiLightRemoteType d
 
 void Settings::parseGroupIdAliases(JsonObject json) {
   JsonObject aliases = json["group_id_aliases"];
+
+  // Save group IDs that were deleted so that they can be processed by discovery
+  // if necessary
+  for (auto it = groupIdAliases.begin(); it != groupIdAliases.end(); ++it) {
+    deletedGroupIdAliases[it->second.getCompactId()] = it->second;
+  }
+
   groupIdAliases.clear();
 
   for (JsonPair kv : aliases) {
@@ -174,6 +182,9 @@ void Settings::parseGroupIdAliases(JsonObject json) {
       MiLightRemoteTypeHelpers::remoteTypeFromString(bulbIdProps[0].as<String>())
     };
     groupIdAliases[kv.key().c_str()] = bulbId;
+
+    // If added this round, do not mark as deleted.
+    deletedGroupIdAliases.erase(bulbId.getCompactId());
   }
 }
 
@@ -271,6 +282,7 @@ void Settings::serialize(Print& stream, const bool prettyPrint) {
   root["wifi_static_ip_gateway"] = this->wifiStaticIPGateway;
   root["wifi_static_ip_netmask"] = this->wifiStaticIPNetmask;
   root["packet_repeats_per_loop"] = this->packetRepeatsPerLoop;
+  root["home_assistant_discovery_prefix"] = this->homeAssistantDiscoveryPrefix;
 
   JsonArray channelArr = root.createNestedArray("rf24_channels");
   JsonHelpers::vectorToJsonArr<RF24Channel, String>(channelArr, rf24Channels, RF24ChannelHelpers::nameFromValue);

+ 2 - 0
lib/Settings/Settings.h

@@ -183,6 +183,8 @@ public:
   String wifiStaticIPGateway;
   size_t packetRepeatsPerLoop;
   std::map<String, BulbId> groupIdAliases;
+  std::map<uint32_t, BulbId> deletedGroupIdAliases;
+  String homeAssistantDiscoveryPrefix;
 
 protected:
   size_t _autoRestartPeriod;

+ 11 - 0
lib/Types/BulbId.cpp

@@ -34,3 +34,14 @@ bool BulbId::operator==(const BulbId &other) {
     && groupId == other.groupId
     && deviceType == other.deviceType;
 }
+
+uint32_t BulbId::getCompactId() const {
+  uint32_t id = (deviceId << 24) | (deviceType << 8) | groupId;
+  return id;
+}
+
+String BulbId::getHexDeviceId() const {
+  char hexDeviceId[7];
+  sprintf_P(hexDeviceId, PSTR("0x%X"), deviceId);
+  return hexDeviceId;
+}

+ 3 - 0
lib/Types/BulbId.h

@@ -13,4 +13,7 @@ struct BulbId {
   BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType);
   bool operator==(const BulbId& other);
   void operator=(const BulbId& other);
+
+  uint32_t getCompactId() const;
+  String getHexDeviceId() const;
 };

+ 1 - 0
platformio.ini

@@ -30,6 +30,7 @@ test_ignore = remote
 upload_speed = 460800
 build_flags =
   !python .get_version.py
+  # For compatibility with WebSockets 2.1.4 and v2.4 of the Arduino SDK
   -D USING_AXTLS
   -D MQTT_MAX_PACKET_SIZE=250
   -D HTTP_UPLOAD_BUFLEN=128

+ 11 - 0
src/main.cpp

@@ -25,6 +25,7 @@
 #include <BulbStateUpdater.h>
 #include <RadioSwitchboard.h>
 #include <PacketSender.h>
+#include <HomeAssistantDiscoveryClient.h>
 
 #include <vector>
 #include <memory>
@@ -239,6 +240,16 @@ void applySettings() {
   if (settings.mqttServer().length() > 0) {
     mqttClient = new MqttClient(settings, milightClient);
     mqttClient->begin();
+    mqttClient->onConnect([settings, mqttClient]() {
+      if (settings.homeAssistantDiscoveryPrefix.length() > 0) {
+        HomeAssistantDiscoveryClient discoveryClient(settings, mqttClient);
+        discoveryClient.sendDiscoverableDevices(settings.groupIdAliases);
+        discoveryClient.removeOldDevices(settings.deletedGroupIdAliases);
+
+        settings.deletedGroupIdAliases.clear();
+      }
+    });
+
     bulbStateUpdater = new BulbStateUpdater(settings, *mqttClient, *stateStore);
   }
 

+ 6 - 4
test/remote/lib/mqtt_client.rb

@@ -70,7 +70,7 @@ class MqttClient
     end
   end
 
-  def on_message(topic, timeout = 10, &block)
+  def on_message(topic, timeout = 10, raise_error = true, &block)
     @listen_threads << Thread.new do
       begin
         Timeout.timeout(timeout) do
@@ -81,14 +81,16 @@ class MqttClient
         end
       rescue Timeout::Error => e
         puts "Timed out listening for message on: #{topic}"
-        raise e
+        raise e if raise_error
       rescue BreakListenLoopError
       end
     end
   end
 
-  def publish(topic, state = {})
-    @client.publish(topic, state.to_json)
+  def publish(topic, state = {}, retain = false)
+    state = state.to_json unless state.is_a?(String)
+
+    @client.publish(topic, state, retain)
   end
 
   def patch_state(id_params, state = {})

+ 166 - 0
test/remote/spec/discovery_spec.rb

@@ -0,0 +1,166 @@
+require 'api_client'
+
+RSpec.describe 'MQTT Discovery' do
+  before(:all) do
+    @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE'))
+    @client.upload_json('/settings', 'settings.json')
+
+    @test_id = 1
+    @topic_prefix = mqtt_topic_prefix()
+    @discovery_prefix = "#{@topic_prefix}discovery/"
+
+    @mqtt_client = create_mqtt_client()
+  end
+
+  after(:all) do
+    # Clean up any leftover cruft
+    @mqtt_client.on_message("#{@discovery_prefix}#", 1, false) do |topic, message|
+      if message.length > 0
+        @mqtt_client.publish(topic, '', true)
+      end
+      false
+    end
+    @mqtt_client.wait_for_listeners
+  end
+
+  before(:each) do
+    mqtt_params = mqtt_parameters()
+
+    @client.put(
+      '/settings',
+      mqtt_params
+    )
+
+    @id_params = {
+      id: @client.generate_id,
+      type: 'rgb_cct',
+      group_id: 1
+    }
+    @discovery_suffix = "#{@id_params[:type]}_#{sprintf("0x%04x", @id_params[:id])}_#{@id_params[:group_id]}/config"
+    @test_discovery_prefix = "#{@discovery_prefix}#{@id_params[:id]}/"
+  end
+
+  context 'when not configured' do
+    it 'should behave appropriately when MQTT is not configured' do
+      @client.patch_settings(mqtt_server: '', home_assistant_discovery_prefix: '')
+      expect { @client.get('/settings') }.to_not raise_error
+    end
+
+    it 'should behave appropriately when MQTT is configured, but discovery is not' do
+      @client.patch_settings(mqtt_parameters().merge(home_assistant_discovery_prefix: ''))
+      expect { @client.get('/settings') }.to_not raise_error
+    end
+  end
+
+  context 'discovery topics' do
+    it 'should send discovery messages' do
+      saw_message = false
+
+      @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message|
+        saw_message = true
+      end
+
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @test_discovery_prefix,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        }
+      )
+
+      @mqtt_client.wait_for_listeners
+
+      expect(saw_message).to be(true)
+    end
+
+    it 'config should have expected keys' do
+      saw_message = false
+      config = nil
+
+      @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message|
+        config = JSON.parse(message)
+        saw_message = true
+      end
+
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @test_discovery_prefix,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        }
+      )
+
+      @mqtt_client.wait_for_listeners
+
+      expect(saw_message).to be(true)
+      expected_keys = %w(
+        schema
+        name
+        command_topic
+        state_topic
+        brightness
+        rgb
+        color_temp
+        effect
+        effect_list
+      )
+      expect(config.keys).to include(*expected_keys)
+    end
+
+    it 'should remove discoverable devices when alias is removed' do
+      seen_config = false
+      seen_blank_message = false
+
+      @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message|
+        seen_config = seen_config || message.length > 0
+        seen_blank_message = seen_blank_message || message.length == 0
+
+        seen_config && seen_blank_message
+      end
+
+      # This should create the device
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @test_discovery_prefix,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        }
+      )
+
+      # This should clear it
+      @client.patch_settings(
+        group_id_aliases: { }
+      )
+
+      @mqtt_client.wait_for_listeners
+
+      expect(seen_config).to be(true)
+      expect(seen_blank_message).to be(true), "should see deletion message"
+    end
+
+    it 'should configure devices with an availability topic if client status is configured' do
+      expected_keys = %w(
+        availability_topic
+        payload_available
+        payload_not_available
+      )
+      config = nil
+
+      @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message|
+        config = JSON.parse(message)
+        (expected_keys - config.keys).empty?
+      end
+
+      # This should create the device
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @test_discovery_prefix,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        },
+        mqtt_client_status_topic: "#{@topic_prefix}status",
+        simple_mqtt_client_status: true
+      )
+
+      @mqtt_client.wait_for_listeners
+
+      expect(config.keys).to include(*expected_keys)
+    end
+  end
+end

+ 1 - 0
test/remote/spec/mqtt_spec.rb

@@ -182,6 +182,7 @@ 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

+ 2 - 1
test/remote/spec/settings_spec.rb

@@ -27,7 +27,8 @@ RSpec.describe 'Settings' do
     it 'should persist known settings keys' do
       {
         'simple_mqtt_client_status' => [true, false],
-        'packet_repeats_per_loop' => [10]
+        'packet_repeats_per_loop' => [10],
+        'home_assistant_discovery_prefix' => ['', 'abc', 'a/b/c']
       }.each do |key, values|
         values.each do |v|
           @client.patch_settings({key => v})

+ 6 - 0
web/src/js/script.js

@@ -187,6 +187,12 @@ var UI_FIELDS = [ {
     },
     tab: "tab-mqtt"
   }, {
+    tag: "home_assistant_discovery_prefix",
+    friendly: "HomeAssistant MQTT Discovery Prefix",
+    help: "If set, will enable integration with HomeAssistant's MQTT discovery functionality to allow saved aliases to be detected automatically",
+    type: "string",
+    tab: "tab-mqtt"
+  }, {
     tag:   "radio_interface_type",
     friendly: "Radio interface type",
     help: "2.4 GHz radio model. Only change this if you know you're not using an NRF24L01!",