Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
#include "MqttManager.h"
#include <WiFi.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <Logger.h>
#include "UiCommand.h"
#include "ui/UIManager.h" // UIManager::postUiCommand
#include "ui/ui.h" // g_uiManager
#include "led/LEDManager.h"
#include "led/Animations.h"
#include "ui/Display.h"
// Global instance.
MqttManager* g_mqttManager = nullptr;
// NVS namespace for broker config.
static const char* NVS_NS = "mqtt";
namespace {
// Effects that aren't animations. Kept first in the effect list so the indices
// after them line up with AnimationType when we need them.
constexpr const char* EFFECT_SOLID = "Solid";
constexpr const char* EFFECT_WHITE = "White";
// 24-bit value → zero-padded 6-char lowercase hex. Mirrors ColourWheel's
// ltoa-based approach (avoids snprintf("%02X"), which trips -Wformat-truncation
// because the compiler assumes the int could need up to 8 hex digits).
String toHex6(uint32_t value) {
char buf[10] = "";
ltoa(static_cast<long>(value & 0xFFFFFFu), buf, 16);
String s = buf;
while (s.length() < 6) s = "0" + s;
return s;
}
// HA's switch payloads are ON/OFF; accept the common hand-typed variants too.
bool isOnPayload(const char* payload) {
return strcmp(payload, "ON") == 0 || strcmp(payload, "on") == 0 ||
strcmp(payload, "1") == 0 || strcmp(payload, "true") == 0;
}
} // namespace
MqttManager::MqttManager(LEDManager* led) : led_(led) {}
MqttManager::~MqttManager() {
stopClient();
if (inboundQueue_) {
vQueueDelete(inboundQueue_);
inboundQueue_ = nullptr;
}
}
void MqttManager::begin() {
inboundQueue_ = xQueueCreate(8, sizeof(InboundMsg));
// Stable id from the MAC (last 3 bytes) — survives reflashes, unique per device.
uint8_t mac[6];
WiFi.macAddress(mac);
shortId_ = toHex6((static_cast<uint32_t>(mac[3]) << 16) |
(static_cast<uint32_t>(mac[4]) << 8) | mac[5]);
objectId_ = String("rackglow_") + shortId_;
if (led_) {
uint8_t b = led_->getBrightness();
if (b > 0) lastOnBrightness_ = b;
}
loadConfig();
if (cfg_.enabled && cfg_.host.length() > 0) {
startClient();
}
}
void MqttManager::loadConfig() {
Preferences prefs;
prefs.begin(NVS_NS, true); // read-only
cfg_.enabled = prefs.getBool("enabled", false);
cfg_.host = prefs.getString("host", "homeassistant.local");
cfg_.port = static_cast<uint16_t>(prefs.getUInt("port", 1883));
cfg_.user = prefs.getString("user", "");
cfg_.pass = prefs.getString("pass", "");
cfg_.prefix = prefs.getString("prefix", "rackglow");
prefs.end();
}
void MqttManager::buildTopics() {
baseTopic_ = cfg_.prefix + "/" + shortId_;
topicLightSet_ = baseTopic_ + "/light/set";
topicLightState_ = baseTopic_ + "/light/state";
topicVuSet_ = baseTopic_ + "/vu/set";
topicVuState_ = baseTopic_ + "/vu/state";
topicSleepSet_ = baseTopic_ + "/sleep/set";
topicSleepState_ = baseTopic_ + "/sleep/state";
topicStatus_ = baseTopic_ + "/status";
}
void MqttManager::startClient() {
buildTopics();
esp_mqtt_client_config_t mcfg = {};
mcfg.broker.address.hostname = cfg_.host.c_str();
mcfg.broker.address.port = cfg_.port;
mcfg.broker.address.transport = MQTT_TRANSPORT_OVER_TCP;
mcfg.credentials.client_id = objectId_.c_str();
if (cfg_.user.length() > 0) {
mcfg.credentials.username = cfg_.user.c_str();
mcfg.credentials.authentication.password = cfg_.pass.c_str();
}
// Last will → device goes "offline" if it drops without a clean disconnect.
mcfg.session.last_will.topic = topicStatus_.c_str();
mcfg.session.last_will.msg = "offline";
mcfg.session.last_will.msg_len = 7;
mcfg.session.last_will.qos = 1;
mcfg.session.last_will.retain = true;
client_ = esp_mqtt_client_init(&mcfg);
if (!client_) {
Logger.error("MQTT: client init failed");
return;
}
esp_mqtt_client_register_event(
client_, static_cast<esp_mqtt_event_id_t>(MQTT_EVENT_ANY), eventHandler, this);
esp_mqtt_client_start(client_);
Logger.info("MQTT: connecting to %s:%u as %s", cfg_.host.c_str(),
static_cast<unsigned>(cfg_.port), objectId_.c_str());
}
void MqttManager::stopClient() {
connected_ = false;
if (client_) {
esp_mqtt_client_stop(client_);
esp_mqtt_client_destroy(client_);
client_ = nullptr;
}
}
void MqttManager::applyConfig(bool enabled, const String& host, uint16_t port,
const String& user, const String& pass, const String& prefix) {
Preferences prefs;
prefs.begin(NVS_NS, false); // read-write
prefs.putBool("enabled", enabled);
prefs.putString("host", host);
prefs.putUInt("port", port);
prefs.putString("user", user);
prefs.putString("pass", pass);
prefs.putString("prefix", prefix.length() ? prefix : String("rackglow"));
prefs.end();
// Defer the actual client restart to update() on the main loop — esp_mqtt
// stop/destroy can block and this runs on the AsyncTCP task.
reconfigurePending_ = true;
Logger.info("MQTT: config saved (enabled=%d host=%s)", enabled ? 1 : 0, host.c_str());
}
void MqttManager::update() {
if (reconfigurePending_) {
reconfigurePending_ = false;
stopClient();
loadConfig();
if (cfg_.enabled && cfg_.host.length() > 0) {
startClient();
}
}
// Drain inbound commands (parsed + applied on the main loop, not the MQTT task).
if (inboundQueue_) {
InboundMsg msg;
while (xQueueReceive(inboundQueue_, &msg, 0) == pdTRUE) {
handleInbound(msg);
}
}
if (connected_) {
publishStateIfChanged(forcePublish_);
forcePublish_ = false;
}
}
void MqttManager::enqueueInbound(const char* topic, int topicLen, const char* data, int dataLen) {
if (!inboundQueue_) return;
InboundMsg msg;
int tl = topicLen < (int)sizeof(msg.topic) - 1 ? topicLen : (int)sizeof(msg.topic) - 1;
int dl = dataLen < (int)sizeof(msg.payload) - 1 ? dataLen : (int)sizeof(msg.payload) - 1;
memcpy(msg.topic, topic, tl);
msg.topic[tl] = '\0';
memcpy(msg.payload, data, dl);
msg.payload[dl] = '\0';
xQueueSend(inboundQueue_, &msg, 0);
}
void MqttManager::handleInbound(const InboundMsg& msg) {
if (topicLightSet_ == msg.topic) {
handleLightCommand(msg.payload);
} else if (topicVuSet_ == msg.topic) {
handleVuCommand(msg.payload);
} else if (topicSleepSet_ == msg.topic) {
handleSleepCommand(msg.payload);
}
}
void MqttManager::handleLightCommand(const char* payload) {
if (!g_uiManager) return;
JsonDocument doc;
if (deserializeJson(doc, payload)) {
Logger.warning("MQTT: bad light payload");
return;
}
// Colour takes the device into solid-colour mode (disables animation/white).
if (doc["color"].is<JsonObjectConst>()) {
JsonObjectConst c = doc["color"].as<JsonObjectConst>();
uint8_t r = static_cast<uint8_t>(c["r"] | 0);
uint8_t g = static_cast<uint8_t>(c["g"] | 0);
uint8_t b = static_cast<uint8_t>(c["b"] | 0);
UiCommand cmd;
cmd.type = UiCommandType::SetColour;
String hex = "#" + toHex6((static_cast<uint32_t>(r) << 16) |
(static_cast<uint32_t>(g) << 8) | b);
strlcpy(cmd.colour, hex.c_str(), sizeof(cmd.colour));
g_uiManager->postUiCommand(cmd);
} else if (doc["effect"].is<const char*>()) {
const char* eff = doc["effect"];
if (strcmp(eff, EFFECT_WHITE) == 0) {
UiCommand cmd;
cmd.type = UiCommandType::SetWhite;
cmd.boolValue = true;
g_uiManager->postUiCommand(cmd);
} else if (strcmp(eff, EFFECT_SOLID) == 0) {
// Re-assert the stored colour to force plain solid mode.
UiCommand cmd;
cmd.type = UiCommandType::SetColour;
strlcpy(cmd.colour, currentColorHex().c_str(), sizeof(cmd.colour));
g_uiManager->postUiCommand(cmd);
} else {
for (int i = 0; i < ANIMATION_COUNT; i++) {
if (strcmp(eff, animationDescription(static_cast<AnimationType>(i))) == 0) {
UiCommand cmd;
cmd.type = UiCommandType::SetAnimation;
cmd.boolValue = true;
cmd.intValue = i;
g_uiManager->postUiCommand(cmd);
break;
}
}
}
}
// Brightness / on-off. Explicit brightness wins; otherwise state ON restores the
// last non-zero brightness and state OFF drops output to zero.
bool haveBrightness = doc["brightness"].is<int>();
int target = -1;
if (haveBrightness) {
target = doc["brightness"].as<int>();
} else if (doc["state"].is<const char*>()) {
const char* st = doc["state"];
if (strcmp(st, "OFF") == 0) {
target = 0;
} else if (strcmp(st, "ON") == 0 && led_ && led_->getBrightness() == 0) {
target = lastOnBrightness_ > 0 ? lastOnBrightness_ : 255;
}
}
if (target >= 0) {
UiCommand cmd;
cmd.type = UiCommandType::SetBrightness;
cmd.intValue = target < 255 ? target : 255;
g_uiManager->postUiCommand(cmd);
}
}
void MqttManager::handleVuCommand(const char* payload) {
if (!g_uiManager) return;
UiCommand cmd;
cmd.type = UiCommandType::SetVu;
cmd.boolValue = isOnPayload(payload);
g_uiManager->postUiCommand(cmd);
}
void MqttManager::handleSleepCommand(const char* payload) {
if (!g_uiManager) return;
UiCommand cmd;
cmd.type = UiCommandType::SetSleep;
cmd.boolValue = isOnPayload(payload);
g_uiManager->postUiCommand(cmd);
}
// ── Publishing ─────────────────────────────────────────────────────────────
void MqttManager::publish(const String& topic, const String& payload, int qos, bool retain) {
if (!client_) return;
esp_mqtt_client_publish(client_, topic.c_str(), payload.c_str(), payload.length(), qos, retain);
}
void MqttManager::publishDiscovery() {
JsonDocument dev;
dev["identifiers"][0] = shortId_;
dev["name"] = "Rackglow";
dev["manufacturer"] = "DIY Doohickeys";
dev["model"] = "Rackglow";
// Light entity (JSON schema): brightness + rgb + effect.
{
JsonDocument doc;
doc["schema"] = "json";
doc["name"] = "LEDs";
doc["has_entity_name"] = true;
doc["unique_id"] = objectId_ + "_light";
doc["command_topic"] = topicLightSet_;
doc["state_topic"] = topicLightState_;
doc["availability_topic"] = topicStatus_;
doc["brightness"] = true;
doc["supported_color_modes"][0] = "rgb";
doc["effect"] = true;
JsonArray effects = doc["effect_list"].to<JsonArray>();
effects.add(EFFECT_SOLID);
effects.add(EFFECT_WHITE);
for (int i = 0; i < ANIMATION_COUNT; i++) {
effects.add(animationDescription(static_cast<AnimationType>(i)));
}
doc["device"] = dev.as<JsonObjectConst>();
String out;
serializeJson(doc, out);
publish("homeassistant/light/" + objectId_ + "/config", out, 1, true);
}
// VU mode switch.
{
JsonDocument doc;
doc["name"] = "VU Mode";
doc["has_entity_name"] = true;
doc["unique_id"] = objectId_ + "_vu";
doc["command_topic"] = topicVuSet_;
doc["state_topic"] = topicVuState_;
doc["availability_topic"] = topicStatus_;
doc["icon"] = "mdi:music-note";
doc["device"] = dev.as<JsonObjectConst>();
String out;
serializeJson(doc, out);
publish("homeassistant/switch/" + objectId_ + "_vu/config", out, 1, true);
}
// Screen sleep switch. ON = backlight + panel off; a screen tap wakes it and
// the state publish flips this back to OFF.
{
JsonDocument doc;
doc["name"] = "Screen Sleep";
doc["has_entity_name"] = true;
doc["unique_id"] = objectId_ + "_sleep";
doc["command_topic"] = topicSleepSet_;
doc["state_topic"] = topicSleepState_;
doc["availability_topic"] = topicStatus_;
doc["icon"] = "mdi:monitor-off";
doc["device"] = dev.as<JsonObjectConst>();
String out;
serializeJson(doc, out);
publish("homeassistant/switch/" + objectId_ + "_sleep/config", out, 1, true);
}
Logger.info("MQTT: published HA discovery for %s", objectId_.c_str());
}
void MqttManager::publishAvailability(bool online) {
publish(topicStatus_, online ? "online" : "offline", 1, true);
}
void MqttManager::publishStateIfChanged(bool force) {
if (!led_) return;
uint8_t bri = led_->getBrightness();
bool anim = led_->isAnimationEnabled();
int animIdx = static_cast<int>(led_->getCurrentAnimation());
bool white = led_->isWhiteModeEnabled();
bool vu = led_->isVuModeEnabled();
CRGB col = led_->getSolidColor();
uint32_t packed = (uint32_t(col.r) << 16) | (uint32_t(col.g) << 8) | col.b;
bool lightChanged = force || bri != lastBrightness_ || anim != lastAnim_ ||
animIdx != lastAnimIdx_ || white != lastWhite_ || packed != lastColor_;
bool vuChanged = force || vu != lastVu_;
bool asleep = Display::isAsleep();
bool sleepChanged = force || asleep != lastSleep_;
if (lightChanged) {
if (bri > 0) lastOnBrightness_ = bri;
JsonDocument doc;
doc["state"] = bri > 0 ? "ON" : "OFF";
doc["brightness"] = bri;
doc["color_mode"] = "rgb";
JsonObject c = doc["color"].to<JsonObject>();
c["r"] = col.r;
c["g"] = col.g;
c["b"] = col.b;
doc["effect"] = currentEffect();
String out;
serializeJson(doc, out);
publish(topicLightState_, out, 0, true);
lastBrightness_ = bri;
lastAnim_ = anim;
lastAnimIdx_ = animIdx;
lastWhite_ = white;
lastColor_ = packed;
}
if (vuChanged) {
publish(topicVuState_, vu ? "ON" : "OFF", 0, true);
lastVu_ = vu;
}
if (sleepChanged) {
publish(topicSleepState_, asleep ? "ON" : "OFF", 0, true);
lastSleep_ = asleep;
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
const char* MqttManager::currentEffect() const {
if (!led_) return EFFECT_SOLID;
if (led_->isAnimationEnabled()) {
return animationDescription(led_->getCurrentAnimation());
}
if (led_->isWhiteModeEnabled()) {
return EFFECT_WHITE;
}
return EFFECT_SOLID;
}
String MqttManager::currentColorHex() const {
CRGB c = led_ ? led_->getSolidColor() : CRGB(255, 255, 255);
return "#" + toHex6((static_cast<uint32_t>(c.r) << 16) |
(static_cast<uint32_t>(c.g) << 8) | c.b);
}
96336ed42This file is pinned to the selected release. Download the original to open it in your design tool.