diff --git a/firmware/.gitignore b/firmware/.gitignore index ecf5a34..13b6b60 100644 --- a/firmware/.gitignore +++ b/firmware/.gitignore @@ -78,6 +78,9 @@ sdkconfig sdkconfig.old sdkconfig.*.old +# Environment files (local, may contain secrets) +.env + # IDE .vscode/ .idea/ diff --git a/firmware/include/config.hpp b/firmware/include/config.hpp index 3e86588..7eb1eb4 100644 --- a/firmware/include/config.hpp +++ b/firmware/include/config.hpp @@ -40,6 +40,9 @@ constexpr const char *PORT_KEY = "mqtt_port"; constexpr const char *USER_KEY = "mqtt_user"; constexpr const char *PASSWORD_KEY = "mqtt_password"; +// Fallback broker port when none has been provisioned. +constexpr const char *DEFAULT_PORT = "1883"; + } // namespace mqtt namespace ble { diff --git a/firmware/src/CMakeLists.txt b/firmware/src/CMakeLists.txt index c5b16a0..57c03af 100644 --- a/firmware/src/CMakeLists.txt +++ b/firmware/src/CMakeLists.txt @@ -1,3 +1,3 @@ FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*) idf_component_register(SRCS ${app_sources} - REQUIRES bt esp_wifi esp_event esp_netif esp_timer) + REQUIRES bt esp_wifi esp_event esp_netif esp_timer mqtt) diff --git a/firmware/src/connectivity/mqtt/application/mqtt_service.cpp b/firmware/src/connectivity/mqtt/application/mqtt_service.cpp new file mode 100644 index 0000000..482f56a --- /dev/null +++ b/firmware/src/connectivity/mqtt/application/mqtt_service.cpp @@ -0,0 +1,90 @@ +#include "connectivity/mqtt/application/mqtt_service.hpp" + +#include +#include + +#include + +#include "connectivity/mqtt/tag.hpp" + +namespace connectivity::mqtt { + +MqttService::MqttService(IMqttClient &client, config::IMqttConfig &config, + config::IDeviceConfig &device, std::string defaultPort) + : _client(client), + _config(config), + _device(device), + _defaultPort(std::move(defaultPort)) {} + +esp_err_t MqttService::connect(StatusHandler onStatus, + MessageHandler onMessage) { + std::string host; + esp_err_t err = _config.getHost(host); + if (err == ESP_ERR_NVS_NOT_FOUND || (err == ESP_OK && host.empty())) { + ESP_LOGW(TAG, "no MQTT host; provision via BLE first"); + + return ESP_ERR_INVALID_STATE; + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed to read host: %s", esp_err_to_name(err)); + + return err; + } + + std::string clientId; + err = _device.getId(clientId); + if (err == ESP_ERR_NVS_NOT_FOUND || (err == ESP_OK && clientId.empty())) { + ESP_LOGW(TAG, "no device_id; cannot set MQTT client id"); + + return ESP_ERR_INVALID_STATE; + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed to read device_id: %s", esp_err_to_name(err)); + + return err; + } + + std::string port; + err = _config.getPort(port); + if (err == ESP_ERR_NVS_NOT_FOUND || (err == ESP_OK && port.empty())) { + port = _defaultPort; + } else if (err != ESP_OK) { + return err; + } + + std::string user; + err = _config.getUser(user); + if (err == ESP_ERR_NVS_NOT_FOUND) { + user.clear(); + } else if (err != ESP_OK) { + return err; + } + + std::string password; + err = _config.getPassword(password); + if (err == ESP_ERR_NVS_NOT_FOUND) { + password.clear(); + } else if (err != ESP_OK) { + return err; + } + + ClientConfig config{"mqtt://" + host + ":" + port, clientId, user, password}; + + return _client.begin(config, std::move(onStatus), std::move(onMessage)); +} + +esp_err_t MqttService::publish(const std::string &topic, + const std::string &payload, int qos, + bool retain) { + return _client.publish(topic, payload, qos, retain); +} + +esp_err_t MqttService::subscribe(const std::string &topic, int qos) { + return _client.subscribe(topic, qos); +} + +esp_err_t MqttService::stop() { return _client.stop(); } + +bool MqttService::isConnected() const { return _client.isConnected(); } + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/application/mqtt_service.hpp b/firmware/src/connectivity/mqtt/application/mqtt_service.hpp new file mode 100644 index 0000000..211cac9 --- /dev/null +++ b/firmware/src/connectivity/mqtt/application/mqtt_service.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "connectivity/config/domain/i_device_config.hpp" +#include "connectivity/config/domain/i_mqtt_config.hpp" +#include "connectivity/mqtt/domain/i_mqtt_client.hpp" + +namespace connectivity::mqtt { + +/** + * @brief Application service that drives the MQTT client from stored config. + * + * Reads the broker settings from the MQTT configuration store and the client id + * from the device identity store (client_id = device_id), assembles the + * @c ClientConfig and asks the client to connect. publish/subscribe/stop are + * thin pass-throughs to the client. + */ +class MqttService { + public: + /** + * @brief Builds the service. + * + * @param client MQTT client port. Must outlive this instance. + * @param config MQTT configuration store. Must outlive this instance. + * @param device Device identity store. Must outlive this instance. + * @param defaultPort Broker port used when none has been provisioned. + */ + MqttService(IMqttClient &client, config::IMqttConfig &config, + config::IDeviceConfig &device, std::string defaultPort); + + /** + * @brief Connects using the stored broker settings and device id. + * + * @param onStatus Callback invoked on every status transition. + * @param onMessage Callback invoked for every received message. + * @return ESP_OK if the client started, ESP_ERR_INVALID_STATE if the + * host or device id are missing, or an ESP-IDF error code. + */ + esp_err_t connect(StatusHandler onStatus, MessageHandler onMessage); + + esp_err_t publish(const std::string &topic, const std::string &payload, + int qos, bool retain); + esp_err_t subscribe(const std::string &topic, int qos); + esp_err_t stop(); + bool isConnected() const; + + private: + IMqttClient &_client; + config::IMqttConfig &_config; + config::IDeviceConfig &_device; + std::string _defaultPort; +}; + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/domain/i_mqtt_client.hpp b/firmware/src/connectivity/mqtt/domain/i_mqtt_client.hpp new file mode 100644 index 0000000..4ec1989 --- /dev/null +++ b/firmware/src/connectivity/mqtt/domain/i_mqtt_client.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include + +#include +#include + +namespace connectivity::mqtt { + +/// Connection state of the MQTT client. +enum class Status { Disconnected, Connecting, Connected }; + +/// Notified on every client status transition. +using StatusHandler = std::function; + +/// An inbound MQTT message. +struct Message { + std::string topic; + std::string payload; +}; + +/// Notified for every received message on a subscribed topic. +using MessageHandler = std::function; + +/// Everything the client needs to connect to the broker. +struct ClientConfig { + std::string uri; // e.g. mqtt://host:port + std::string clientId; // the device id + std::string username; // empty for anonymous + std::string password; // empty for anonymous +}; + +/** + * @brief Port for an MQTT client. + * + * Abstracts the broker connection (esp-mqtt here) behind connect/publish/ + * subscribe operations and asynchronous status/message callbacks. Keeps the + * MQTT stack out of the application layer. + */ +class IMqttClient { + public: + virtual ~IMqttClient() = default; + + /** + * @brief Initializes the client and starts connecting (non-blocking). + * + * The outcome and inbound messages arrive asynchronously via the callbacks. + * The transport reconnects on its own while running. + * + * @param config Broker URI and credentials. + * @param onStatus Callback invoked on every status transition. + * @param onMessage Callback invoked for every received message. + * @return ESP_OK if the client started, or an ESP-IDF error code. + */ + virtual esp_err_t begin(const ClientConfig &config, StatusHandler onStatus, + MessageHandler onMessage) = 0; + + /** + * @brief Publishes a payload to a topic. + * + * @param topic Destination topic. + * @param payload Message payload (may be binary). + * @param qos MQTT QoS (0, 1 or 2). + * @param retain Whether the broker should retain the message. + * @return ESP_OK on success, or an ESP-IDF error code on failure. + */ + virtual esp_err_t publish(const std::string &topic, + const std::string &payload, int qos, + bool retain) = 0; + + /** + * @brief Subscribes to a topic. + * + * Subscriptions are not restored automatically after a reconnect; + * re-subscribe from the @c Connected status callback. + * + * @param topic Topic filter to subscribe to. + * @param qos Requested MQTT QoS. + * @return ESP_OK on success, or an ESP-IDF error code on failure. + */ + virtual esp_err_t subscribe(const std::string &topic, int qos) = 0; + + /** + * @brief Stops the client and disconnects from the broker. + * + * @return ESP_OK on success, or an ESP-IDF error code on failure. + */ + virtual esp_err_t stop() = 0; + + /** + * @brief Whether the client currently holds a broker connection. + */ + virtual bool isConnected() const = 0; +}; + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/infrastructure/esp_mqtt_client.cpp b/firmware/src/connectivity/mqtt/infrastructure/esp_mqtt_client.cpp new file mode 100644 index 0000000..67ee639 --- /dev/null +++ b/firmware/src/connectivity/mqtt/infrastructure/esp_mqtt_client.cpp @@ -0,0 +1,149 @@ +#include "connectivity/mqtt/infrastructure/esp_mqtt_client.hpp" + +#include +#include + +#include +#include + +#include "connectivity/mqtt/tag.hpp" + +namespace connectivity::mqtt { + +namespace { + +// Single client per device, so the esp-mqtt handle and state live at file +// scope. +esp_mqtt_client_handle_t g_client = nullptr; +bool g_connected = false; +StatusHandler g_onStatus; +MessageHandler g_onMessage; +ClientConfig g_config; // keeps the credential strings alive + +void notify(Status status) { + if (g_onStatus) { + g_onStatus(status); + } +} + +void eventHandler(void *, esp_event_base_t, std::int32_t id, void *data) { + auto *event = static_cast(data); + + switch (static_cast(id)) { + case MQTT_EVENT_CONNECTED: + g_connected = true; + ESP_LOGI(TAG, "connected to broker"); + notify(Status::Connected); + break; + case MQTT_EVENT_DISCONNECTED: + g_connected = false; + ESP_LOGW(TAG, "disconnected; retrying"); + notify(Status::Disconnected); + break; + case MQTT_EVENT_BEFORE_CONNECT: + notify(Status::Connecting); + break; + case MQTT_EVENT_DATA: + if (event->topic != nullptr && g_onMessage) { + Message msg; + msg.topic.assign(event->topic, event->topic_len); + msg.payload.assign(event->data, event->data_len); + g_onMessage(msg); + } + break; + case MQTT_EVENT_ERROR: + ESP_LOGE(TAG, "mqtt error"); + break; + default: + break; + } +} + +} // namespace + +esp_err_t EspMqttClient::begin(const ClientConfig &config, + StatusHandler onStatus, + MessageHandler onMessage) { + if (g_client != nullptr) { + return ESP_OK; + } + + g_config = config; + g_onStatus = std::move(onStatus); + g_onMessage = std::move(onMessage); + + esp_mqtt_client_config_t cfg = {}; + cfg.broker.address.uri = g_config.uri.c_str(); + cfg.credentials.client_id = g_config.clientId.c_str(); + if (!g_config.username.empty()) { + cfg.credentials.username = g_config.username.c_str(); + } + if (!g_config.password.empty()) { + cfg.credentials.authentication.password = g_config.password.c_str(); + } + + g_client = esp_mqtt_client_init(&cfg); + if (g_client == nullptr) { + ESP_LOGE(TAG, "esp_mqtt_client_init failed"); + + return ESP_FAIL; + } + + esp_err_t err = esp_mqtt_client_register_event(g_client, MQTT_EVENT_ANY, + &eventHandler, nullptr); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_mqtt_client_register_event failed: %s", + esp_err_to_name(err)); + + return err; + } + + notify(Status::Connecting); + err = esp_mqtt_client_start(g_client); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_mqtt_client_start failed: %s", esp_err_to_name(err)); + + return err; + } + + ESP_LOGI(TAG, "mqtt client started (%s)", g_config.uri.c_str()); + + return ESP_OK; +} + +esp_err_t EspMqttClient::publish(const std::string &topic, + const std::string &payload, int qos, + bool retain) { + if (g_client == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + int msgId = esp_mqtt_client_publish(g_client, topic.c_str(), payload.data(), + payload.size(), qos, retain ? 1 : 0); + + return msgId < 0 ? ESP_FAIL : ESP_OK; +} + +esp_err_t EspMqttClient::subscribe(const std::string &topic, int qos) { + if (g_client == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + int msgId = esp_mqtt_client_subscribe_single(g_client, topic.c_str(), qos); + + return msgId < 0 ? ESP_FAIL : ESP_OK; +} + +esp_err_t EspMqttClient::stop() { + if (g_client == nullptr) { + return ESP_OK; + } + + g_connected = false; + + return esp_mqtt_client_stop(g_client); +} + +bool EspMqttClient::isConnected() const { return g_connected; } + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/infrastructure/esp_mqtt_client.hpp b/firmware/src/connectivity/mqtt/infrastructure/esp_mqtt_client.hpp new file mode 100644 index 0000000..cb932fc --- /dev/null +++ b/firmware/src/connectivity/mqtt/infrastructure/esp_mqtt_client.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "connectivity/mqtt/domain/i_mqtt_client.hpp" + +namespace connectivity::mqtt { + +/** + * @brief esp-mqtt client adapter. + * + * Implements @c IMqttClient over the ESP-IDF esp-mqtt component. There is a + * single client per device, so the client handle and state live at file scope + * in the implementation; this keeps the MQTT stack headers out of the rest of + * the codebase. esp-mqtt reconnects to the broker on its own. + */ +class EspMqttClient : public IMqttClient { + public: + /** + * @brief Initializes the esp-mqtt client and starts it. + * + * Builds the client config from @p config, registers the event handler and + * starts the client (non-blocking). Idempotent: a no-op if already started. + * + * @param config Broker URI and credentials. + * @param onStatus Callback invoked on every status transition. + * @param onMessage Callback invoked for every received message. + * @return ESP_OK if the client started, or an ESP-IDF error code. + */ + esp_err_t begin(const ClientConfig &config, StatusHandler onStatus, + MessageHandler onMessage) override; + + /** + * @brief Publishes a payload to a topic. + * + * @return ESP_OK on success, ESP_ERR_INVALID_STATE if not started, or + * ESP_FAIL if the client rejected the message. + */ + esp_err_t publish(const std::string &topic, const std::string &payload, + int qos, bool retain) override; + + /** + * @brief Subscribes to a topic. + * + * @return ESP_OK on success, ESP_ERR_INVALID_STATE if not started, or + * ESP_FAIL on failure. + */ + esp_err_t subscribe(const std::string &topic, int qos) override; + + /** + * @brief Stops the client and disconnects from the broker. + * + * @return ESP_OK on success, or an ESP-IDF error code on failure. + */ + esp_err_t stop() override; + + /** + * @brief Whether the client currently holds a broker connection. + */ + bool isConnected() const override; +}; + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/mqtt.cpp b/firmware/src/connectivity/mqtt/mqtt.cpp new file mode 100644 index 0000000..786aa6a --- /dev/null +++ b/firmware/src/connectivity/mqtt/mqtt.cpp @@ -0,0 +1,46 @@ +#include "connectivity/mqtt/mqtt.hpp" + +#include + +namespace connectivity::mqtt { + +Mqtt::Mqtt(config::IMqttConfig &config, config::IDeviceConfig &device, + std::string defaultPort) + : _service(_client, config, device, std::move(defaultPort)) {} + +esp_err_t Mqtt::start() { + return _service.connect( + [this](Status status) { + if (_userStatus) { + _userStatus(status); + } + }, + [this](const Message &message) { + if (_userMessage) { + _userMessage(message); + } + }); +} + +esp_err_t Mqtt::stop() { return _service.stop(); } + +esp_err_t Mqtt::publish(const std::string &topic, const std::string &payload, + int qos, bool retain) { + return _service.publish(topic, payload, qos, retain); +} + +esp_err_t Mqtt::subscribe(const std::string &topic, int qos) { + return _service.subscribe(topic, qos); +} + +bool Mqtt::isConnected() const { return _service.isConnected(); } + +void Mqtt::onStatusChange(StatusHandler handler) { + _userStatus = std::move(handler); +} + +void Mqtt::onMessage(MessageHandler handler) { + _userMessage = std::move(handler); +} + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/mqtt.hpp b/firmware/src/connectivity/mqtt/mqtt.hpp new file mode 100644 index 0000000..bdb07c6 --- /dev/null +++ b/firmware/src/connectivity/mqtt/mqtt.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include + +#include "connectivity/config/domain/i_device_config.hpp" +#include "connectivity/config/domain/i_mqtt_config.hpp" +#include "connectivity/mqtt/application/mqtt_service.hpp" +#include "connectivity/mqtt/domain/i_mqtt_client.hpp" +#include "connectivity/mqtt/infrastructure/esp_mqtt_client.hpp" + +namespace connectivity::mqtt { + +/** + * @brief Module facade for MQTT connectivity. + * + * Composition root of the mqtt module: owns the esp-mqtt adapter and the + * application service, wiring them to the MQTT and device configuration stores. + * Connects asynchronously using the stored broker settings (client_id = + * device_id) and exposes publish/subscribe primitives plus connection status. + */ +class Mqtt { + public: + /** + * @brief Builds the MQTT module. + * + * @param config MQTT configuration store. Must outlive this instance. + * @param device Device identity store. Must outlive this instance. + * @param defaultPort Broker port used when none has been provisioned. + */ + Mqtt(config::IMqttConfig &config, config::IDeviceConfig &device, + std::string defaultPort); + + /** + * @brief Connects to the broker using the stored settings. + * + * @return ESP_OK if the client started, ESP_ERR_INVALID_STATE if the host or + * device id are missing, or an ESP-IDF error code on failure. + */ + esp_err_t start(); + + /** + * @brief Stops the client and disconnects. + * + * @return ESP_OK on success, or an ESP-IDF error code on failure. + */ + esp_err_t stop(); + + /** + * @brief Publishes a payload to a topic. + */ + esp_err_t publish(const std::string &topic, const std::string &payload, + int qos = 0, bool retain = false); + + /** + * @brief Subscribes to a topic (re-subscribe from the Connected callback). + */ + esp_err_t subscribe(const std::string &topic, int qos = 0); + + /** + * @brief Whether the client currently holds a broker connection. + */ + bool isConnected() const; + + /** + * @brief Registers a callback notified on every status transition. + */ + void onStatusChange(StatusHandler handler); + + /** + * @brief Registers a callback notified for every received message. + */ + void onMessage(MessageHandler handler); + + private: + EspMqttClient _client; + MqttService _service; + StatusHandler _userStatus; + MessageHandler _userMessage; +}; + +} // namespace connectivity::mqtt diff --git a/firmware/src/connectivity/mqtt/tag.hpp b/firmware/src/connectivity/mqtt/tag.hpp new file mode 100644 index 0000000..6274fb4 --- /dev/null +++ b/firmware/src/connectivity/mqtt/tag.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace connectivity::mqtt { + +constexpr const char *TAG = "connectivity/mqtt"; + +} // namespace connectivity::mqtt diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 25b177b..744dbe3 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -7,6 +7,7 @@ #include "connectivity/config/infrastructure/flash_device_config.hpp" #include "connectivity/config/infrastructure/flash_mqtt_config.hpp" #include "connectivity/config/infrastructure/flash_wifi_config.hpp" +#include "connectivity/mqtt/mqtt.hpp" #include "connectivity/provisioning/provisioning.hpp" #include "connectivity/wifi/wifi.hpp" #include "peripherals/flash_memory/flash_memory.hpp" @@ -45,6 +46,8 @@ static connectivity::provisioning::Provisioning provisioning( static connectivity::wifi::Wifi wifi(wifiConfig, config::wifi::RECONNECT_DELAY_MS, config::wifi::MAX_TX_POWER); +static connectivity::mqtt::Mqtt mqtt(mqttConfig, deviceConfig, + config::mqtt::DEFAULT_PORT); extern "C" void app_main() { if (beginPeripherals(flashMemory, loadRelay, builtinLed) != ESP_OK) { @@ -63,6 +66,11 @@ extern "C" void app_main() { ESP_LOGE(TAG, "wifi start failed: %s", esp_err_to_name(err)); } + err = mqtt.start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "mqtt start failed: %s", esp_err_to_name(err)); + } + // Idle loop: keeps app_main alive until the real application logic is added. while (true) { vTaskDelay(pdMS_TO_TICKS(1000));