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:
@@ -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"
|
||||
Reference in New Issue
Block a user