// 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 #include #include #include #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 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 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"