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 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-29 16:59:53 +01:00
parent 8c59cf4903
commit 64f166490f
4 changed files with 326 additions and 128 deletions

91
src/MqttDisco.cpp Normal file
View file

@ -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<JsonObject>();
dobj["hw_version"] = shade.fwVersion;
dobj["name"] = shade.hostname;
dobj["mf"] = "rstrouse";
JsonArray arrids = dobj["identifiers"].to<JsonArray>();
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);
}

29
src/MqttDisco.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef MQTT_DISCO_H
#define MQTT_DISCO_H
#include <ArduinoJson.h>
#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: <discoTopic>/cover/<id>/config or
// <discoTopic>/switch/<id>/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

View file

@ -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<JsonObject>();
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");
}
}