Files
arduino-home-assistant/src/HAMqtt.h
T

812 lines
27 KiB
C++

#ifndef AHA_HAMQTT_H
#define AHA_HAMQTT_H
#include <Arduino.h>
#include <Client.h>
#include <IPAddress.h>
#include "ArduinoHADefines.h"
#define HAMQTT_CALLBACK(name) void (*name)()
#define HAMQTT_STATE_CALLBACK(name) void (*name)(ConnectionState state)
#define HAMQTT_MESSAGE_CALLBACK(name) void (*name)(const char* topic, const uint8_t* payload, uint16_t length)
#define HAMQTT_DEFAULT_PORT 1883
#ifdef ARDUINOHA_TEST
class PubSubClientMock;
#else
#include <PubSubClient.h>
#endif
#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__)
#define HAMQTT_DEFAULT_DEVICES_LIMIT 6
#else
#define HAMQTT_DEFAULT_DEVICES_LIMIT 24
#endif
class HADevice;
class HABaseDeviceType;
class HASerializer;
#if defined(ARDUINO_API_VERSION)
using namespace arduino;
#endif
/**
* This class is a wrapper for the PubSub API.
* It's a central point of the library where instances of all device types are stored.
*/
class HAMqtt
{
public:
/**
* Best-effort classification of the last disconnect or transport failure
* (for diagnostics; not an MQTT DISCONNECT reason code).
*/
enum class DiagnosticDisconnectReason : uint8_t {
None = 0,
LoopReturnedFalse,
DeferredBeginPublishFailed,
DeferredEndPublishFailed,
NotConnectedDuringDeferredFlush,
UnderlyingPubSubStateChanged,
ExplicitDisconnect,
};
static const char* diagnosticDisconnectReasonText(DiagnosticDisconnectReason reason);
enum ConnectionState {
StateConnecting = -5,
StateConnectionTimeout = -4,
StateConnectionLost = -3,
StateConnectionFailed = -2,
StateDisconnected = -1,
StateConnected = 0,
StateBadProtocol = 1,
StateBadClientId = 2,
StateUnavailable = 3,
StateBadCredentials = 4,
StateUnauthorized = 5
};
/**
* Explicit stages for safely migrating retained single-component discovery
* to Home Assistant device discovery.
*/
enum DeviceDiscoveryMigrationState : uint8_t {
DeviceDiscoveryMigrationIdle = 0,
DeviceDiscoveryMigrationMarkersPending,
DeviceDiscoveryMigrationMarkersPublished,
DeviceDiscoveryMigrationDevicePublished,
DeviceDiscoveryMigrationCompleted,
DeviceDiscoveryMigrationRollbackPending
};
/**
* Returns existing instance (singleton) of the HAMqtt class.
* It may be a null pointer if the HAMqtt object was never constructed or it was destroyed.
*/
inline static HAMqtt* instance()
{ return _instance; }
#ifdef ARDUINOHA_TEST
explicit HAMqtt(
PubSubClientMock* pubSub,
HADevice& device,
const uint8_t maxDevicesTypesNb = HAMQTT_DEFAULT_DEVICES_LIMIT
);
#else
/**
* Creates a new instance of the HAMqtt class.
* Please note that only one instance of the class can be initialized at the same time.
*
* @param netClient The EthernetClient or WiFiClient that's going to be used for the network communication.
* @param device An instance of the HADevice class representing your device.
* @param maxDevicesTypesNb The maximum number of device types (sensors, switches, etc.) that you're going to implement.
*/
explicit HAMqtt(
Client& netClient,
HADevice& device,
const uint8_t maxDevicesTypesNb = HAMQTT_DEFAULT_DEVICES_LIMIT
);
#endif
/**
* Removes singleton of the HAMqtt class.
*/
~HAMqtt();
HAMqtt(const HAMqtt&) = delete;
HAMqtt& operator=(const HAMqtt&) = delete;
HAMqtt(HAMqtt&&) = delete;
HAMqtt& operator=(HAMqtt&&) = delete;
/**
* Sets the prefix of the Home Assistant discovery topics.
* It needs to match the prefix set in the HA admin panel.
* The default prefix is "homeassistant".
*
* @param prefix The discovery topics' prefix.
*/
inline void setDiscoveryPrefix(const char* prefix)
{ _discoveryPrefix = prefix; }
/**
* Returns the discovery topics' prefix.
*/
inline const char* getDiscoveryPrefix() const
{ return _discoveryPrefix; }
/**
* Sets prefix of the data topics.
* It may be useful if you want to pass MQTT traffic through a bridge.
* The default prefix is "aha".
*
* @param prefix The data topics' prefix.
*/
inline void setDataPrefix(const char* prefix)
{ _dataPrefix = prefix; }
/**
* Returns the data topics' prefix.
*/
inline const char* getDataPrefix() const
{ return _dataPrefix; }
/**
* Enables MQTT device discovery payloads.
* Single-component discovery remains the default when this is not enabled.
*/
inline void enableDeviceDiscovery()
{ _deviceDiscoveryEnabled = true; }
/**
* Returns true when MQTT device discovery payloads are enabled.
*/
inline bool isDeviceDiscoveryEnabled() const
{ return _deviceDiscoveryEnabled; }
/**
* Starts an explicit Home Assistant-safe migration from retained
* single-component discovery to device discovery.
*
* This method enables device discovery but does not publish anything. Call
* the subsequent migration methods after MQTT is connected, in order.
*/
bool beginDeviceDiscoveryMigration();
/**
* Publishes retained migration markers to each legacy component config
* topic. The state advances only when every marker is published.
*/
bool publishDeviceDiscoveryMigrationMarkers();
/**
* Publishes the retained device discovery config after all legacy markers
* have been published.
*/
bool publishDeviceDiscoveryMigrationConfig();
/**
* Clears the retained legacy component config topics after the device
* config was published. The state advances only when every cleanup
* publish succeeds.
*/
bool completeDeviceDiscoveryMigration();
/**
* Reverses a staged device discovery migration. A migration marker is
* published to the device discovery topic before legacy retained configs
* are restored and the device config is cleared.
* The method never runs automatically and returns to single-component
* discovery only after every required retained publish succeeds.
*/
bool rollbackDeviceDiscoveryMigration();
/**
* Returns the current in-memory device discovery migration stage.
*/
inline DeviceDiscoveryMigrationState getDeviceDiscoveryMigrationState() const
{ return _deviceDiscoveryMigrationState; }
/**
* Returns true while an explicit device discovery migration awaits a
* marker, device config, or legacy-topic cleanup step.
*/
inline bool isDeviceDiscoveryMigrationInProgress() const
{
return _deviceDiscoveryMigrationState != DeviceDiscoveryMigrationIdle &&
_deviceDiscoveryMigrationState != DeviceDiscoveryMigrationCompleted;
}
/**
* Republishes the current MQTT device discovery payload when device
* discovery is enabled.
*/
bool publishDeviceDiscovery();
/**
* Sets optional `support_url` in the discovery `origin` object (must remain valid).
*/
inline void setOriginSupportUrl(const char* url)
{ _originSupportUrl = url; }
inline const char* getOriginSupportUrl() const
{ return _originSupportUrl; }
/**
* Returns instance of the device assigned to the HAMqtt class.
* It's the same object (pointer) that was passed to the HAMqtt constructor.
*/
inline HADevice const* getDevice() const
{ return &_device; }
/**
* Registers a new callback method that will be called when the device receives an MQTT message.
* Please note that the callback is also fired by internal MQTT messages used by the library.
* You should always verify the topic of the received message.
*
* @param callback Callback method.
*/
inline void onMessage(HAMQTT_MESSAGE_CALLBACK(callback))
{ _messageCallback = callback; }
/**
* Registers a new callback method that will be called each time a connection to the MQTT broker is acquired.
* The callback is also fired after reconnecting to the broker.
* You can use this method to register topics' subscriptions.
*
* @param callback Callback method.
*/
inline void onConnected(HAMQTT_CALLBACK(callback))
{ _connectedCallback = callback; }
/**
* Registers a new callback method that will be called each time the connection to the MQTT broker is lost.
*
* @param callback Callback method.
*/
inline void onDisconnected(HAMQTT_CALLBACK(callback))
{ _disconnectedCallback = callback; }
/**
* Registers a new callback method that will be called each time the connection state changes.
*
* @param callback Callback method.
*/
inline void onStateChanged(HAMQTT_STATE_CALLBACK(callback))
{ _stateChangedCallback = callback; }
/**
* Returns the current state of the MQTT connection.
*/
inline ConnectionState getState() const
{ return _currentState; }
inline uint8_t getDeferredQueueCount() const
{ return _deferredCount; }
inline uint32_t getLastLoopOkAt() const
{ return _lastLoopOkAt; }
inline uint32_t getLastMessageAt() const
{ return _lastMessageAt; }
inline uint32_t getLastPublishAt() const
{ return _lastPublishAt; }
inline uint32_t getLastDisconnectAt() const
{ return _lastDisconnectAt; }
inline DiagnosticDisconnectReason getLastDisconnectReason() const
{ return _lastDisconnectReason; }
int getPubSubState() const;
/**
* Sets parameters of the MQTT connection using the IP address and port.
* The library will try to connect to the broker in first loop cycle.
* Please note that the library automatically reconnects to the broker if connection is lost.
*
* @param serverIp IP address of the MQTT broker.
* @param serverPort Port of the MQTT broker.
* @param username Username for authentication. It can be nullptr if the anonymous connection needs to be performed.
* @param password Password for authentication. It can be nullptr if the anonymous connection needs to be performed.
*/
bool begin(
const IPAddress serverIp,
const uint16_t serverPort = HAMQTT_DEFAULT_PORT,
const char* username = nullptr,
const char* password = nullptr
);
/**
* Sets parameters of the MQTT connection using the IP address and the default port (1883).
* The library will try to connect to the broker in first loop cycle.
* Please note that the library automatically reconnects to the broker if connection is lost.
*
* @param serverIp IP address of the MQTT broker.
* @param username Username for authentication. It can be nullptr if the anonymous connection needs to be performed.
* @param password Password for authentication. It can be nullptr if the anonymous connection needs to be performed.
*/
bool begin(
const IPAddress serverIp,
const char* username,
const char* password
);
/**
* Sets parameters of the MQTT connection using the hostname and port.
* The library will try to connect to the broker in first loop cycle.
* Please note that the library automatically reconnects to the broker if connection is lost.
*
* @param serverHostname Hostname of the MQTT broker.
* @param serverPort Port of the MQTT broker.
* @param username Username for authentication. It can be nullptr if the anonymous connection needs to be performed.
* @param password Password for authentication. It can be nullptr if the anonymous connection needs to be performed.
*/
bool begin(
const char* serverHostname,
const uint16_t serverPort = HAMQTT_DEFAULT_PORT,
const char* username = nullptr,
const char* password = nullptr
);
/**
* Sets parameters of the MQTT connection using the hostname and the default port (1883).
* The library will try to connect to the broker in first loop cycle.
* Please note that the library automatically reconnects to the broker if connection is lost.
*
* @param serverHostname Hostname of the MQTT broker.
* @param username Username for authentication. It can be nullptr if the anonymous connection needs to be performed.
* @param password Password for authentication. It can be nullptr if the anonymous connection needs to be performed.
*/
bool begin(
const char* serverHostname,
const char* username,
const char* password
);
/**
* Closes the MQTT connection.
*/
bool disconnect();
/**
* This method should be called periodically inside the main loop of the firmware.
* It's safe to call this method in some interval (like 5ms).
*/
void loop();
/**
* Returns true if connection to the MQTT broker is established.
*/
bool isConnected() const;
/**
* Sets keep alive for the MQTT connection.
* By default it's 15 seconds.
*
* @param keepAlive Number of seconds to keep connection alive.
*/
void setKeepAlive(uint16_t keepAlive);
/**
* Sets the buffer size for the MQTT connection.
* By default it's 256 bytes.
*
* @param size Size of the buffer.
*/
bool setBufferSize(uint16_t size);
/**
* Sets minimal interval between MQTT reconnect attempts.
* By default it's 10000 milliseconds.
*
* @param interval Interval in milliseconds. Zero is ignored.
*/
void setReconnectInterval(uint16_t interval);
/**
* Returns minimal interval between MQTT reconnect attempts.
*/
inline uint16_t getReconnectInterval() const
{ return _reconnectInterval; }
/**
* Adds a new device's type to the MQTT.
* Each time the connection with MQTT broker is acquired, the HAMqtt class
* calls "onMqttConnected" method in all devices' types instances.
*
* @note The HAMqtt class doesn't take ownership of the given pointer.
* @param deviceType Instance of the device's type (HASwitch, HABinarySensor, etc.).
*/
bool addDeviceType(HABaseDeviceType* deviceType);
/**
* Removes a destroyed entity from the connection registry.
* The MQTT instance does not own entity lifetimes.
*/
bool removeDeviceType(HABaseDeviceType* deviceType);
/**
* Number of entities currently registered for connection callbacks.
*/
inline uint8_t getRegisteredDeviceTypeCount() const
{ return _devicesTypesNb; }
/**
* Maximum number of entities that can be registered in this HAMqtt instance.
*/
inline uint8_t getDeviceTypeLimit() const
{ return _maxDevicesTypesNb; }
/**
* Number of registrations dropped because the configured entity limit was reached.
*/
inline uint16_t getDeviceTypeRegistrationFailures() const
{ return _deviceTypeRegistrationFailures; }
/**
* Publishes the MQTT message with given topic and payload.
* Message won't be published if the connection with the MQTT broker is not established.
* In this case method returns false.
*
* While handling an inbound MQTT message (see isProcessingMessage()), a successful
* return means the message was queued for send after dispatch completes, not that it
* was already transmitted. Outside that context, behavior is unchanged.
*
* @param topic The topic to publish.
* @param payload The payload to publish (it may be empty const char).
* @param retained Specifies whether message should be retained.
*/
bool publish(const char* topic, const char* payload, bool retained = false);
/**
* Begins publishing of a message with the given properties.
* When this method returns true the payload can be written using HAMqtt::writePayload method.
*
* @param topic Topic of the published message.
* @param payloadLength Length of the payload (bytes) that's going to be published.
* @param retained Specifies whether the published message should be retained.
*/
bool beginPublish(const char* topic, uint16_t payloadLength, bool retained = false);
/**
* Writes given string to the TCP stream.
* Please note that before writing any data the HAMqtt::beginPublish method
* needs to be called.
*
* @param data The string to publish.
* @param length Length of the data (bytes).
*/
bool writePayload(const char* data, const uint16_t length);
/**
* Writes given data to the TCP stream.
* Please note that before writing any data the HAMqtt::beginPublish method
* needs to be called.
*
* @param data The data to publish.
* @param length Length of the data (bytes).
*/
bool writePayload(const uint8_t* data, const uint16_t length);
/**
* Writes given progmem data to the TCP stream.
* Please note that before writing any data the HAMqtt::beginPublish method
* needs to be called.
*
* @param data Progmem data to publish.
*/
bool writePayload(const __FlashStringHelper* data);
/**
* Finishes publishing of a message.
* After calling this method the message will be processed by the broker.
*/
bool endPublish();
/**
* Subscribes to the given topic.
* Whenever a new message is received the onMqttMessage callback in all
* devices types is called.
*
* Please note that you need to subscribe topic each time the connection
* with the broker is acquired.
*
* @param topic Topic to subscribe.
*/
bool subscribe(const char* topic);
/**
* Enables the last will message that will be produced when the device disconnects from the broker.
* If you want to change availability of the device in Home Assistant panel
* please use enableLastWill() method from the HADevice class instead.
*
* @param lastWillTopic The topic to publish.
* @param lastWillMessage The message (payload) to publish.
* @param lastWillRetain Specifies whether the published message should be retained.
*/
inline void setLastWill(
const char* lastWillTopic,
const char* lastWillMessage,
bool lastWillRetain
) {
_lastWillTopic = lastWillTopic;
_lastWillMessage = lastWillMessage;
_lastWillRetain = lastWillRetain;
}
/**
* Processes MQTT message received from the broker (subscription).
*
* @note Do not use this method on your own. It's only for the internal purpose.
* @param topic Topic of the message.
* @param payload Content of the message.
* @param length Length of the message.
*/
void processMessage(const char* topic, const uint8_t* payload, uint16_t length);
/**
* True while handling an inbound MQTT message (user onMessage and device onMqttMessage).
* Publish attempts in this window are queued and flushed when dispatch completes.
*/
inline bool isProcessingMessage() const
{ return _messageDispatchDepth > 0; }
#ifdef ARDUINOHA_TEST
inline uint8_t getDevicesTypesNb() const
{ return _devicesTypesNb; }
inline HABaseDeviceType** getDevicesTypes() const
{ return _devicesTypes; }
inline uint16_t getDeferredPublishEnqueueCountForTest() const
{ return _deferredPublishEnqueueCountForTest; }
inline void resetDeferredPublishTestCounters()
{
_deferredPublishEnqueueCountForTest = 0;
_deferredFlushFailedForTest = false;
_lastDeferredFlushErrorForTest = 0;
}
inline uint8_t getPendingDeferredPublishesForTest() const
{ return _deferredCount; }
inline bool hasDeferredFlushFailureForTest() const
{ return _deferredFlushFailedForTest; }
inline uint8_t getLastDeferredFlushErrorForTest() const
{ return _lastDeferredFlushErrorForTest; }
inline bool didDeferredFlushFailDueToDisconnectForTest() const
{ return _lastDeferredFlushErrorForTest == 1; }
inline bool didDeferredFlushFailAtBeginPublishForTest() const
{ return _lastDeferredFlushErrorForTest == 2; }
inline bool didDeferredFlushFailAtEndPublishForTest() const
{ return _lastDeferredFlushErrorForTest == 3; }
#endif
private:
/// Default interval between MQTT reconnect attempts (milliseconds).
static const uint16_t DefaultReconnectInterval = 10000;
/// Living instance of the HAMqtt class. It can be nullptr.
static HAMqtt* _instance;
/**
* Attempts to connect to the MQTT broker.
* The method uses properties passed to the "begin" method.
*/
void connectToServer();
/**
* This method is called each time the connection with MQTT broker is acquired.
*/
void onConnectedLogic();
bool publishDeviceDiscoveryPayload(HABaseDeviceType* removalType = nullptr);
HASerializer* buildDeviceDiscoveryComponentSerializer(
HABaseDeviceType* deviceType,
HABaseDeviceType* removalType
);
bool clearDeviceDiscoveryConfig();
bool publishDeviceDiscoveryMigrationMarker(HABaseDeviceType* deviceType);
/**
* Marks the retained device discovery config for Home Assistant's reverse
* migration protocol before restoring legacy component configs.
*/
bool publishDeviceDiscoveryMigrationMarker();
/**
* Removes a component from a device discovery payload using Home
* Assistant's required platform-only marker, followed by a compacted
* bundle when another component remains.
*/
bool removeDeviceDiscoveryComponent(HABaseDeviceType* deviceType);
/**
* Re-adds (or refreshes) a component in the current device discovery
* payload.
*/
bool republishDeviceDiscoveryComponent(HABaseDeviceType* deviceType);
/**
* Sets the state of the MQTT connection.
*/
void setState(ConnectionState state);
struct DeferredPublishMessage {
char* topic;
uint8_t* payload;
uint16_t length;
bool retained;
};
struct DeferredPublishBuilder {
bool active;
bool valid;
char* topic;
uint8_t* payload;
uint16_t expectedLength;
uint16_t writtenLength;
bool retained;
};
enum DeferredFlushError {
DeferredFlushErrorNone = 0,
DeferredFlushErrorNotConnected,
DeferredFlushErrorBeginPublish,
DeferredFlushErrorEndPublish
};
static const uint8_t DeferredQueueCapacity = 8;
bool enqueueDeferredPublish(
const char* topic,
const uint8_t* payload,
uint16_t length,
bool retained
);
void clearDeferredMessage(DeferredPublishMessage& msg);
void clearDeferredQueue();
void clearDeferredBuilder();
/**
* Sends queued publishes in order. On transport failure, stops and leaves the
* remaining queue intact for a later retry (e.g. from loop()).
*/
bool flushDeferredPublishes();
/**
* PubSubClient snapshot when a direct publish step fails (header or endPublish).
*/
String formatDirectPublishFailureDiagnostics(bool hamqttConnectedBefore, int pubsubStateBefore) const;
// Buffer direct MQTT streaming writes so serializers do not issue hundreds
// of tiny TCP writes while a PubSubClient publish is open.
static const uint16_t DirectPublishBufferSize = 128;
bool flushDirectPublishBuffer();
bool appendDirectPublishPayload(const uint8_t* data, uint16_t length);
bool appendDirectPublishProgmemPayload(const __FlashStringHelper* src);
void clearDirectPublishBuffer();
void abortDirectPublish();
#ifdef ARDUINOHA_TEST
PubSubClientMock* _mqtt;
#else
/// Storage for the PubSubClient instance used in production builds.
PubSubClient _mqttStorage;
/// Pointer to the active MQTT client implementation.
PubSubClient* _mqtt;
#endif
uint8_t _directPublishBuffer[DirectPublishBufferSize];
uint16_t _directPublishBufferLength;
bool _directPublishActive;
/// Instance of the HADevice passed to the constructor.
const HADevice& _device;
/// The callback method that will be called when an MQTT message is received.
HAMQTT_MESSAGE_CALLBACK(_messageCallback);
/// The callback method that will be called when the MQTT connection is acquired.
HAMQTT_CALLBACK(_connectedCallback);
/// The callback method that will be called when the MQTT connection is lost.
HAMQTT_CALLBACK(_disconnectedCallback);
/// The callback method that will be called when the MQTT connection state changes.
HAMQTT_STATE_CALLBACK(_stateChangedCallback);
/// Specifies whether the HAMqtt::begin method was ever called.
bool _initialized;
/// Teh discovery prefix that's used for the configuration messages.
const char* _discoveryPrefix;
/// The data prefix that's used for publishing data messages.
const char* _dataPrefix;
/// Enables MQTT device discovery mode when set to true.
bool _deviceDiscoveryEnabled;
/// Current in-memory stage of an explicit device discovery migration.
DeviceDiscoveryMigrationState _deviceDiscoveryMigrationState;
const char* _originSupportUrl;
/// The username used for the authentication. It's set in the HAMqtt::begin method.
const char* _username;
/// The password used for the authentication. It's set in the HAMqtt::begin method.
const char* _password;
/// Time of the last connection attemps (milliseconds since boot).
uint32_t _lastConnectionAttemptAt;
/// Interval between MQTT reconnect attempts (milliseconds).
uint16_t _reconnectInterval;
/// The amount of registered devices types.
uint8_t _devicesTypesNb;
/// The maximum amount of devices types that can be registered.
uint8_t _maxDevicesTypesNb;
/// Pointers of all registered devices types (array of pointers).
HABaseDeviceType** _devicesTypes;
/// The last will topic set by HAMqtt::setLastWill
const char* _lastWillTopic;
/// Count of entity registrations rejected because the configured cap was full.
uint16_t _deviceTypeRegistrationFailures;
/// The last will message set by HAMqtt::setLastWill
const char* _lastWillMessage;
/// The last will retain set by HAMqtt::setLastWill
bool _lastWillRetain;
/// The last known state of the MQTT connection.
ConnectionState _currentState;
uint32_t _lastLoopOkAt;
uint32_t _lastMessageAt;
uint32_t _lastPublishAt;
uint32_t _lastDisconnectAt;
DiagnosticDisconnectReason _lastDisconnectReason;
/// Nesting depth for inbound message dispatch (processMessage).
uint8_t _messageDispatchDepth;
DeferredPublishMessage _deferredQueue[DeferredQueueCapacity];
uint8_t _deferredHead;
uint8_t _deferredCount;
DeferredPublishBuilder _deferredBuilder;
#ifdef ARDUINOHA_TEST
uint16_t _deferredPublishEnqueueCountForTest = 0;
bool _deferredFlushFailedForTest = false;
uint8_t _lastDeferredFlushErrorForTest = 0;
#endif
friend class HABaseDeviceType;
};
#endif