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
+8
View File
@@ -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)
+62
View File
@@ -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;
};
+236
View File
@@ -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"
+150
View File
@@ -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"