#include #include "MqttDisco.h" void setUp() {} void tearDown() {} static shade_disco_t shade(shade_types type = shade_types::roller) { shade_disco_t s; s.shadeId = 3; s.name = "Kitchen"; s.shadeType = type; s.rootTopic = "somfy"; s.discoTopic = "homeassistant"; s.serverId = "abc123"; s.hostname = "ESPSomfyRTS"; s.fwVersion = "v4.0.0"; return s; } // The config Home Assistant gets and the topic it goes to. struct disco_t { JsonDocument doc; char topic[128] = ""; }; static void build(const shade_disco_t &s, disco_t &d) { shadeDisco(s, d.doc.to(), d.topic, sizeof(d.topic)); } static void test_a_roller_is_a_cover_under_the_discovery_topic() { disco_t d; build(shade(), d); TEST_ASSERT_EQUAL_STRING("homeassistant/cover/3/config", d.topic); TEST_ASSERT_EQUAL_STRING("somfy/shades/3", d.doc["~"]); TEST_ASSERT_EQUAL_STRING("Kitchen", d.doc["name"]); TEST_ASSERT_EQUAL_STRING("mqtt_abc123_shade3", d.doc["unique_id"]); TEST_ASSERT_EQUAL_STRING("shade", d.doc["device_class"]); TEST_ASSERT_TRUE(d.doc["enabled_by_default"].as()); } // Every shade of a device is grouped under it in Home Assistant. static void test_the_device_is_the_esp() { disco_t d; build(shade(), d); JsonObject device = d.doc["device"]; TEST_ASSERT_EQUAL_STRING("mqtt_espsomfyrts_abc123", device["identifiers"][0]); TEST_ASSERT_EQUAL_STRING("ESPSomfyRTS", device["name"]); TEST_ASSERT_EQUAL_STRING("v4.0.0", device["hw_version"]); TEST_ASSERT_EQUAL_STRING("ESPSomfy-RTS MQTT", device["model"]); TEST_ASSERT_EQUAL_STRING("somfy/status", d.doc["availability_topic"]); TEST_ASSERT_EQUAL_STRING("online", d.doc["payload_available"]); TEST_ASSERT_EQUAL_STRING("offline", d.doc["payload_not_available"]); } // The topics under ~ are the ones MqttCommands listens on and the shade // publishes. static void test_a_cover_moves_through_the_shade_topics() { disco_t d; build(shade(), d); TEST_ASSERT_EQUAL_STRING("~/direction/set", d.doc["command_topic"]); TEST_ASSERT_EQUAL_STRING("~/target/set", d.doc["set_position_topic"]); TEST_ASSERT_EQUAL_STRING("~/position", d.doc["position_topic"]); TEST_ASSERT_EQUAL_STRING("~/direction", d.doc["state_topic"]); TEST_ASSERT_EQUAL_STRING("0", d.doc["payload_stop"]); TEST_ASSERT_EQUAL_STRING("0", d.doc["state_stopped"]); TEST_ASSERT_FALSE(d.doc["tilt_command_topic"].is()); } // 0 is open: an upward move opens. static void test_a_roller_opens_up() { disco_t d; build(shade(), d); TEST_ASSERT_EQUAL_STRING("-1", d.doc["payload_open"]); TEST_ASSERT_EQUAL_STRING("1", d.doc["payload_close"]); TEST_ASSERT_EQUAL(0, d.doc["position_open"].as()); TEST_ASSERT_EQUAL(100, d.doc["position_closed"].as()); TEST_ASSERT_EQUAL_STRING("-1", d.doc["state_opening"]); TEST_ASSERT_EQUAL_STRING("1", d.doc["state_closing"]); } static void test_a_flipped_position_opens_at_100() { shade_disco_t s = shade(); s.flipPosition = true; disco_t d; build(s, d); TEST_ASSERT_EQUAL_STRING("1", d.doc["payload_open"]); TEST_ASSERT_EQUAL_STRING("-1", d.doc["payload_close"]); TEST_ASSERT_EQUAL(100, d.doc["position_open"].as()); TEST_ASSERT_EQUAL(0, d.doc["position_closed"].as()); TEST_ASSERT_EQUAL_STRING("1", d.doc["state_opening"]); TEST_ASSERT_EQUAL_STRING("-1", d.doc["state_closing"]); } // An awning opens downward, and flipped opens up again. static void test_an_awning_opens_down() { disco_t d; build(shade(shade_types::awning), d); TEST_ASSERT_EQUAL_STRING("awning", d.doc["device_class"]); TEST_ASSERT_EQUAL_STRING("1", d.doc["payload_open"]); TEST_ASSERT_EQUAL(100, d.doc["position_open"].as()); shade_disco_t s = shade(shade_types::awning); s.flipPosition = true; disco_t flipped; build(s, flipped); TEST_ASSERT_EQUAL_STRING("-1", flipped.doc["payload_open"]); TEST_ASSERT_EQUAL(0, flipped.doc["position_open"].as()); } static void test_each_kind_of_shade_has_its_device_class() { const struct { shade_types type; const char *deviceClass; } kinds[] = { {shade_types::roller, "shade"}, {shade_types::blind, "blind"}, {shade_types::ldrapery, "curtain"}, {shade_types::rdrapery, "curtain"}, {shade_types::cdrapery, "curtain"}, {shade_types::lgate, "curtain"}, {shade_types::cgate1, "curtain"}, {shade_types::garage1, "garage"}, {shade_types::garage3, "garage"}, {shade_types::shutter, "shutter"}, {shade_types::awning, "awning"}, }; for(const auto &kind : kinds) { disco_t d; build(shade(kind.type), d); TEST_ASSERT_EQUAL_STRING(kind.deviceClass, d.doc["device_class"]); } } static void test_a_tilting_blind_has_the_tilt_topics() { shade_disco_t s = shade(shade_types::blind); s.tiltType = tilt_types::tiltmotor; disco_t d; build(s, d); TEST_ASSERT_EQUAL_STRING("~/tiltTarget/set", d.doc["tilt_command_topic"]); TEST_ASSERT_EQUAL_STRING("~/tiltPosition", d.doc["tilt_status_topic"]); TEST_ASSERT_EQUAL_STRING("~/direction/set", d.doc["command_topic"]); } // A tilt only blind doesn't open or close, so it has only the tilt. static void test_a_tilt_only_blind_has_only_the_tilt() { shade_disco_t s = shade(shade_types::blind); s.tiltType = tilt_types::tiltonly; disco_t d; build(s, d); TEST_ASSERT_EQUAL_STRING("~/tiltTarget/set", d.doc["tilt_command_topic"]); TEST_ASSERT_FALSE(d.doc["command_topic"].is()); TEST_ASSERT_FALSE(d.doc["set_position_topic"].is()); // Sent as null so Home Assistant drops its defaults. std::string json; serializeJson(d.doc, json); TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "\"payload_open\":null")); TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "\"payload_close\":null")); TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "\"payload_stop\":null")); } // A dry contact is on (100) or off (0). static void test_a_dry_contact_is_a_switch() { for(shade_types type : {shade_types::drycontact, shade_types::drycontact2}) { disco_t d; build(shade(type), d); TEST_ASSERT_EQUAL_STRING("homeassistant/switch/3/config", d.topic); TEST_ASSERT_FALSE(d.doc["device_class"].is()); TEST_ASSERT_EQUAL(100, d.doc["payload_on"].as()); TEST_ASSERT_EQUAL(0, d.doc["payload_off"].as()); TEST_ASSERT_EQUAL(100, d.doc["state_on"].as()); TEST_ASSERT_EQUAL(0, d.doc["state_off"].as()); TEST_ASSERT_EQUAL_STRING("~/position", d.doc["state_topic"]); TEST_ASSERT_EQUAL_STRING("~/target/set", d.doc["command_topic"]); TEST_ASSERT_FALSE(d.doc["payload_open"].is()); } } // Deleting a shade clears both, as its kind may have changed. static void test_the_config_topic_of_each_kind() { char topic[64]; shadeDiscoTopic("ha", 12, false, topic, sizeof(topic)); TEST_ASSERT_EQUAL_STRING("ha/cover/12/config", topic); shadeDiscoTopic("ha", 12, true, topic, sizeof(topic)); TEST_ASSERT_EQUAL_STRING("ha/switch/12/config", topic); TEST_ASSERT_TRUE(isDiscoSwitch(shade_types::drycontact2)); TEST_ASSERT_FALSE(isDiscoSwitch(shade_types::garage1)); } int main(int argc, char **argv) { UNITY_BEGIN(); RUN_TEST(test_a_roller_is_a_cover_under_the_discovery_topic); RUN_TEST(test_the_device_is_the_esp); RUN_TEST(test_a_cover_moves_through_the_shade_topics); RUN_TEST(test_a_roller_opens_up); RUN_TEST(test_a_flipped_position_opens_at_100); RUN_TEST(test_an_awning_opens_down); RUN_TEST(test_each_kind_of_shade_has_its_device_class); RUN_TEST(test_a_tilting_blind_has_the_tilt_topics); RUN_TEST(test_a_tilt_only_blind_has_only_the_tilt); RUN_TEST(test_a_dry_contact_is_a_switch); RUN_TEST(test_the_config_topic_of_each_kind); return UNITY_END(); }