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:
@@ -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