Explorar o código

Initial pass at HASS discovery client

Christopher Mullins %!s(int64=6) %!d(string=hai) anos
pai
achega
d2ce61d8b3

+ 135 - 0
lib/MQTT/HomeAssistantDiscoveryClient.cpp

@@ -0,0 +1,135 @@
+#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::removeConfig(const char* alias, 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")] = bindTopicVariables(settings.mqttTopicPattern, alias, bulbId);
+  config[F("state_topic")] = bindTopicVariables(settings.mqttStateTopicPattern, alias, 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_" + (ESP.getChipId());
+
+  // make the object ID based on the actual parameters rather than the alias.
+  topic += "/";
+  topic += MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType);
+  topic += "_0x";
+  topic += String(bulbId.deviceId, HEX);
+  topic += "_";
+  topic += bulbId.groupId;
+  topic += "/config";
+
+  return topic;
+}
+
+String HomeAssistantDiscoveryClient::bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId) {
+  String boundTopic = topic;
+
+  boundTopic.replace(":device_alias", alias);
+  boundTopic.replace(":device_id", String("0x") + String(bulbId.deviceId, HEX));
+  boundTopic.replace(":hex_device_id", String("0x") + String(bulbId.deviceId, HEX));
+  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;
+}

+ 22 - 0
lib/MQTT/HomeAssistantDiscoveryClient.h

@@ -0,0 +1,22 @@
+#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 char* alias, const BulbId& bulbId);
+
+  void sendDiscoverableDevices(const std::map<String, BulbId>& aliases);
+
+private:
+  Settings& settings;
+  MqttClient* mqttClient;
+
+  String buildTopic(const BulbId& bulbId);
+  String bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId);
+};

+ 1 - 0
src/main.cpp

@@ -25,6 +25,7 @@
 #include <BulbStateUpdater.h>
 #include <RadioSwitchboard.h>
 #include <PacketSender.h>
+#include <HomeAssistantDiscoveryClient.h>
 
 #include <vector>
 #include <memory>

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

@@ -0,0 +1,118 @@
+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')
+  end
+
+  before(:each) do
+    mqtt_params = mqtt_parameters()
+    @topic_prefix = mqtt_topic_prefix()
+    @discovery_prefix = "#{@topic_prefix}/discovery"
+
+    @client.put(
+      '/settings',
+      mqtt_params
+    )
+
+    @id_params = {
+      id: @client.generate_id,
+      type: 'rgb_cct',
+      group_id: 1
+    }
+    @discovery_suffix = "#{@id_params[:type]}_#{sprintf("%x", @id_params[:id])}_#{@id_params[:group_id]}"
+
+    @mqtt_client = create_mqtt_client()
+  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("#{@discovery_prefix}/light/+/#{@discovery_suffix}") do |topic, message|
+        saw_message = true
+      end
+
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @discovery_prefix,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        }
+      )
+
+      expect(saw_message).to be_true
+    end
+
+    it 'config should have expected keys' do
+      saw_message = false
+      config = nil
+
+      @mqtt_client.on_message("#{@discovery_prefix}/light/+/#{@discovery_suffix}") do |topic, message|
+        config = JSON.parse(message)
+        saw_message = true
+      end
+
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @discovery_prefix,
+        group_id_aliases: {
+          'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]]
+        }
+      )
+
+      expect(saw_message).to be_true
+      expected_keys = %w(
+        schema
+        name
+        command_topic
+        state_topic
+        availability_topic
+        payload_available
+        payload_not_available
+        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("#{@discovery_prefix}/light/+/#{@discovery_suffix}") do |topic, message|
+        seen_config = message.length > 0
+        seen_blank_message = message.empty?
+
+        seen_config && seen_blank_message
+      end
+
+      # This should create the device
+      @client.patch_settings(
+        home_assistant_discovery_prefix: @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: { }
+      )
+    end
+  end
+end