HA entity, sensor and device classes (iteration 2, step 3)

HaEntity describes an entity for device-based discovery, throttles its
state (SEN-14) and reports availability (SEN-15); HaSensor is the
`sensor` type. HaDevice publishes discovery, availability and states
after each connect and after HA's birth message, provides the last
will and removes itself from HA. Unit tests with a fake MQTT client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:12:43 +02:00
co-authored by Claude Opus 5.5
parent 96df60e6bd
commit 466743eff9
13 changed files with 862 additions and 4 deletions
+3
View File
@@ -54,6 +54,9 @@ qt_add_library(frameassistant_lib STATIC
config/Config.h config/Config.cpp
mqtt/MqttClient.h
mqtt/MosquittoClient.h mqtt/MosquittoClient.cpp
ha/HaEntity.h ha/HaEntity.cpp
ha/HaSensor.h ha/HaSensor.cpp
ha/HaDevice.h ha/HaDevice.cpp
)
target_include_directories(frameassistant_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(frameassistant_lib PUBLIC Qt6::Core PkgConfig::MOSQUITTO)
+141
View File
@@ -0,0 +1,141 @@
#include "ha/HaDevice.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <utility>
#include "ha/HaSensor.h"
namespace {
const QString kTopicRoot = QStringLiteral("frameassistant");
const QByteArray kOnline = "online";
const QByteArray kOffline = "offline";
QString baseTopicFor(const QString& deviceId) {
return kTopicRoot + QLatin1Char('/') + deviceId;
}
} // namespace
HaDevice::HaDevice(Info info, MqttClient* client, QObject* parent)
: QObject(parent), m_info(std::move(info)), m_client(client) {
m_birthTimer.setSingleShot(true);
connect(&m_birthTimer, &QTimer::timeout, this, &HaDevice::publishAll);
connect(m_client, &MqttClient::connectionChanged, this,
&HaDevice::onConnectionChanged);
connect(m_client, &MqttClient::messageReceived, this, &HaDevice::onMessage);
}
MqttClient::Will HaDevice::lastWill(const QString& deviceId) {
return {baseTopicFor(deviceId) + QStringLiteral("/availability"), kOffline,
true};
}
HaSensor* HaDevice::addSensor(HaEntity::Description description) {
auto* sensor = new HaSensor(std::move(description), this);
addEntity(sensor);
return sensor;
}
QString HaDevice::baseTopic() const { return baseTopicFor(m_info.id); }
QString HaDevice::discoveryTopic() const {
return m_info.discoveryPrefix + QStringLiteral("/device/") + m_info.id +
QStringLiteral("/config");
}
QString HaDevice::availabilityTopic() const {
return lastWill(m_info.id).topic;
}
QByteArray HaDevice::discoveryPayload() const {
const QString uniquePrefix = kTopicRoot + QLatin1Char('_') + m_info.id;
QJsonObject components;
for (const HaEntity* entity : m_entities)
components.insert(entity->description().id,
entity->discoveryComponent(uniquePrefix));
const QJsonObject payload{
{QStringLiteral("dev"),
QJsonObject{{QStringLiteral("ids"), QJsonArray{uniquePrefix}},
{QStringLiteral("name"), m_info.name},
{QStringLiteral("sw"), m_info.softwareVersion}}},
{QStringLiteral("o"),
QJsonObject{{QStringLiteral("name"), QStringLiteral("FrameAssistant")},
{QStringLiteral("sw"), m_info.softwareVersion}}},
{QStringLiteral("~"), baseTopic()},
{QStringLiteral("cmps"), components},
{QStringLiteral("qos"), 1}};
return QJsonDocument(payload).toJson(QJsonDocument::Compact);
}
bool HaDevice::removeFromHa() {
// Discovery first: HA deletes the device before its topics go empty.
bool ok = m_client->publish(discoveryTopic(), {}, true);
ok = m_client->publish(availabilityTopic(), {}, true) && ok;
for (const HaEntity* entity : m_entities) {
ok = m_client->publish(topic(entity->stateTopic()), {}, true) && ok;
ok = m_client->publish(topic(entity->availabilityTopic()), {}, true) && ok;
}
return ok;
}
void HaDevice::setBirthDelay(int minMs, int maxMs) {
m_birthDelayMinMs = minMs;
m_birthDelayMaxMs = maxMs;
}
void HaDevice::addEntity(HaEntity* entity) {
m_entities.append(entity);
// While disconnected these publishes fail; publishAll() catches up after
// the next connect.
connect(entity, &HaEntity::stateChanged, this,
[this, entity](const QByteArray& state) {
m_client->publish(topic(entity->stateTopic()), state, true);
});
connect(entity, &HaEntity::availabilityChanged, this,
[this, entity] { publishAvailability(entity); });
}
void HaDevice::publishAll() {
m_client->publish(discoveryTopic(), discoveryPayload(), true);
m_client->publish(availabilityTopic(), kOnline, true);
for (const HaEntity* entity : m_entities) {
// The state before the availability, so HA never shows "unknown".
if (entity->hasValue())
m_client->publish(topic(entity->stateTopic()), entity->state(), true);
publishAvailability(entity);
}
}
void HaDevice::publishAvailability(const HaEntity* entity) {
m_client->publish(topic(entity->availabilityTopic()),
entity->hasValue() ? kOnline : kOffline, true);
}
QString HaDevice::topic(const QString& relative) const {
return baseTopic() + QLatin1Char('/') + relative;
}
QString HaDevice::statusTopic() const {
return m_info.discoveryPrefix + QStringLiteral("/status");
}
void HaDevice::onConnectionChanged(MqttClient::State state) {
if (state != MqttClient::State::Connected) {
m_birthTimer.stop();
return;
}
m_client->subscribe(statusTopic());
publishAll();
}
void HaDevice::onMessage(const QString& topic, const QByteArray& payload) {
// HA (re)started: it may have lost what it knew. Spread the load of all
// devices answering at once (HA's documentation recommends a delay).
if (topic == statusTopic() && payload == kOnline)
m_birthTimer.start(int(QRandomGenerator::global()->bounded(
m_birthDelayMinMs, m_birthDelayMaxMs + 1)));
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <QList>
#include <QObject>
#include <QString>
#include <QTimer>
#include "ha/HaEntity.h"
#include "mqtt/MqttClient.h"
class HaSensor;
// One HA device per headset (docs/DESIGN.md, D18, D31, D32): device-based
// discovery, availability, and the states of its entities over MQTT. Owns
// its entities.
//
// After each connect it subscribes to HA's status topic and publishes
// discovery, availability and all states; it does the same again a little
// later when HA announces that it (re)started.
class HaDevice : public QObject {
Q_OBJECT
public:
struct Info {
QString name; // shown in HA
QString id; // device id (D31)
QString discoveryPrefix = QStringLiteral("homeassistant");
QString softwareVersion;
};
// `client` must outlive the device.
HaDevice(Info info, MqttClient* client, QObject* parent = nullptr);
// The device's last will: its availability topic, "offline", retained.
// For the MQTT client's settings, which exist before the device.
static MqttClient::Will lastWill(const QString& deviceId);
HaSensor* addSensor(HaEntity::Description description);
const QList<HaEntity*>& entities() const { return m_entities; }
QString baseTopic() const; // frameassistant/<device id>
QString discoveryTopic() const;
QString availabilityTopic() const;
QByteArray discoveryPayload() const;
// Clears everything the device left on the broker (empty retained
// payloads): HA then deletes the device and its entities. Returns false if
// something couldn't be published (not connected).
bool removeFromHa();
// Republish after HA's birth message: random delay in [minMs, maxMs].
void setBirthDelay(int minMs, int maxMs);
private:
void addEntity(HaEntity* entity);
void publishAll();
void publishAvailability(const HaEntity* entity);
QString topic(const QString& relative) const;
QString statusTopic() const;
void onConnectionChanged(MqttClient::State state);
void onMessage(const QString& topic, const QByteArray& payload);
const Info m_info;
MqttClient* const m_client;
QList<HaEntity*> m_entities;
QTimer m_birthTimer;
int m_birthDelayMinMs = 1000;
int m_birthDelayMaxMs = 5000;
};
+82
View File
@@ -0,0 +1,82 @@
#include "ha/HaEntity.h"
#include <QJsonArray>
#include <utility>
HaEntity::HaEntity(QString platform, Description description, QObject* parent)
: QObject(parent),
m_platform(std::move(platform)),
m_description(std::move(description)) {
m_throttle.setSingleShot(true);
connect(&m_throttle, &QTimer::timeout, this, &HaEntity::emitState);
}
QJsonObject HaEntity::discoveryComponent(const QString& uniqueIdPrefix) const {
const Description& d = m_description;
// Abbreviated keys, as HA's MQTT discovery documents them.
QJsonObject component{
{QStringLiteral("p"), m_platform},
{QStringLiteral("name"), d.name},
{QStringLiteral("uniq_id"), uniqueIdPrefix + QLatin1Char('_') + d.id},
{QStringLiteral("stat_t"), QStringLiteral("~/") + stateTopic()},
// Available only while the device is online and the entity has a
// value (D32).
{QStringLiteral("avty"),
QJsonArray{
QJsonObject{{QStringLiteral("t"), QStringLiteral("~/availability")}},
QJsonObject{{QStringLiteral("t"),
QStringLiteral("~/") + availabilityTopic()}}}},
{QStringLiteral("avty_mode"), QStringLiteral("all")}};
if (!d.deviceClass.isEmpty())
component.insert(QStringLiteral("dev_cla"), d.deviceClass);
if (!d.unit.isEmpty())
component.insert(QStringLiteral("unit_of_meas"), d.unit);
if (!d.category.isEmpty())
component.insert(QStringLiteral("ent_cat"), d.category);
return component;
}
QString HaEntity::stateTopic() const {
return m_description.id + QStringLiteral("/state");
}
QString HaEntity::availabilityTopic() const {
return m_description.id + QStringLiteral("/availability");
}
void HaEntity::updateState(const QByteArray& state) {
const bool first = !m_hasValue;
m_state = state;
m_hasValue = true;
if (first) {
// A first value goes out at once, then the entity becomes available.
m_throttle.stop();
emitState();
emit availabilityChanged(true);
return;
}
if (m_throttle.isActive()) return; // emitState() picks up the newest one
const qint64 left = m_sinceEmit.isValid()
? m_description.throttleMs - m_sinceEmit.elapsed()
: 0;
if (left <= 0)
emitState();
else
m_throttle.start(int(left));
}
void HaEntity::clearState() {
if (!m_hasValue) return;
m_hasValue = false;
m_state.clear();
m_emittedState.clear();
m_throttle.stop();
emit availabilityChanged(false);
}
void HaEntity::emitState() {
if (!m_hasValue || m_state == m_emittedState) return;
m_emittedState = m_state;
m_sinceEmit.start();
emit stateChanged(m_state);
}
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <QByteArray>
#include <QElapsedTimer>
#include <QJsonObject>
#include <QObject>
#include <QString>
#include <QTimer>
// One HA entity (docs/DESIGN.md, D23): the base class of every entity type.
// It describes the entity for discovery, holds its current state, throttles
// state changes (SEN-14) and knows whether it has a value (SEN-15: without
// one, HA shows it as unavailable).
//
// It knows nothing about MQTT: HaDevice publishes what it reports.
class HaEntity : public QObject {
Q_OBJECT
public:
struct Description {
// Topic level and suffix of the unique id, e.g. "uptime".
QString id;
// Name shown in HA, e.g. "Uptime".
QString name;
// Optional: HA device class, unit, entity category ("diagnostic", …).
QString deviceClass;
QString unit;
QString category;
// Minimum time between two published states (SEN-14).
int throttleMs = 1000;
};
// `platform`: the HA entity type, e.g. "sensor".
HaEntity(QString platform, Description description,
QObject* parent = nullptr);
const QString& platform() const { return m_platform; }
const Description& description() const { return m_description; }
// The component for device-based discovery; topics are relative to the
// device's base topic "~". `uniqueIdPrefix`: e.g.
// "frameassistant_<device id>".
virtual QJsonObject discoveryComponent(const QString& uniqueIdPrefix) const;
// Topics relative to the device's base topic.
QString stateTopic() const;
QString availabilityTopic() const;
bool hasValue() const { return m_hasValue; }
// The state as published; empty without a value.
QByteArray state() const { return m_hasValue ? m_state : QByteArray(); }
signals:
// A new state to publish, at most once per throttle interval (the newest
// one wins). Emitted before availabilityChanged(true) for a first value.
void stateChanged(const QByteArray& state);
void availabilityChanged(bool available);
protected:
// For the concrete types: set or clear the state.
void updateState(const QByteArray& state);
void clearState();
private:
void emitState();
const QString m_platform;
const Description m_description;
QByteArray m_state;
bool m_hasValue = false;
// The state last emitted, and when.
QByteArray m_emittedState;
QElapsedTimer m_sinceEmit;
QTimer m_throttle;
};
+12
View File
@@ -0,0 +1,12 @@
#include "ha/HaSensor.h"
#include <utility>
HaSensor::HaSensor(Description description, QObject* parent)
: HaEntity(QStringLiteral("sensor"), std::move(description), parent) {}
void HaSensor::setValue(const QVariant& value) {
updateState(value.toString().toUtf8());
}
void HaSensor::clearValue() { clearState(); }
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QVariant>
#include "ha/HaEntity.h"
// An HA `sensor`: a value with optional unit and device class.
class HaSensor : public HaEntity {
Q_OBJECT
public:
explicit HaSensor(Description description, QObject* parent = nullptr);
// Published as text (QVariant::toString()).
void setValue(const QVariant& value);
// No value: HA shows the sensor as unavailable (SEN-15).
void clearValue();
};