diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d7948c2..39e1232 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 734d926..d2fab4a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b5d19a0..30a4e5b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/ha/HaDevice.cpp b/src/ha/HaDevice.cpp new file mode 100644 index 0000000..4629ccd --- /dev/null +++ b/src/ha/HaDevice.cpp @@ -0,0 +1,141 @@ +#include "ha/HaDevice.h" + +#include +#include +#include +#include +#include + +#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))); +} diff --git a/src/ha/HaDevice.h b/src/ha/HaDevice.h new file mode 100644 index 0000000..b72d711 --- /dev/null +++ b/src/ha/HaDevice.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +#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& entities() const { return m_entities; } + + QString baseTopic() const; // frameassistant/ + 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 m_entities; + QTimer m_birthTimer; + int m_birthDelayMinMs = 1000; + int m_birthDelayMaxMs = 5000; +}; diff --git a/src/ha/HaEntity.cpp b/src/ha/HaEntity.cpp new file mode 100644 index 0000000..096f198 --- /dev/null +++ b/src/ha/HaEntity.cpp @@ -0,0 +1,82 @@ +#include "ha/HaEntity.h" + +#include +#include + +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); +} diff --git a/src/ha/HaEntity.h b/src/ha/HaEntity.h new file mode 100644 index 0000000..e0df2f2 --- /dev/null +++ b/src/ha/HaEntity.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// 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_". + 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; +}; diff --git a/src/ha/HaSensor.cpp b/src/ha/HaSensor.cpp new file mode 100644 index 0000000..41fcb8b --- /dev/null +++ b/src/ha/HaSensor.cpp @@ -0,0 +1,12 @@ +#include "ha/HaSensor.h" + +#include + +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(); } diff --git a/src/ha/HaSensor.h b/src/ha/HaSensor.h new file mode 100644 index 0000000..766325d --- /dev/null +++ b/src/ha/HaSensor.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#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(); +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5c97362..8837bbd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/FakeMqttClient.h b/tests/FakeMqttClient.h new file mode 100644 index 0000000..a300189 --- /dev/null +++ b/tests/FakeMqttClient.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#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 to(const QString& topic) const { + QList result; + for (const Message& message : published) + if (message.topic == topic) result.append(message); + return result; + } + + QList published; + QStringList subscriptions; + int connectCalls = 0; + + private: + State m_state = State::Disconnected; +}; diff --git a/tests/tst_hadevice.cpp b/tests/tst_hadevice.cpp new file mode 100644 index 0000000..5d27a93 --- /dev/null +++ b/tests/tst_hadevice.cpp @@ -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 +#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" diff --git a/tests/tst_haentity.cpp b/tests/tst_haentity.cpp new file mode 100644 index 0000000..9cfb141 --- /dev/null +++ b/tests/tst_haentity.cpp @@ -0,0 +1,150 @@ +// HaEntity/HaSensor (docs/DESIGN.md, D23): discovery component, topics, +// throttling (SEN-14), availability without a value (SEN-15). + +#include +#include +#include + +#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 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{"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"