From 64f166490f8147ca9fef5261f9402480eb7ec80d Mon Sep 17 00:00:00 2001 From: cjkas Date: Thu, 29 Oct 2026 16:59:53 +0100 Subject: [PATCH] test: Home Assistant discovery config on the PC The config a shade publishes for MQTT discovery moves to MqttDisco, with 11 unit tests: the topics, the device, which way each kind of shade opens, tilt, tilt only and dry contacts. A deleted group's retained sunSensor topic was never cleared: unpublish named it SunSensor. Co-Authored-By: Claude Opus 5.5 --- src/MqttDisco.cpp | 91 ++++++++++++ src/MqttDisco.h | 29 ++++ src/outputs/MqttPublisher.cpp | 147 +++---------------- test/test_mqttdisco/test_mqttdisco.cpp | 187 +++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 128 deletions(-) create mode 100644 src/MqttDisco.cpp create mode 100644 src/MqttDisco.h create mode 100644 test/test_mqttdisco/test_mqttdisco.cpp diff --git a/src/MqttDisco.cpp b/src/MqttDisco.cpp new file mode 100644 index 0000000..8243030 --- /dev/null +++ b/src/MqttDisco.cpp @@ -0,0 +1,91 @@ +#include "MqttDisco.h" + +bool isDiscoSwitch(shade_types type) { return type == shade_types::drycontact || type == shade_types::drycontact2; } +void shadeDiscoTopic(const char *discoTopic, uint8_t shadeId, bool isSwitch, char *topic, size_t len) { + snprintf(topic, len, "%s/%s/%d/config", discoTopic, isSwitch ? "switch" : "cover", shadeId); +} +static const char *deviceClass(shade_types type) { + switch(type) { + case shade_types::blind: + return "blind"; + case shade_types::lgate: + case shade_types::cgate: + case shade_types::rgate: + case shade_types::lgate1: + case shade_types::cgate1: + case shade_types::rgate1: + case shade_types::ldrapery: + case shade_types::rdrapery: + case shade_types::cdrapery: + return "curtain"; + case shade_types::garage1: + case shade_types::garage3: + return "garage"; + case shade_types::awning: + return "awning"; + case shade_types::shutter: + return "shutter"; + default: + return "shade"; + } +} +void shadeDisco(const shade_disco_t &shade, JsonObject obj, char *topic, size_t len) { + snprintf(topic, len, "%s/shades/%d", shade.rootTopic, shade.shadeId); + obj["~"] = topic; + JsonObject dobj = obj["device"].to(); + dobj["hw_version"] = shade.fwVersion; + dobj["name"] = shade.hostname; + dobj["mf"] = "rstrouse"; + JsonArray arrids = dobj["identifiers"].to(); + snprintf(topic, len, "mqtt_espsomfyrts_%s", shade.serverId); + arrids.add(topic); + dobj["model"] = "ESPSomfy-RTS MQTT"; + snprintf(topic, len, "%s/status", shade.rootTopic); + obj["availability_topic"] = topic; + obj["payload_available"] = "online"; + obj["payload_not_available"] = "offline"; + obj["name"] = shade.name; + snprintf(topic, len, "mqtt_%s_shade%d", shade.serverId, shade.shadeId); + obj["unique_id"] = topic; + const bool isSwitch = isDiscoSwitch(shade.shadeType); + if(!isSwitch) { + obj["device_class"] = deviceClass(shade.shadeType); + // Positions count down from open: 0 is open and 100 closed, so an + // upward move (-1) opens. An awning, which opens downward, and a + // flipped position turn that around. + const bool reversed = shade.flipPosition != (shade.shadeType == shade_types::awning); + obj["payload_close"] = reversed ? "-1" : "1"; + obj["payload_open"] = reversed ? "1" : "-1"; + obj["position_open"] = reversed ? 100 : 0; + obj["position_closed"] = reversed ? 0 : 100; + obj["state_closing"] = reversed ? "-1" : "1"; + obj["state_opening"] = reversed ? "1" : "-1"; + if(shade.tiltType != tilt_types::tiltonly) { + obj["command_topic"] = "~/direction/set"; + obj["position_topic"] = "~/position"; + obj["set_position_topic"] = "~/target/set"; + obj["state_topic"] = "~/direction"; + obj["payload_stop"] = "0"; + obj["state_stopped"] = "0"; + } + else { + obj["payload_close"] = nullptr; + obj["payload_open"] = nullptr; + obj["payload_stop"] = nullptr; + } + if(shade.tiltType != tilt_types::none) { + obj["tilt_command_topic"] = "~/tiltTarget/set"; + obj["tilt_status_topic"] = "~/tiltPosition"; + } + } + else { + obj["payload_on"] = 100; + obj["payload_off"] = 0; + obj["state_off"] = 0; + obj["state_on"] = 100; + obj["state_topic"] = "~/position"; + obj["command_topic"] = "~/target/set"; + } + obj["enabled_by_default"] = true; + shadeDiscoTopic(shade.discoTopic, shade.shadeId, isSwitch, topic, len); +} diff --git a/src/MqttDisco.h b/src/MqttDisco.h new file mode 100644 index 0000000..ac8ffc2 --- /dev/null +++ b/src/MqttDisco.h @@ -0,0 +1,29 @@ +#ifndef MQTT_DISCO_H +#define MQTT_DISCO_H +#include +#include "ShadeTypes.h" + +// What Home Assistant's MQTT discovery needs to know about a shade. Kept +// free of the shade model and MQTT so the config it gets can be unit tested +// on the PC (pio test -e native). +struct shade_disco_t { + uint8_t shadeId = 0; + const char *name = ""; + shade_types shadeType = shade_types::roller; + tilt_types tiltType = tilt_types::none; + bool flipPosition = false; + // From the MQTT and general settings. + const char *rootTopic = ""; + const char *discoTopic = ""; + const char *serverId = ""; + const char *hostname = ""; + const char *fwVersion = ""; +}; +// Dry contacts are switches, the rest covers. +bool isDiscoSwitch(shade_types type); +// Where the shade's config goes: /cover//config or +// /switch//config. +void shadeDiscoTopic(const char *discoTopic, uint8_t shadeId, bool isSwitch, char *topic, size_t len); +// Fills obj with the shade's config and topic with where it goes. +void shadeDisco(const shade_disco_t &shade, JsonObject obj, char *topic, size_t len); +#endif diff --git a/src/outputs/MqttPublisher.cpp b/src/outputs/MqttPublisher.cpp index ac4a768..43928a0 100644 --- a/src/outputs/MqttPublisher.cpp +++ b/src/outputs/MqttPublisher.cpp @@ -3,6 +3,7 @@ #include "Somfy.h" #include "SomfySnapshot.h" #include "MqttConnection.h" +#include "MqttDisco.h" extern SomfyShadeController somfy; extern ConfigSettings settings; @@ -48,140 +49,30 @@ void SomfyShade::publishState() { snapshotShade(*this, s); publishShadeTopics(shadeStateTopics(s), s, publishShadeTopic, this); } +// The shade's config for Home Assistant's MQTT discovery. void SomfyShade::publishDisco() { if(!mqtt.connected() || !settings.MQTT.pubDisco) return; + shade_disco_t disco; + disco.shadeId = this->shadeId; + disco.name = this->name; + disco.shadeType = this->shadeType; + disco.tiltType = this->tiltType; + disco.flipPosition = this->flipPosition; + disco.rootTopic = settings.MQTT.rootTopic; + disco.discoTopic = settings.MQTT.discoTopic; + disco.serverId = settings.serverId; + disco.hostname = settings.hostname; + disco.fwVersion = settings.fwVersion.name; char topic[128] = ""; JsonDocument doc; JsonObject obj = doc.to(); - snprintf(topic, sizeof(topic), "%s/shades/%d", settings.MQTT.rootTopic, this->shadeId); - obj["~"] = topic; - JsonObject dobj = obj.createNestedObject("device"); - dobj["hw_version"] = settings.fwVersion.name; - dobj["name"] = settings.hostname; - dobj["mf"] = "rstrouse"; - JsonArray arrids = dobj.createNestedArray("identifiers"); - //snprintf(topic, sizeof(topic), "mqtt_espsomfyrts_%s_shade%d", settings.serverId, this->shadeId); - snprintf(topic, sizeof(topic), "mqtt_espsomfyrts_%s", settings.serverId); - arrids.add(topic); - //snprintf(topic, sizeof(topic), "ESPSomfy-RTS_%s", settings.serverId); - dobj["model"] = "ESPSomfy-RTS MQTT"; - snprintf(topic, sizeof(topic), "%s/status", settings.MQTT.rootTopic); - obj["availability_topic"] = topic; - obj["payload_available"] = "online"; - obj["payload_not_available"] = "offline"; - obj["name"] = this->name; - snprintf(topic, sizeof(topic), "mqtt_%s_shade%d", settings.serverId, this->shadeId); - obj["unique_id"] = topic; - switch(this->shadeType) { - case shade_types::blind: - obj["device_class"] = "blind"; - obj["payload_close"] = this->flipPosition ? "-1" : "1"; - obj["payload_open"] = this->flipPosition ? "1" : "-1"; - obj["position_open"] = this->flipPosition ? 100 : 0; - obj["position_closed"] = this->flipPosition ? 0 : 100; - obj["state_closing"] = this->flipPosition ? "-1" : "1"; - obj["state_opening"] = this->flipPosition ? "1" : "-1"; - break; - case shade_types::lgate: - case shade_types::cgate: - case shade_types::rgate: - case shade_types::lgate1: - case shade_types::cgate1: - case shade_types::rgate1: - case shade_types::ldrapery: - case shade_types::rdrapery: - case shade_types::cdrapery: - obj["device_class"] = "curtain"; - obj["payload_close"] = this->flipPosition ? "-1" : "1"; - obj["payload_open"] = this->flipPosition ? "1" : "-1"; - obj["position_open"] = this->flipPosition ? 100 : 0; - obj["position_closed"] = this->flipPosition ? 0 : 100; - obj["state_closing"] = this->flipPosition ? "-1" : "1"; - obj["state_opening"] = this->flipPosition ? "1" : "-1"; - break; - case shade_types::garage1: - case shade_types::garage3: - obj["device_class"] = "garage"; - obj["payload_close"] = this->flipPosition ? "-1" : "1"; - obj["payload_open"] = this->flipPosition ? "1" : "-1"; - obj["position_open"] = this->flipPosition ? 100 : 0; - obj["position_closed"] = this->flipPosition ? 0 : 100; - obj["state_closing"] = this->flipPosition ? "-1" : "1"; - obj["state_opening"] = this->flipPosition ? "1" : "-1"; - break; - case shade_types::awning: - obj["device_class"] = "awning"; - obj["payload_close"] = this->flipPosition ? "1" : "-1"; - obj["payload_open"] = this->flipPosition ? "-1" : "1"; - obj["position_open"] = this->flipPosition ? 0 : 100; - obj["position_closed"] = this->flipPosition ? 100 : 0; - obj["state_closing"] = this->flipPosition ? "1" : "-1"; - obj["state_opening"] = this->flipPosition ? "-1" : "1"; - break; - case shade_types::shutter: - obj["device_class"] = "shutter"; - obj["payload_close"] = this->flipPosition ? "-1" : "1"; - obj["payload_open"] = this->flipPosition ? "1" : "-1"; - obj["position_open"] = this->flipPosition ? 100 : 0; - obj["position_closed"] = this->flipPosition ? 0 : 100; - obj["state_closing"] = this->flipPosition ? "-1" : "1"; - obj["state_opening"] = this->flipPosition ? "1" : "-1"; - break; - case shade_types::drycontact2: - case shade_types::drycontact: - break; - default: - obj["device_class"] = "shade"; - obj["payload_close"] = this->flipPosition ? "-1" : "1"; - obj["payload_open"] = this->flipPosition ? "1" : "-1"; - obj["position_open"] = this->flipPosition ? 100 : 0; - obj["position_closed"] = this->flipPosition ? 0 : 100; - obj["state_closing"] = this->flipPosition ? "-1" : "1"; - obj["state_opening"] = this->flipPosition ? "1" : "-1"; - break; - } - if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::drycontact2) { - if(this->tiltType != tilt_types::tiltonly) { - obj["command_topic"] = "~/direction/set"; - obj["position_topic"] = "~/position"; - obj["set_position_topic"] = "~/target/set"; - obj["state_topic"] = "~/direction"; - obj["payload_stop"] = "0"; - obj["state_stopped"] = "0"; - } - else { - obj["payload_close"] = nullptr; - obj["payload_open"] = nullptr; - obj["payload_stop"] = nullptr; - } - - if(this->tiltType != tilt_types::none) { - obj["tilt_command_topic"] = "~/tiltTarget/set"; - obj["tilt_status_topic"] = "~/tiltPosition"; - } - snprintf(topic, sizeof(topic), "%s/cover/%d/config", settings.MQTT.discoTopic, this->shadeId); - } - else { - obj["payload_on"] = 100; - obj["payload_off"] = 0; - obj["state_off"] = 0; - obj["state_on"] = 100; - obj["state_topic"] = "~/position"; - obj["command_topic"] = "~/target/set"; - snprintf(topic, sizeof(topic), "%s/switch/%d/config", settings.MQTT.discoTopic, this->shadeId); - } - - obj["enabled_by_default"] = true; - mqtt.publishDisco(topic, obj, true); + shadeDisco(disco, obj, topic, sizeof(topic)); + mqtt.publishDisco(topic, obj, true); } void SomfyShade::unpublishDisco() { if(!mqtt.connected() || !settings.MQTT.pubDisco) return; char topic[128] = ""; - if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::drycontact2) { - snprintf(topic, sizeof(topic), "%s/cover/%d/config", settings.MQTT.discoTopic, this->shadeId); - } - else - snprintf(topic, sizeof(topic), "%s/switch/%d/config", settings.MQTT.discoTopic, this->shadeId); + shadeDiscoTopic(settings.MQTT.discoTopic, this->shadeId, isDiscoSwitch(this->shadeType), topic, sizeof(topic)); mqtt.unpublish(topic); } void SomfyShade::publish() { @@ -248,9 +139,9 @@ void SomfyShade::unpublish(uint8_t id) { SomfyShade::unpublish(id, "sunny"); if(settings.MQTT.pubDisco) { char topic[128] = ""; - snprintf(topic, sizeof(topic), "%s/cover/%d/config", settings.MQTT.discoTopic, id); + shadeDiscoTopic(settings.MQTT.discoTopic, id, false, topic, sizeof(topic)); mqtt.unpublish(topic); - snprintf(topic, sizeof(topic), "%s/switch/%d/config", settings.MQTT.discoTopic, id); + shadeDiscoTopic(settings.MQTT.discoTopic, id, true, topic, sizeof(topic)); mqtt.unpublish(topic); } } @@ -264,7 +155,7 @@ void SomfyGroup::unpublish(uint8_t id) { SomfyGroup::unpublish(id, "direction"); SomfyGroup::unpublish(id, "lastRollingCode"); SomfyGroup::unpublish(id, "flags"); - SomfyGroup::unpublish(id, "SunSensor"); + SomfyGroup::unpublish(id, "sunSensor"); SomfyGroup::unpublish(id, "flipCommands"); } } diff --git a/test/test_mqttdisco/test_mqttdisco.cpp b/test/test_mqttdisco/test_mqttdisco.cpp new file mode 100644 index 0000000..687972e --- /dev/null +++ b/test/test_mqttdisco/test_mqttdisco.cpp @@ -0,0 +1,187 @@ +#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(); +}