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:
+2
-2
@@ -399,8 +399,8 @@ delete).
|
||||
|
||||
| Test | Content |
|
||||
|---|---|
|
||||
| `tst_haentity` | discovery component, topics, throttling (several values within an interval → one publish with the last value), unavailable without a value |
|
||||
| `tst_hadevice` (fake client) | discovery payload, `online` and states after connect, republish after reconnect and birth, will configuration, `removeFromHa()` |
|
||||
| `tst_haentity` | discovery component (optional keys only when set), topics, throttling (several values within an interval → one state with the newest value; unchanged or reverted values not emitted), first value → state then available, cleared → unavailable |
|
||||
| `tst_hadevice` (`tests/FakeMqttClient.h`) | topics, discovery prefix, last will, discovery payload, what is published after a connect and in which order, entity without value unavailable, changes while connected, nothing buffered and current state after reconnect, birth message (delayed; cancelled by a disconnect; other messages ignored), `removeFromHa()` |
|
||||
| `tst_mqtt` (integration; real `mosquitto` on a free port, QSKIP without it) | connect and messages; retained message for a later subscriber; nothing published while disconnected; first connect retried until the broker runs; reconnect after a broker restart; unresolvable host; last will after `kill -9` of a helper process (the test binary itself, `--will-helper`); `disconnectFromBroker()` publishes the will; login right and wrong; TLS with a test CA (step 5) |
|
||||
| `tst_surfaces` | `--desktop-novr`; app command: hook called, no surface created, exit code returned |
|
||||
| Manual | from the PC against the user's HA: device appears, online/offline, broker restart, two devices, `--ha-remove`, UI-1 in `--desktop`. On the Frame: the package with libmosquitto runs |
|
||||
|
||||
+7
-2
@@ -23,8 +23,9 @@ CLAUDE.md ("Working rules").
|
||||
The error itself is covered off the device by `tst_config`. Checks 3 and 4 run in the next
|
||||
session on the Frame (iteration 2 testing needs it too).
|
||||
- **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in
|
||||
CMake and the containers) and 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) done; next:
|
||||
step 3 (`HaEntity`, `HaSensor`, `HaDevice`, unit tests).
|
||||
CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) and 3
|
||||
(`HaEntity`, `HaSensor`, `HaDevice`, unit tests) done; next: step 4 (app wiring,
|
||||
`--desktop-novr`, UI-1).
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -144,6 +145,10 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter
|
||||
host 3 × 12/12; `container-test.sh` 9/9 (Debian's libmosquitto 2.0.21), no Vulkan
|
||||
validation messages; cross build: private Qt symbols only `QVulkan*`, at most
|
||||
`GLIBC_2.34`; the app binary links libmosquitto only from step 4 on (first use).
|
||||
- Step 3: `HaEntity` (description, discovery component, throttling, availability),
|
||||
`HaSensor`, `HaDevice` (discovery payload, publish all after connect and after HA's birth
|
||||
message with random delay, last will, `removeFromHa()`), `FakeMqttClient` for tests.
|
||||
`tst_haentity` 11/11, `tst_hadevice` 14/14 on the host.
|
||||
|
||||
### 2026-10-05: requirements approved
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(); }
|
||||
@@ -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();
|
||||
};
|
||||
@@ -41,6 +41,14 @@ qt_add_executable(tst_config tst_config.cpp)
|
||||
target_link_libraries(tst_config PRIVATE frameassistant_lib Qt6::Test)
|
||||
add_test(NAME config COMMAND tst_config)
|
||||
|
||||
# HA entities and the HA device (D23, D31, D32); the device with a fake MQTT client.
|
||||
qt_add_executable(tst_haentity tst_haentity.cpp)
|
||||
target_link_libraries(tst_haentity PRIVATE frameassistant_lib Qt6::Test)
|
||||
add_test(NAME haentity COMMAND tst_haentity)
|
||||
qt_add_executable(tst_hadevice tst_hadevice.cpp FakeMqttClient.h)
|
||||
target_link_libraries(tst_hadevice PRIVATE frameassistant_lib Qt6::Test)
|
||||
add_test(NAME hadevice COMMAND tst_hadevice)
|
||||
|
||||
# MosquittoClient against a local mosquitto broker (D30 level 2); skips without one.
|
||||
qt_add_executable(tst_mqtt tst_mqtt.cpp)
|
||||
target_link_libraries(tst_mqtt PRIVATE frameassistant_lib Qt6::Test)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QStringList>
|
||||
|
||||
#include "mqtt/MqttClient.h"
|
||||
|
||||
// MqttClient for unit tests: records what is published and subscribed;
|
||||
// the test drives the connection state and incoming messages.
|
||||
class FakeMqttClient : public MqttClient {
|
||||
Q_OBJECT
|
||||
public:
|
||||
struct Message {
|
||||
QString topic;
|
||||
QByteArray payload;
|
||||
bool retain = false;
|
||||
};
|
||||
|
||||
using MqttClient::MqttClient;
|
||||
|
||||
void connectToBroker() override { ++connectCalls; }
|
||||
bool disconnectFromBroker(int) override {
|
||||
setState(State::Disconnected);
|
||||
return true;
|
||||
}
|
||||
bool publish(const QString& topic, const QByteArray& payload,
|
||||
bool retain) override {
|
||||
if (m_state != State::Connected) return false;
|
||||
published.append({topic, payload, retain});
|
||||
return true;
|
||||
}
|
||||
bool subscribe(const QString& topic) override {
|
||||
if (m_state != State::Connected) return false;
|
||||
subscriptions.append(topic);
|
||||
return true;
|
||||
}
|
||||
bool waitForPublished(int) override { return true; }
|
||||
State state() const override { return m_state; }
|
||||
|
||||
// Test side.
|
||||
void setState(State state, const QString& error = {}) {
|
||||
m_state = state;
|
||||
emit connectionChanged(state, error);
|
||||
}
|
||||
void deliver(const QString& topic, const QByteArray& payload) {
|
||||
emit messageReceived(topic, payload);
|
||||
}
|
||||
// The messages published to `topic`, oldest first.
|
||||
QList<Message> to(const QString& topic) const {
|
||||
QList<Message> result;
|
||||
for (const Message& message : published)
|
||||
if (message.topic == topic) result.append(message);
|
||||
return result;
|
||||
}
|
||||
|
||||
QList<Message> published;
|
||||
QStringList subscriptions;
|
||||
int connectCalls = 0;
|
||||
|
||||
private:
|
||||
State m_state = State::Disconnected;
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
// HaDevice with a fake MQTT client (docs/DESIGN.md, D31, D32): discovery
|
||||
// payload, what is published after a connect, reconnect, HA's birth message,
|
||||
// last will, removal.
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTest>
|
||||
|
||||
#include "FakeMqttClient.h"
|
||||
#include "ha/HaDevice.h"
|
||||
#include "ha/HaSensor.h"
|
||||
|
||||
namespace {
|
||||
|
||||
using State = MqttClient::State;
|
||||
|
||||
HaDevice::Info info() {
|
||||
return {.name = QStringLiteral("Wohnzimmer Frame"),
|
||||
.id = QStringLiteral("wohnzimmer_frame"),
|
||||
.softwareVersion = QStringLiteral("1.2.3")};
|
||||
}
|
||||
|
||||
HaEntity::Description uptime() {
|
||||
return {.id = QStringLiteral("uptime"),
|
||||
.name = QStringLiteral("Uptime"),
|
||||
.unit = QStringLiteral("s"),
|
||||
.throttleMs = 0};
|
||||
}
|
||||
|
||||
const QString kBase = QStringLiteral("frameassistant/wohnzimmer_frame");
|
||||
const QString kDiscovery =
|
||||
QStringLiteral("homeassistant/device/wohnzimmer_frame/config");
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestHaDevice : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void topics() {
|
||||
FakeMqttClient client;
|
||||
const HaDevice device(info(), &client);
|
||||
QCOMPARE(device.baseTopic(), kBase);
|
||||
QCOMPARE(device.discoveryTopic(), kDiscovery);
|
||||
QCOMPARE(device.availabilityTopic(), kBase + "/availability");
|
||||
}
|
||||
|
||||
void discoveryPrefixFromInfo() {
|
||||
FakeMqttClient client;
|
||||
HaDevice::Info i = info();
|
||||
i.discoveryPrefix = QStringLiteral("ha");
|
||||
HaDevice device(i, &client);
|
||||
QCOMPARE(device.discoveryTopic(),
|
||||
QStringLiteral("ha/device/wohnzimmer_frame/config"));
|
||||
client.setState(State::Connected);
|
||||
QCOMPARE(client.subscriptions, QStringList{QStringLiteral("ha/status")});
|
||||
}
|
||||
|
||||
void lastWill() {
|
||||
const MqttClient::Will will =
|
||||
HaDevice::lastWill(QStringLiteral("wohnzimmer_frame"));
|
||||
QCOMPARE(will.topic, kBase + "/availability");
|
||||
QCOMPARE(will.payload, QByteArray("offline"));
|
||||
QVERIFY(will.retain);
|
||||
}
|
||||
|
||||
void discoveryPayload() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.addSensor(uptime());
|
||||
const QJsonObject payload =
|
||||
QJsonDocument::fromJson(device.discoveryPayload()).object();
|
||||
const QJsonObject dev = payload.value(QStringLiteral("dev")).toObject();
|
||||
QCOMPARE(dev.value(QStringLiteral("ids")).toArray(),
|
||||
QJsonArray{QStringLiteral("frameassistant_wohnzimmer_frame")});
|
||||
QCOMPARE(dev.value(QStringLiteral("name")).toString(),
|
||||
QStringLiteral("Wohnzimmer Frame"));
|
||||
QCOMPARE(dev.value(QStringLiteral("sw")).toString(),
|
||||
QStringLiteral("1.2.3"));
|
||||
const QJsonObject origin = payload.value(QStringLiteral("o")).toObject();
|
||||
QCOMPARE(origin.value(QStringLiteral("name")).toString(),
|
||||
QStringLiteral("FrameAssistant"));
|
||||
QCOMPARE(payload.value(QStringLiteral("~")).toString(), kBase);
|
||||
QCOMPARE(payload.value(QStringLiteral("qos")).toInt(), 1);
|
||||
const QJsonObject components =
|
||||
payload.value(QStringLiteral("cmps")).toObject();
|
||||
QCOMPARE(components.keys(), QStringList{QStringLiteral("uptime")});
|
||||
QCOMPARE(components.value(QStringLiteral("uptime"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("uniq_id"))
|
||||
.toString(),
|
||||
QStringLiteral("frameassistant_wohnzimmer_frame_uptime"));
|
||||
}
|
||||
|
||||
// After a connect: subscription, then discovery, device availability,
|
||||
// state and entity availability, all retained.
|
||||
void publishesAllAfterConnect() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.addSensor(uptime())->setValue(7);
|
||||
QVERIFY(client.published.isEmpty()); // not connected yet
|
||||
client.setState(State::Connected);
|
||||
QCOMPARE(client.subscriptions,
|
||||
QStringList{QStringLiteral("homeassistant/status")});
|
||||
QStringList topics;
|
||||
for (const FakeMqttClient::Message& m : client.published) {
|
||||
topics.append(m.topic);
|
||||
QVERIFY2(m.retain, qPrintable(m.topic));
|
||||
}
|
||||
QCOMPARE(topics, (QStringList{kDiscovery, kBase + "/availability",
|
||||
kBase + "/uptime/state",
|
||||
kBase + "/uptime/availability"}));
|
||||
QCOMPARE(client.published[0].payload, device.discoveryPayload());
|
||||
QCOMPARE(client.published[1].payload, QByteArray("online"));
|
||||
QCOMPARE(client.published[2].payload, QByteArray("7"));
|
||||
QCOMPARE(client.published[3].payload, QByteArray("online"));
|
||||
}
|
||||
|
||||
// SEN-15: an entity without a value is published as unavailable.
|
||||
void entityWithoutValueUnavailable() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.addSensor(uptime());
|
||||
client.setState(State::Connected);
|
||||
QVERIFY(client.to(kBase + "/uptime/state").isEmpty());
|
||||
const QList<FakeMqttClient::Message> availability =
|
||||
client.to(kBase + "/uptime/availability");
|
||||
QCOMPARE(availability.size(), 1);
|
||||
QCOMPARE(availability.first().payload, QByteArray("offline"));
|
||||
}
|
||||
|
||||
void valueChangesPublishedWhileConnected() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
HaSensor* sensor = device.addSensor(uptime());
|
||||
client.setState(State::Connected);
|
||||
client.published.clear();
|
||||
sensor->setValue(1);
|
||||
QCOMPARE(client.published.size(), 2);
|
||||
QCOMPARE(client.published[0].topic, kBase + "/uptime/state");
|
||||
QCOMPARE(client.published[0].payload, QByteArray("1"));
|
||||
QVERIFY(client.published[0].retain);
|
||||
QCOMPARE(client.published[1].topic, kBase + "/uptime/availability");
|
||||
QCOMPARE(client.published[1].payload, QByteArray("online"));
|
||||
sensor->setValue(2);
|
||||
QCOMPARE(client.published.size(), 3);
|
||||
QCOMPARE(client.published[2].payload, QByteArray("2"));
|
||||
sensor->clearValue();
|
||||
QCOMPARE(client.published.size(), 4);
|
||||
QCOMPARE(client.published[3].topic, kBase + "/uptime/availability");
|
||||
QCOMPARE(client.published[3].payload, QByteArray("offline"));
|
||||
}
|
||||
|
||||
// CON-4: nothing is buffered; after a reconnect everything is current.
|
||||
void reconnectRepublishesCurrentState() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
HaSensor* sensor = device.addSensor(uptime());
|
||||
sensor->setValue(1);
|
||||
client.setState(State::Connected);
|
||||
client.setState(State::Disconnected, QStringLiteral("lost"));
|
||||
client.published.clear();
|
||||
client.subscriptions.clear();
|
||||
sensor->setValue(2);
|
||||
sensor->setValue(3);
|
||||
QVERIFY(client.published.isEmpty());
|
||||
client.setState(State::Connected);
|
||||
QCOMPARE(client.subscriptions.size(), 1);
|
||||
const QList<FakeMqttClient::Message> states =
|
||||
client.to(kBase + "/uptime/state");
|
||||
QCOMPARE(states.size(), 1);
|
||||
QCOMPARE(states.first().payload, QByteArray("3"));
|
||||
QCOMPARE(client.to(kDiscovery).size(), 1);
|
||||
}
|
||||
|
||||
void birthMessageRepublishesAfterDelay() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.setBirthDelay(50, 100);
|
||||
device.addSensor(uptime())->setValue(1);
|
||||
client.setState(State::Connected);
|
||||
client.published.clear();
|
||||
client.deliver(QStringLiteral("homeassistant/status"), "online");
|
||||
QVERIFY(client.published.isEmpty()); // not at once
|
||||
QTRY_COMPARE_WITH_TIMEOUT(client.to(kDiscovery).size(), 1, 2000);
|
||||
QCOMPARE(client.to(kBase + "/uptime/state").size(), 1);
|
||||
}
|
||||
|
||||
void otherMessagesIgnored() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.setBirthDelay(0, 0);
|
||||
client.setState(State::Connected);
|
||||
client.published.clear();
|
||||
client.deliver(QStringLiteral("homeassistant/status"), "offline");
|
||||
client.deliver(QStringLiteral("other/status"), "online");
|
||||
QTest::qWait(50);
|
||||
QVERIFY(client.published.isEmpty());
|
||||
}
|
||||
|
||||
void disconnectCancelsPendingBirthRepublish() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.setBirthDelay(50, 50);
|
||||
client.setState(State::Connected);
|
||||
client.deliver(QStringLiteral("homeassistant/status"), "online");
|
||||
client.setState(State::Disconnected);
|
||||
client.published.clear();
|
||||
QTest::qWait(150);
|
||||
QVERIFY(client.published.isEmpty());
|
||||
}
|
||||
|
||||
// --ha-remove: empty retained payloads on every topic, discovery first.
|
||||
void removeFromHa() {
|
||||
FakeMqttClient client;
|
||||
HaDevice device(info(), &client);
|
||||
device.addSensor(uptime());
|
||||
QVERIFY(!device.removeFromHa()); // not connected
|
||||
client.setState(State::Connected);
|
||||
client.published.clear();
|
||||
QVERIFY(device.removeFromHa());
|
||||
QStringList topics;
|
||||
for (const FakeMqttClient::Message& m : client.published) {
|
||||
topics.append(m.topic);
|
||||
QVERIFY(m.payload.isEmpty());
|
||||
QVERIFY(m.retain);
|
||||
}
|
||||
QCOMPARE(topics, (QStringList{kDiscovery, kBase + "/availability",
|
||||
kBase + "/uptime/state",
|
||||
kBase + "/uptime/availability"}));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TestHaDevice)
|
||||
#include "tst_hadevice.moc"
|
||||
@@ -0,0 +1,150 @@
|
||||
// HaEntity/HaSensor (docs/DESIGN.md, D23): discovery component, topics,
|
||||
// throttling (SEN-14), availability without a value (SEN-15).
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
#include <QTest>
|
||||
|
||||
#include "ha/HaSensor.h"
|
||||
|
||||
namespace {
|
||||
|
||||
HaEntity::Description uptime(int throttleMs = 1000) {
|
||||
return {.id = QStringLiteral("uptime"),
|
||||
.name = QStringLiteral("Uptime"),
|
||||
.deviceClass = QStringLiteral("duration"),
|
||||
.unit = QStringLiteral("s"),
|
||||
.category = QStringLiteral("diagnostic"),
|
||||
.throttleMs = throttleMs};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestHaEntity : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void discoveryComponent() {
|
||||
const HaSensor sensor(uptime());
|
||||
const QJsonObject c = sensor.discoveryComponent(QStringLiteral("fa_dev"));
|
||||
QCOMPARE(c.value(QStringLiteral("p")).toString(), QStringLiteral("sensor"));
|
||||
QCOMPARE(c.value(QStringLiteral("name")).toString(),
|
||||
QStringLiteral("Uptime"));
|
||||
QCOMPARE(c.value(QStringLiteral("uniq_id")).toString(),
|
||||
QStringLiteral("fa_dev_uptime"));
|
||||
QCOMPARE(c.value(QStringLiteral("dev_cla")).toString(),
|
||||
QStringLiteral("duration"));
|
||||
QCOMPARE(c.value(QStringLiteral("unit_of_meas")).toString(),
|
||||
QStringLiteral("s"));
|
||||
QCOMPARE(c.value(QStringLiteral("ent_cat")).toString(),
|
||||
QStringLiteral("diagnostic"));
|
||||
QCOMPARE(c.value(QStringLiteral("stat_t")).toString(),
|
||||
QStringLiteral("~/uptime/state"));
|
||||
const QJsonArray availability = c.value(QStringLiteral("avty")).toArray();
|
||||
QCOMPARE(availability.size(), 2);
|
||||
QCOMPARE(availability[0].toObject().value(QStringLiteral("t")).toString(),
|
||||
QStringLiteral("~/availability"));
|
||||
QCOMPARE(availability[1].toObject().value(QStringLiteral("t")).toString(),
|
||||
QStringLiteral("~/uptime/availability"));
|
||||
QCOMPARE(c.value(QStringLiteral("avty_mode")).toString(),
|
||||
QStringLiteral("all"));
|
||||
}
|
||||
|
||||
void optionalKeysOnlyWhenSet() {
|
||||
const HaSensor sensor(
|
||||
{.id = QStringLiteral("x"), .name = QStringLiteral("X")});
|
||||
const QJsonObject c = sensor.discoveryComponent(QStringLiteral("fa_dev"));
|
||||
QVERIFY(!c.contains(QStringLiteral("dev_cla")));
|
||||
QVERIFY(!c.contains(QStringLiteral("unit_of_meas")));
|
||||
QVERIFY(!c.contains(QStringLiteral("ent_cat")));
|
||||
}
|
||||
|
||||
void topics() {
|
||||
const HaSensor sensor(uptime());
|
||||
QCOMPARE(sensor.stateTopic(), QStringLiteral("uptime/state"));
|
||||
QCOMPARE(sensor.availabilityTopic(), QStringLiteral("uptime/availability"));
|
||||
}
|
||||
|
||||
// SEN-15: no value → unavailable; the first value → state, then available.
|
||||
void firstValueMakesAvailable() {
|
||||
HaSensor sensor(uptime());
|
||||
QVERIFY(!sensor.hasValue());
|
||||
QVERIFY(sensor.state().isEmpty());
|
||||
QList<QString> events;
|
||||
connect(&sensor, &HaEntity::stateChanged, this,
|
||||
[&](const QByteArray& s) { events.append("state " + s); });
|
||||
connect(&sensor, &HaEntity::availabilityChanged, this,
|
||||
[&](bool a) { events.append(a ? "available" : "unavailable"); });
|
||||
sensor.setValue(42);
|
||||
QCOMPARE(events, (QList<QString>{"state 42", "available"}));
|
||||
QVERIFY(sensor.hasValue());
|
||||
QCOMPARE(sensor.state(), QByteArray("42"));
|
||||
}
|
||||
|
||||
void clearValueMakesUnavailable() {
|
||||
HaSensor sensor(uptime());
|
||||
sensor.setValue(1);
|
||||
QSignalSpy availability(&sensor, &HaEntity::availabilityChanged);
|
||||
QSignalSpy states(&sensor, &HaEntity::stateChanged);
|
||||
sensor.clearValue();
|
||||
QCOMPARE(availability.size(), 1);
|
||||
QCOMPARE(availability.first().first().toBool(), false);
|
||||
QVERIFY(!sensor.hasValue());
|
||||
sensor.clearValue(); // no change, no signal
|
||||
QCOMPARE(availability.size(), 1);
|
||||
// The same value as before the gap is a new first value.
|
||||
sensor.setValue(1);
|
||||
QCOMPARE(states.size(), 1);
|
||||
QCOMPARE(availability.size(), 2);
|
||||
QCOMPARE(availability.last().first().toBool(), true);
|
||||
}
|
||||
|
||||
void unchangedValueNotEmitted() {
|
||||
HaSensor sensor(uptime(0));
|
||||
QSignalSpy states(&sensor, &HaEntity::stateChanged);
|
||||
sensor.setValue(5);
|
||||
sensor.setValue(5);
|
||||
QCOMPARE(states.size(), 1);
|
||||
}
|
||||
|
||||
// SEN-14: changes within the interval → one state, the newest, at its end.
|
||||
void throttlesChanges() {
|
||||
HaSensor sensor(uptime(300));
|
||||
QSignalSpy states(&sensor, &HaEntity::stateChanged);
|
||||
sensor.setValue(1);
|
||||
QCOMPARE(states.size(), 1);
|
||||
sensor.setValue(2);
|
||||
sensor.setValue(3);
|
||||
QCOMPARE(states.size(), 1);
|
||||
QTest::qWait(100);
|
||||
QCOMPARE(states.size(), 1);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(states.size(), 2, 1000);
|
||||
QCOMPARE(states.last().first().toByteArray(), QByteArray("3"));
|
||||
// Nothing more pending.
|
||||
QTest::qWait(400);
|
||||
QCOMPARE(states.size(), 2);
|
||||
}
|
||||
|
||||
void changeAfterIntervalEmittedAtOnce() {
|
||||
HaSensor sensor(uptime(100));
|
||||
QSignalSpy states(&sensor, &HaEntity::stateChanged);
|
||||
sensor.setValue(1);
|
||||
QTest::qWait(150);
|
||||
sensor.setValue(2);
|
||||
QCOMPARE(states.size(), 2);
|
||||
}
|
||||
|
||||
// Back to the emitted value within the interval: nothing to publish.
|
||||
void revertWithinIntervalNotEmitted() {
|
||||
HaSensor sensor(uptime(200));
|
||||
QSignalSpy states(&sensor, &HaEntity::stateChanged);
|
||||
sensor.setValue(1);
|
||||
sensor.setValue(2);
|
||||
sensor.setValue(1);
|
||||
QTest::qWait(400);
|
||||
QCOMPARE(states.size(), 1);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TestHaEntity)
|
||||
#include "tst_haentity.moc"
|
||||
Reference in New Issue
Block a user