diff --git a/.github/workflows/mqttmanager-tests.yml b/.github/workflows/mqttmanager-tests.yml new file mode 100644 index 00000000..36e94ae7 --- /dev/null +++ b/.github/workflows/mqttmanager-tests.yml @@ -0,0 +1,60 @@ +name: "MQTTManager tests" + +on: + pull_request: + paths: + - "docker/MQTTManager/**" + - ".github/workflows/mqttmanager-tests.yml" + push: + branches: + - "main" + - "devel" + paths: + - "docker/MQTTManager/**" + - ".github/workflows/mqttmanager-tests.yml" + +jobs: + test: + runs-on: ubuntu-latest + # Building the Conan dependencies from source takes about an hour when the cache is cold. + timeout-minutes: 120 + env: + # Host directory mounted as the container's Conan package cache (see run_tests.sh). + NSPM_TEST_CONAN_CACHE: ${{ github.workspace }}/.conan-cache + steps: + - uses: actions/checkout@v4 + + - name: Restore Conan packages + id: conan-cache + uses: actions/cache/restore@v4 + with: + path: ${{ env.NSPM_TEST_CONAN_CACHE }} + key: mqttmanager-conan-${{ hashFiles('docker/MQTTManager/conanfile.py', 'docker/MQTTManager/tests/docker/Dockerfile') }} + restore-keys: mqttmanager-conan- + + - name: Build and run tests + run: | + mkdir -p "$NSPM_TEST_CONAN_CACHE" + docker/MQTTManager/run_tests.sh + env: + NSPM_TEST_RESULTS_DIR: ${{ github.workspace }}/test-results + + # annotate_only writes the job summary and failure annotations without creating a + # check run, which needs checks: write and so fails on pull requests from forks. + - name: Report test results + if: ${{ !cancelled() }} + uses: mikepenz/action-junit-report@v6 + with: + report_paths: test-results/*.xml + annotate_only: true + detailed_summary: true + include_passed: true + require_tests: true + + # Saved even when the tests fail, so a failing test does not cost another dependency build. + - name: Save Conan packages + if: always() && steps.conan-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ env.NSPM_TEST_CONAN_CACHE }} + key: ${{ steps.conan-cache.outputs.cache-primary-key }} diff --git a/.gitignore b/.gitignore index 32cb8341..59605479 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ firmware/NSPanelManagerFirmware/CMakeLists.txt # MacOS .DS_Store +docker/MQTTManager/tests/last_run.log diff --git a/docker/MQTTManager/CMakeLists.txt b/docker/MQTTManager/CMakeLists.txt index a3255d89..6a78036b 100644 --- a/docker/MQTTManager/CMakeLists.txt +++ b/docker/MQTTManager/CMakeLists.txt @@ -3,13 +3,19 @@ project(nspm_mqttmanager) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_CXX_STANDARD 23) -set(TEST_MODE 0) +# Build the unit tests (tests/) and compile the inline TEST_MODE tests into the libraries. +# Run them with ./run_tests.sh, or `ctest` in the build directory. +option(NSPM_BUILD_TESTS "Build the MQTTManager unit tests" OFF) set(CMAKE_INCLUDE_SRC_DIRECTORY /MQTTManager/include) set(PROTOBUF_SRC_DIRECTORY /MQTTManager/include/protobuf) add_compile_options(-rdynamic -g) add_compile_definitions(SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_TRACE BOOST_STACKTRACE_USE_BACKTRACE BOOST_BIND_GLOBAL_PLACEHOLDERS BOOST_STACKTRACE_LIBCXX_RUNTIME_MAY_CAUSE_MEMORY_LEAK) -add_compile_definitions(TEST_MODE=0) +if(NSPM_BUILD_TESTS) + add_compile_definitions(TEST_MODE=1) +else() + add_compile_definitions(TEST_MODE=0) +endif() # Statically link libraries IF(DEFINED ENV{STRIP}) @@ -151,7 +157,7 @@ add_executable(${PROJECT_NAME} src/main.cpp) target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) target_link_libraries(${PROJECT_NAME} MQTT_Manager MQTTManager_Config MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager MQTTManager_EntityManager MQTTManager_NextionImageServer MQTTManager_Scene MQTTManager_Room MQTTManager_WebsocketServer MQTTManager_CommandManager gtest::gtest) -if (TEST_MODE==1) +if(NSPM_BUILD_TESTS) enable_testing() - gtest_discover_tests(${PROJECT_NAME}) + add_subdirectory(tests) endif() diff --git a/docker/MQTTManager/compile_mqttmanager.sh b/docker/MQTTManager/compile_mqttmanager.sh index 00fd1936..8ae1249a 100755 --- a/docker/MQTTManager/compile_mqttmanager.sh +++ b/docker/MQTTManager/compile_mqttmanager.sh @@ -29,11 +29,9 @@ while true; do done if [ "$TEST_MODE" -eq 1 ]; then - sed -i 's/set(TEST_MODE.*/set(TEST_MODE 1)/g' /MQTTManager/CMakeLists.txt - sed -i 's/add_compile_definitions(TEST_MODE.*/add_compile_definitions(TEST_MODE=1)/g' /MQTTManager/CMakeLists.txt + NSPM_BUILD_TESTS=ON else - sed -i 's/set(TEST_MODE.*/set(TEST_MODE 0)/g' /MQTTManager/CMakeLists.txt - sed -i 's/add_compile_definitions(TEST_MODE.*/add_compile_definitions(TEST_MODE=0)/g' /MQTTManager/CMakeLists.txt + NSPM_BUILD_TESTS=OFF fi if [ -z "$TARGETPLATFORM" ]; then @@ -139,7 +137,7 @@ conan install . --build=missing -pr:b default -pr:h host echo "--> Conan install complete." cd build source $BUILD_TYPE/generators/conanbuild.sh -cmake .. -DCMAKE_TOOLCHAIN_FILE=$BUILD_TYPE/generators/conan_toolchain.cmake -DCMAKE_BUILD_TYPE=$BUILD_TYPE -DBUILD_SHARED_LIBS=${BUILD_SHARED_LIBS} +cmake .. -DCMAKE_TOOLCHAIN_FILE=$BUILD_TYPE/generators/conan_toolchain.cmake -DCMAKE_BUILD_TYPE=$BUILD_TYPE -DBUILD_SHARED_LIBS=${BUILD_SHARED_LIBS} -DNSPM_BUILD_TESTS=${NSPM_BUILD_TESTS} cmake --build . --config $BUILD_TYPE -j $(nproc) if [ -f /MQTTManager/fix_compile_commands_path.sh ]; then source /MQTTManager/fix_compile_commands_path.sh diff --git a/docker/MQTTManager/include/button/nspm_button.cpp b/docker/MQTTManager/include/button/nspm_button.cpp index b50c7162..980b558b 100644 --- a/docker/MQTTManager/include/button/nspm_button.cpp +++ b/docker/MQTTManager/include/button/nspm_button.cpp @@ -18,8 +18,8 @@ NSPMButton::NSPMButton(uint32_t button_id) : ButtonEntity(button_id) { // Process Home Assistant specific details. General button data is loaded in the "Button" constructor. - if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::HOME_ASSISTANT) { - SPDLOG_ERROR("HomeAssistantSwitch has not been recognized as controlled by HOME_ASSISTANT. Will stop processing switch."); + if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::NSPM) { + SPDLOG_ERROR("NSPMButton has not been recognized as controlled by NSPM. Will stop processing button."); return; } diff --git a/docker/MQTTManager/include/command_manager/command_manager.hpp b/docker/MQTTManager/include/command_manager/command_manager.hpp index 5819ae86..6bd0080c 100644 --- a/docker/MQTTManager/include/command_manager/command_manager.hpp +++ b/docker/MQTTManager/include/command_manager/command_manager.hpp @@ -21,6 +21,13 @@ class CommandManager { CommandManager::_callbacks.disconnect(callback); } +#if defined(TEST_MODE) && TEST_MODE == 1 + // Number of attached command callbacks. + static size_t test_callback_count() { + return CommandManager::_callbacks.num_slots(); + } +#endif + private: static inline boost::signals2::signal _callbacks; }; diff --git a/docker/MQTTManager/include/entity_manager/entity_manager.cpp b/docker/MQTTManager/include/entity_manager/entity_manager.cpp index 4c933d3f..a7b285c6 100644 --- a/docker/MQTTManager/include/entity_manager/entity_manager.cpp +++ b/docker/MQTTManager/include/entity_manager/entity_manager.cpp @@ -121,10 +121,13 @@ void EntityManager::load_rooms() { SPDLOG_INFO("Loading {} rooms.", room_ids.size()); // Remove room if it does no longer exist - EntityManager::_rooms.erase(std::remove_if(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [&room_ids](auto room) { - return std::find_if(room_ids.begin(), room_ids.end(), [&room](auto id) { return id == room->get_id(); }) == room_ids.end(); - }), - EntityManager::_rooms.end()); + { + std::lock_guard mutex_guard(EntityManager::_rooms_mutex); + EntityManager::_rooms.erase(std::remove_if(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [&room_ids](auto room) { + return std::find_if(room_ids.begin(), room_ids.end(), [&room](auto id) { return id == room->get_id(); }) == room_ids.end(); + }), + EntityManager::_rooms.end()); + } // Cause existing room to reload config or add a new room if it does not exist. for (auto &room_id : room_ids) { @@ -142,6 +145,7 @@ void EntityManager::load_rooms() { } } + std::lock_guard mutex_guard(EntityManager::_rooms_mutex); std::sort(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [](const std::shared_ptr &a, const std::shared_ptr &b) { return a->get_display_order() < b->get_display_order(); }); @@ -573,9 +577,16 @@ void EntityManager::update_all_rooms_status() { uint16_t num_kelvin_lights_ceiling = 0; // Total number of ceiling lights with color temperature uint16_t num_kelvin_lights_table = 0; // Total number of table lights with color temperature + // Work on a copy, as load_rooms() may change the list meanwhile. + std::vector> rooms; + { + std::lock_guard mutex_guard(EntityManager::_rooms_mutex); + rooms = EntityManager::_rooms; + } + // Determine if any light is on in any of the rooms bool any_light_on = false; - for (auto room : EntityManager::_rooms) { + for (auto room : rooms) { std::vector> entities = room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT); for (auto light : entities) { if (light->get_state() && light->get_controlled_from_main_page()) { @@ -585,7 +596,7 @@ void EntityManager::update_all_rooms_status() { } } - for (auto room : EntityManager::_rooms) { + for (auto room : rooms) { for (auto &light : room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT)) { // Light is not controlled from main page, exclude it from calculations. if (!light->get_controlled_from_main_page()) { diff --git a/docker/MQTTManager/include/entity_manager/entity_manager.hpp b/docker/MQTTManager/include/entity_manager/entity_manager.hpp index b26d7f8b..56afffbe 100644 --- a/docker/MQTTManager/include/entity_manager/entity_manager.hpp +++ b/docker/MQTTManager/include/entity_manager/entity_manager.hpp @@ -213,6 +213,13 @@ class EntityManager { static std::expected, EntityError> get_nspanel_by_mac(std::string mac); static std::expected get_room_id_for_panel_id(uint32_t nspanel_id); +#if defined(TEST_MODE) && TEST_MODE == 1 + // Handles a panel command as if CommandManager had delivered it, without starting init()'s threads. + static void test_process_command(NSPanelMQTTManagerCommand &command) { + EntityManager::_command_callback(command); + } +#endif + private: static inline std::vector> _entities; static inline std::mutex _entities_mutex; diff --git a/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.cpp b/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.cpp index b2b5b7ad..dabd0f55 100644 --- a/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.cpp +++ b/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.cpp @@ -188,6 +188,12 @@ void HomeAssistantManager::_send_auth() { void HomeAssistantManager::send_json(nlohmann::json &data) { data["id"] = HomeAssistantManager::_next_message_id++; +#if defined(TEST_MODE) && TEST_MODE == 1 + { + std::lock_guard lock_guard(HomeAssistantManager::_test_sent_messages_mutex); + HomeAssistantManager::_test_sent_messages.push_back(data); + } +#endif std::string buffer = data.dump(); HomeAssistantManager::_send_string(buffer); } diff --git a/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.hpp b/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.hpp index c831d165..8bb730cb 100644 --- a/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.hpp +++ b/docker/MQTTManager/include/home_assistant_manager/home_assistant_manager.hpp @@ -10,6 +10,12 @@ #include #include +#if defined(TEST_MODE) && TEST_MODE == 1 +#include +#include +#include +#endif + class HomeAssistantManager { public: static void init(); // Start a new thread and connect to HA. @@ -41,7 +47,25 @@ class HomeAssistantManager { HomeAssistantManager::_home_assistant_observers[item].disconnect(callback); } +#if defined(TEST_MODE) && TEST_MODE == 1 + // Everything passed to send_json since the last call, whether or not Home Assistant is connected. + static std::vector test_take_sent_messages() { + std::lock_guard lock_guard(HomeAssistantManager::_test_sent_messages_mutex); + return std::exchange(HomeAssistantManager::_test_sent_messages, {}); + } + + // Deliver an event as if it had been received from Home Assistant. + static void test_process_event(nlohmann::json &event_data) { + HomeAssistantManager::_process_home_assistant_event(event_data); + } +#endif + private: +#if defined(TEST_MODE) && TEST_MODE == 1 + static inline std::mutex _test_sent_messages_mutex; + static inline std::vector _test_sent_messages; +#endif + // Request all states that Home Assistant has as there is no way to only request state of one object. static void _request_all_states(); diff --git a/docker/MQTTManager/include/light/openhab_light.cpp b/docker/MQTTManager/include/light/openhab_light.cpp index 3cb3f280..086dc7ad 100644 --- a/docker/MQTTManager/include/light/openhab_light.cpp +++ b/docker/MQTTManager/include/light/openhab_light.cpp @@ -35,6 +35,12 @@ OpenhabLight::OpenhabLight(uint32_t light_id) : Light(light_id) { this->_last_on_off_change = 0; this->_last_brightness_change = 0; this->_last_color_temp_change = 0; + this->_last_group_brightness_item_state_changed_event_ms = 0; + this->_last_group_color_temp_item_state_changed_event_ms = 0; + this->_last_group_rgb_item_state_changed_event_ms = 0; + this->_openhab_group_brightness_item_state_changed_event_thread_running = false; + this->_openhab_group_color_temp_item_state_changed_event_thread_running = false; + this->_openhab_group_rgb_item_state_changed_event_thread_running = false; this->_current_mode = MQTT_MANAGER_LIGHT_MODE::DEFAULT; this->_current_brightness = 0; this->_current_color_temperature = 0; diff --git a/docker/MQTTManager/include/mqtt_manager/mqtt_manager.cpp b/docker/MQTTManager/include/mqtt_manager/mqtt_manager.cpp index 9af44851..a81ab4b9 100644 --- a/docker/MQTTManager/include/mqtt_manager/mqtt_manager.cpp +++ b/docker/MQTTManager/include/mqtt_manager/mqtt_manager.cpp @@ -218,7 +218,13 @@ void MQTT_Manager::_reconnect_mqtt_client() { "online", true); // Loop over retained MQTT messages and send them again to the MQTT broker as it may have restarted and lost retained messages. - for (auto it = MQTT_Manager::_mqtt_retain_buffer.cbegin(); it != MQTT_Manager::_mqtt_retain_buffer.cend(); it++) { + // Publish from a copy, as publish() writes to the buffer. + std::unordered_map retained_messages; + { + std::lock_guard retain_buffer_guard(MQTT_Manager::_mqtt_retain_buffer_mutex); + retained_messages = MQTT_Manager::_mqtt_retain_buffer; + } + for (auto it = retained_messages.cbegin(); it != retained_messages.cend(); it++) { MQTT_Manager::publish(it->first, it->second, true); } @@ -250,6 +256,7 @@ void MQTT_Manager::_reconnect_mqtt_client() { // Send buffered messages if any try { + std::lock_guard mutex_guard(MQTT_Manager::_mqtt_client_mutex); auto it = MQTT_Manager::_mqtt_messages_buffer.cbegin(); while (it != MQTT_Manager::_mqtt_messages_buffer.cend()) { MQTT_Manager::_mqtt_client->publish((*it)); @@ -292,7 +299,15 @@ void MQTT_Manager::publish(const std::string &topic, const std::string &payload, return; // It's not allowed to publish to empty topic. } +#if defined(TEST_MODE) && TEST_MODE == 1 + { + std::lock_guard lock_guard(MQTT_Manager::_test_published_messages_mutex); + MQTT_Manager::_test_published_messages.push_back({topic, payload, retain}); + } +#endif + if (retain) { + std::lock_guard retain_buffer_guard(MQTT_Manager::_mqtt_retain_buffer_mutex); MQTT_Manager::_mqtt_retain_buffer[topic] = payload; } @@ -346,7 +361,10 @@ void MQTT_Manager::clear_retain(const std::string &topic) { return; } - MQTT_Manager::_mqtt_retain_buffer.erase(topic); + { + std::lock_guard retain_buffer_guard(MQTT_Manager::_mqtt_retain_buffer_mutex); + MQTT_Manager::_mqtt_retain_buffer.erase(topic); + } std::lock_guard mutex_guard(MQTT_Manager::_mqtt_client_mutex); WebsocketServer::set_stomp_topic_retained(fmt::format("mqtt/{}", topic), false); diff --git a/docker/MQTTManager/include/mqtt_manager/mqtt_manager.hpp b/docker/MQTTManager/include/mqtt_manager/mqtt_manager.hpp index 8f423829..98425a2d 100644 --- a/docker/MQTTManager/include/mqtt_manager/mqtt_manager.hpp +++ b/docker/MQTTManager/include/mqtt_manager/mqtt_manager.hpp @@ -17,6 +17,11 @@ #include #include +#if defined(TEST_MODE) && TEST_MODE == 1 +#include +#include +#endif + struct MQTTMessage { std::string topic; std::string message; @@ -106,7 +111,31 @@ class MQTT_Manager { */ static void clear_retain(const std::string &topic); +#if defined(TEST_MODE) && TEST_MODE == 1 + struct TestPublishedMessage { + std::string topic; + std::string payload; + bool retain; + }; + + // Everything passed to publish since the last call, whether or not the MQTT client is connected. + static std::vector test_take_published_messages() { + std::lock_guard lock_guard(MQTT_Manager::_test_published_messages_mutex); + return std::exchange(MQTT_Manager::_test_published_messages, {}); + } + + // Number of callbacks attached to a topic. + static size_t test_callback_count(const std::string &topic) { + std::lock_guard mutex_guard(MQTT_Manager::_mqtt_client_mutex); + return MQTT_Manager::_mqtt_callbacks.count(topic) > 0 ? MQTT_Manager::_mqtt_callbacks.at(topic).num_slots() : 0; + } +#endif + private: +#if defined(TEST_MODE) && TEST_MODE == 1 + static inline std::mutex _test_published_messages_mutex; + static inline std::vector _test_published_messages; +#endif static inline boost::lockfree::spsc_queue> _mqtt_message_queue; static inline std::thread _process_messages_thread; static inline mqtt::client *_mqtt_client = nullptr; @@ -114,6 +143,7 @@ class MQTT_Manager { static inline std::mutex _mqtt_message_mutex; static inline std::list _mqtt_messages_buffer; static inline std::unordered_map _mqtt_retain_buffer; // Used so that when we reconnect to an MQTT server that has restarted we can repopulate the retained messages. + static inline std::mutex _mqtt_retain_buffer_mutex; static void _reconnect_mqtt_client(); diff --git a/docker/MQTTManager/include/mqtt_manager_config/mqtt_manager_config.hpp b/docker/MQTTManager/include/mqtt_manager_config/mqtt_manager_config.hpp index 18c52ec1..bfef1ab6 100644 --- a/docker/MQTTManager/include/mqtt_manager_config/mqtt_manager_config.hpp +++ b/docker/MQTTManager/include/mqtt_manager_config/mqtt_manager_config.hpp @@ -49,7 +49,6 @@ enum MQTT_MANAGER_SETTING { OPENHAB_TOKEN, OPENHAB_BRIGHTNESS_CHANNEL_MAX, OPENHAB_BRIGHTNESS_CHANNEL_MIN, - OPENHAB_RGB_CHANNEL_NAME, OUTSIDE_TEMP_SENSOR_ENTITY_ID, OUTSIDE_TEMP_SENSOR_PROVIDER, WEATHER_PRECIPITATION_FORMAT, diff --git a/docker/MQTTManager/include/nspanel/nspanel.cpp b/docker/MQTTManager/include/nspanel/nspanel.cpp index e5aa9006..8aca5fe7 100644 --- a/docker/MQTTManager/include/nspanel/nspanel.cpp +++ b/docker/MQTTManager/include/nspanel/nspanel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -55,12 +57,7 @@ NSPanel::NSPanel(uint32_t id) { this->_id = id; SPDLOG_INFO("Loading new NSPanel with ID {}.", id); this->reload_config(); - if (!this->_mqtt_config_topic.empty()) { - SPDLOG_INFO("Loaded accepted NSPanel {}::{}.", this->_id, this->_name); - this->_state = MQTT_MANAGER_NSPANEL_STATE::OFFLINE; // Assume offline until we have received a state update from the panel. - } - - if (!this->_mqtt_config_topic.empty()) { + if (!this->_mqtt_config_topic.empty() && this->_state != MQTT_MANAGER_NSPANEL_STATE::AWAITING_ACCEPT) { SPDLOG_INFO("Loaded accepted NSPanel {}::{}.", this->_id, this->_name); this->_state = MQTT_MANAGER_NSPANEL_STATE::WAITING; } @@ -172,7 +169,7 @@ void NSPanel::reload_config() { } bool register_relay2_as_light = this->_get_nspanel_setting_with_default("relay2_is_light", "False").compare("True") == 0; - SPDLOG_DEBUG("Will register NSPanel {}::{} relay 2 as {}", this->_id, this->_name, register_relay1_as_light ? "light" : "relay"); + SPDLOG_DEBUG("Will register NSPanel {}::{} relay 2 as {}", this->_id, this->_name, register_relay2_as_light ? "light" : "relay"); if (this->_register_relay2_as_light != register_relay2_as_light) { this->_register_relay2_as_light = register_relay2_as_light; reregister_to_ha_mqtt_discovery = true; @@ -183,6 +180,8 @@ void NSPanel::reload_config() { this->_heap_used_pct = 0; this->_nspanel_warnings.clear(); this->_temperature = 0; + this->_humidity = 0; + this->_pressure = 0; this->_update_progress = 0; } @@ -196,6 +195,9 @@ void NSPanel::reload_config() { if (!panel_settings.denied && !panel_settings.accepted) { // No decission has been made on wether ot accept or deny panel. It is therefore awaiting_accept this->_state = MQTT_MANAGER_NSPANEL_STATE::AWAITING_ACCEPT; + } else if (this->_state == MQTT_MANAGER_NSPANEL_STATE::AWAITING_ACCEPT || this->_state == MQTT_MANAGER_NSPANEL_STATE::DENIED) { + // The panel has just been accepted. Wait for it to register again. + this->_state = MQTT_MANAGER_NSPANEL_STATE::WAITING; } SPDLOG_DEBUG("Building MQTT topics for NSPanel {}::{}", this->_id, this->_name); @@ -228,6 +230,9 @@ void NSPanel::reload_config() { this->_mqtt_relay2_state_topic = fmt::format("nspanel/{}/relay2_state", this->_mac); this->_mqtt_status_topic = fmt::format("nspanel/{}/status", this->_mac); this->_mqtt_status_report_topic = fmt::format("nspanel/{}/status_report", this->_mac); + this->_mqtt_mac_log_topic = fmt::format("nspanel/{}/log", this->_mac); + this->_mqtt_legacy_status_topic = fmt::format("nspanel/{}/status", this->_name); // TODO: Remove me and use only topic based on MAC-address instead + this->_mqtt_legacy_status_report_topic = fmt::format("nspanel/{}/status_report", this->_name); // TODO: Remove me and use only topic based on MAC-address instead this->_mqtt_temperature_topic = fmt::format("nspanel/{}/temperature", this->_mac); this->_mqtt_humidity_topic = fmt::format("nspanel/{}/humidity", this->_mac); this->_mqtt_pressure_topic = fmt::format("nspanel/{}/pressure", this->_mac); @@ -244,9 +249,9 @@ void NSPanel::reload_config() { MQTT_Manager::subscribe(this->_mqtt_relay1_state_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); MQTT_Manager::subscribe(this->_mqtt_relay2_state_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); MQTT_Manager::subscribe(this->_mqtt_log_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); // TODO: Remove me and use only topic based on MAC-address instead - MQTT_Manager::subscribe(fmt::format("nspanel/{}/status", this->_name), boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); // TODO: Remove me and use only topic based on MAC-address instead - MQTT_Manager::subscribe(fmt::format("nspanel/{}/status_report", this->_name), boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); // TODO: Remove me and use only topic based on MAC-address instead - MQTT_Manager::subscribe(fmt::format("nspanel/{}/log", this->_mac), boost::bind(&NSPanel::mqtt_log_callback, this, _1, _2)); + MQTT_Manager::subscribe(this->_mqtt_legacy_status_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); + MQTT_Manager::subscribe(this->_mqtt_legacy_status_report_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); + MQTT_Manager::subscribe(this->_mqtt_mac_log_topic, boost::bind(&NSPanel::mqtt_log_callback, this, _1, _2)); MQTT_Manager::subscribe(this->_mqtt_status_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); MQTT_Manager::subscribe(this->_mqtt_status_report_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); } @@ -304,6 +309,9 @@ void NSPanel::send_config() { config.set_button1_upper_temperature(0); config.set_button2_lower_temperature(0); config.set_button2_upper_temperature(0); + if ((*default_room)->has_temperature_sensor()) { + config.set_inside_temperature_sensor_mqtt_topic((*default_room)->get_temperature_sensor_mqtt_topic()); + } ButtonMode b1_mode = static_cast(this->_settings.button1_mode); if (b1_mode == ButtonMode::DIRECT) { @@ -312,12 +320,12 @@ void NSPanel::send_config() { config.set_button1_mode(NSPanelConfig_NSPanelButtonMode_FOLLOW); } else if (b1_mode == ButtonMode::THERMOSTAT_HEATING) { config.set_button1_mode(NSPanelConfig_NSPanelButtonMode_THERMOSTAT_HEAT); - config.set_button1_lower_temperature(std::stoi(this->_get_nspanel_setting_with_default("button1_relay_lower_temperature", "0"))); - config.set_button1_upper_temperature(std::stoi(this->_get_nspanel_setting_with_default("button1_relay_upper_temperature", "0"))); + config.set_button1_lower_temperature(this->_get_nspanel_temperature_limit_setting("button1_relay_lower_temperature")); + config.set_button1_upper_temperature(this->_get_nspanel_temperature_limit_setting("button1_relay_upper_temperature")); } else if (b1_mode == ButtonMode::THERMOSTAT_COOLING) { config.set_button1_mode(NSPanelConfig_NSPanelButtonMode_THERMOSTAT_COOL); - config.set_button1_lower_temperature(std::stoi(this->_get_nspanel_setting_with_default("button1_relay_lower_temperature", "0"))); - config.set_button1_upper_temperature(std::stoi(this->_get_nspanel_setting_with_default("button1_relay_upper_temperature", "0"))); + config.set_button1_lower_temperature(this->_get_nspanel_temperature_limit_setting("button1_relay_lower_temperature")); + config.set_button1_upper_temperature(this->_get_nspanel_temperature_limit_setting("button1_relay_upper_temperature")); } else { config.set_button1_mode(NSPanelConfig_NSPanelButtonMode_NOTIFY_MANAGER); } @@ -329,12 +337,12 @@ void NSPanel::send_config() { config.set_button2_mode(NSPanelConfig_NSPanelButtonMode_FOLLOW); } else if (b2_mode == ButtonMode::THERMOSTAT_HEATING) { config.set_button2_mode(NSPanelConfig_NSPanelButtonMode_THERMOSTAT_HEAT); - config.set_button2_lower_temperature(std::stoi(this->_get_nspanel_setting_with_default("button2_relay_lower_temperature", "0"))); - config.set_button2_upper_temperature(std::stoi(this->_get_nspanel_setting_with_default("button2_relay_upper_temperature", "0"))); + config.set_button2_lower_temperature(this->_get_nspanel_temperature_limit_setting("button2_relay_lower_temperature")); + config.set_button2_upper_temperature(this->_get_nspanel_temperature_limit_setting("button2_relay_upper_temperature")); } else if (b2_mode == ButtonMode::THERMOSTAT_COOLING) { config.set_button2_mode(NSPanelConfig_NSPanelButtonMode_THERMOSTAT_COOL); - config.set_button2_lower_temperature(std::stoi(this->_get_nspanel_setting_with_default("button2_relay_lower_temperature", "0"))); - config.set_button2_upper_temperature(std::stoi(this->_get_nspanel_setting_with_default("button2_relay_upper_temperature", "0"))); + config.set_button2_lower_temperature(this->_get_nspanel_temperature_limit_setting("button2_relay_lower_temperature")); + config.set_button2_upper_temperature(this->_get_nspanel_temperature_limit_setting("button2_relay_upper_temperature")); } else { config.set_button2_mode(NSPanelConfig_NSPanelButtonMode_NOTIFY_MANAGER); } @@ -477,6 +485,7 @@ void NSPanel::send_config() { NSPanel::~NSPanel() { SPDLOG_INFO("Destroying NSPanel {}::{}", this->_id, this->_name); + CommandManager::detach_callback(boost::bind(&NSPanel::command_callback, this, _1)); WebsocketServer::detach_stomp_callback(fmt::format("nspanel/{}/command", this->_mac), boost::bind(&NSPanel::handle_stomp_command_callback, this, _1)); this->reset_mqtt_topics(); @@ -489,6 +498,9 @@ void NSPanel::reset_mqtt_topics() { MQTT_Manager::detach_callback(this->_mqtt_log_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); MQTT_Manager::detach_callback(this->_mqtt_status_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); MQTT_Manager::detach_callback(this->_mqtt_status_report_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); + MQTT_Manager::detach_callback(this->_mqtt_mac_log_topic, boost::bind(&NSPanel::mqtt_log_callback, this, _1, _2)); + MQTT_Manager::detach_callback(this->_mqtt_legacy_status_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); + MQTT_Manager::detach_callback(this->_mqtt_legacy_status_report_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); // This nspanel was removed. Clear any retain on any MQTT topic. MQTT_Manager::clear_retain(this->_mqtt_command_topic); @@ -1686,3 +1698,30 @@ std::string NSPanel::_get_nspanel_setting_with_default(std::string key, std::str } return default_value; } + +int NSPanel::_get_nspanel_temperature_limit_setting(std::string key) { + // The limits are free text in the web interface, so a bad value must not stop the config being sent. + std::string value = this->_get_nspanel_setting_with_default(key, "0"); + double limit = 0; + size_t parsed = 0; + try { + limit = std::stod(value, &parsed); + } catch (std::exception &ex) { + parsed = 0; + } + if (parsed == 0 || value.find_first_not_of(" \t", parsed) != std::string::npos || !std::isfinite(limit)) { + SPDLOG_ERROR("NSPanel {}::{} setting {} is '{}', which is not a number. Will send 0.", this->_id, this->_name, key, value); + return 0; + } + if (std::fabs(limit) > std::numeric_limits::max()) { + SPDLOG_ERROR("NSPanel {}::{} setting {} is {}, which is out of range. Will send 0.", this->_id, this->_name, key, value); + return 0; + } + + // NSPanelConfig only holds whole degrees. + int rounded = static_cast(std::lround(limit)); + if (rounded != limit) { + SPDLOG_WARN("NSPanel {}::{} setting {} is {}, but panels only take whole degrees. Will send {}.", this->_id, this->_name, key, value, rounded); + } + return rounded; +} diff --git a/docker/MQTTManager/include/nspanel/nspanel.hpp b/docker/MQTTManager/include/nspanel/nspanel.hpp index 927ca9b3..331a03bd 100644 --- a/docker/MQTTManager/include/nspanel/nspanel.hpp +++ b/docker/MQTTManager/include/nspanel/nspanel.hpp @@ -176,6 +176,8 @@ class NSPanel { private: std::string _get_nspanel_setting_with_default(std::string key, std::string default_value); + // A thermostat temperature limit setting, or 0 if it is not a number. + int _get_nspanel_temperature_limit_setting(std::string key); // Vars: uint32_t _id; @@ -219,27 +221,32 @@ class NSPanel { // MQTT Stuff: // Wether or not relay1 should be registered to Home Assistant as a switch or light. - bool _register_relay1_as_light; + bool _register_relay1_as_light = false; // The topic to send commands to the relay1 std::string _mqtt_relay1_command_topic; // The topic where relay1 state is published std::string _mqtt_relay1_state_topic; // Wether or not relay1 is on - bool _relay1_state; + bool _relay1_state = false; // Wether or not relay2 should be registered to Home Assistant as a switch or light. - bool _register_relay2_as_light; + bool _register_relay2_as_light = false; // The topic to send commands to the relay2 std::string _mqtt_relay2_command_topic; // The topic where relay2 state is published std::string _mqtt_relay2_state_topic; // Wether or not relay2 is on - bool _relay2_state; + bool _relay2_state = false; // The topic to capture logs from MQTT std::string _mqtt_log_topic; // The topic to capture status (online/offline) from MQTT std::string _mqtt_status_topic; // The topic to capture status reports from MQTT std::string _mqtt_status_report_topic; + // The topic to capture logs from MQTT, based on MAC-address + std::string _mqtt_mac_log_topic; + // The old name based status and status report topics, still subscribed to for older firmware + std::string _mqtt_legacy_status_topic; + std::string _mqtt_legacy_status_report_topic; // The topic to send out temperature in raw format instead of encoded in protobuf status report std::string _mqtt_temperature_topic; // The topic to send out humidity in raw format instead of encoded in protobuf status report diff --git a/docker/MQTTManager/include/openhab_manager/openhab_manager.cpp b/docker/MQTTManager/include/openhab_manager/openhab_manager.cpp index 82d08336..78edae2f 100644 --- a/docker/MQTTManager/include/openhab_manager/openhab_manager.cpp +++ b/docker/MQTTManager/include/openhab_manager/openhab_manager.cpp @@ -242,6 +242,12 @@ std::string OpenhabManager::_fetch_item_state_via_rest(std::string item) { void OpenhabManager::send_json(nlohmann::json &data) { data["source"] = "NSPanelManager::MqttManager"; +#if defined(TEST_MODE) && TEST_MODE == 1 + { + std::lock_guard lock_guard(OpenhabManager::_test_sent_messages_mutex); + OpenhabManager::_test_sent_messages.push_back(data); + } +#endif std::string buffer = data.dump(); OpenhabManager::_send_string(buffer); } diff --git a/docker/MQTTManager/include/openhab_manager/openhab_manager.hpp b/docker/MQTTManager/include/openhab_manager/openhab_manager.hpp index 59231954..58f8ca8f 100644 --- a/docker/MQTTManager/include/openhab_manager/openhab_manager.hpp +++ b/docker/MQTTManager/include/openhab_manager/openhab_manager.hpp @@ -16,6 +16,11 @@ #include #include +#if defined(TEST_MODE) && TEST_MODE == 1 +#include +#include +#endif + class OpenhabManager { public: static void connect(); @@ -75,7 +80,31 @@ class OpenhabManager { OpenhabManager::_openhab_item_observers[item].disconnect(callback); } +#if defined(TEST_MODE) && TEST_MODE == 1 + // Everything passed to send_json since the last call, whether or not OpenHAB is connected. + static std::vector test_take_sent_messages() { + std::lock_guard lock_guard(OpenhabManager::_test_sent_messages_mutex); + return std::exchange(OpenhabManager::_test_sent_messages, {}); + } + + // Deliver a message as if it had been received on the OpenHAB websocket. + static void test_process_websocket_message(const std::string &message) { + OpenhabManager::_process_websocket_message(message); + } + + // Set where REST requests go without (re)connecting the websocket. + static void test_set_rest_api(const std::string &address, const std::string &token) { + std::lock_guard lock_guard(OpenhabManager::_setting_values_mutex); + OpenhabManager::_openhab_address = address; + OpenhabManager::_openhab_token = token; + } +#endif + private: +#if defined(TEST_MODE) && TEST_MODE == 1 + static inline std::mutex _test_sent_messages_mutex; + static inline std::vector _test_sent_messages; +#endif static void _process_openhab_event(nlohmann::json &event_data); // Callback registration for items static inline boost::ptr_map> _openhab_item_observers; diff --git a/docker/MQTTManager/include/room/room.hpp b/docker/MQTTManager/include/room/room.hpp index f602f7c1..359c0237 100644 --- a/docker/MQTTManager/include/room/room.hpp +++ b/docker/MQTTManager/include/room/room.hpp @@ -185,7 +185,7 @@ class Room { boost::signals2::signal _room_changed_callbacks; // Room temperature sensor provider - MQTT_MANAGER_ENTITY_CONTROLLER _room_temp_provider; + MQTT_MANAGER_ENTITY_CONTROLLER _room_temp_provider = MQTT_MANAGER_ENTITY_CONTROLLER::NONE; // Room temperature sensor item name/home assistant entity id std::string _room_temp_sensor; diff --git a/docker/MQTTManager/include/thermostat/home_assistant_thermostat.cpp b/docker/MQTTManager/include/thermostat/home_assistant_thermostat.cpp index 7795a302..ff74e209 100644 --- a/docker/MQTTManager/include/thermostat/home_assistant_thermostat.cpp +++ b/docker/MQTTManager/include/thermostat/home_assistant_thermostat.cpp @@ -279,405 +279,3 @@ void HomeAssistantThermostat::home_assistant_event_callback(nlohmann::json data) } } } - -// TESTING -#if defined(TEST_MODE) && TEST_MODE == 1 -#include - -class HomeAssistantLightTest : public ::testing::Test { -protected: - HomeAssistantLightTest() { - // Initialize any necessary resources or setup for the tests - } - - void SetUp() override { - - // Initialize any necessary resources or setup for the tests - database_manager::Entity light_entity; - light_entity.entity_type = "light"; - light_entity.friendly_name = "Unit test HA light (light)"; - light_entity.room_id = 1; - light_entity.room_view_position = 2; - light_entity.set_entity_data_json({{"can_color_temperature", true}, - {"can_dim", true}, - {"can_rgb", true}, - {"controlled_by_nspanel_main_page", true}, - {"controller", "home_assistant"}, - {"home_assistant_name", "light.office_ceiling_light"}, - {"is_ceiling_light", true}, - {"openhab_control_mode", "dimmer"}, - {"openhab_item_color_temp", ""}, - {"openhab_item_dimmer", ""}, - {"openhab_item_rgb", ""}, - {"openhab_item_switch", ""}, - {"openhab_name", ""}}); - ha_light_id = database_manager::database.insert(light_entity); - // light_entity = database_manager::database.get(ha_light_id); - - SPDLOG_INFO("HA Light created in DB. Creating instance of light object."); - light = new HomeAssistantLight(ha_light_id); - } - - void TearDown() override { - // Clean up any resources or teardown after the tests - - database_manager::database.remove(ha_light_id); - // database_manager::database.remove(ha_light_switch_id); - } - - HomeAssistantLight *light = nullptr; - - int32_t ha_light_id; - int32_t ha_light_switch_id; -}; - -TEST_F(HomeAssistantLightTest, is_off_by_default) { - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); -} - -TEST_F(HomeAssistantLightTest, is_not_on_after_turn_on_in_nonoptimistic_mode) { - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "false"); - light->turn_on(false); - light->set_brightness(50, true); - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); -} - -TEST_F(HomeAssistantLightTest, is_on_after_turn_on_in_optimistic_mode) { - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - light->turn_on(false); - light->set_brightness(50, true); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); -} - -TEST_F(HomeAssistantLightTest, light_reacts_to_state_changes_from_home_assistant) { - // Enable optimistic mode for this test - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - - nlohmann::json event_data = nlohmann::json::parse(R"( - { - - "event":{ - "context":{ - "id":"01K2WNR8WT8VS6B5HTQ7ZEKSAP", - "parent_id":null, - "user_id":"d28bd745ad714c108c27c31a90406189" - }, - "data":{ - "entity_id":"light.office_ceiling_light", - "new_state":{ - "attributes":{ - "brightness":204, - "color_mode":"color_temp", - "color_temp":443, - "color_temp_kelvin":2257, - "effect":null, - "effect_list":[ - "blink", - "breathe", - "okay", - "channel_change", - "finish_effect", - "stop_effect" - ], - "friendly_name":"office_ceiling_light", - "hs_color":[ - 20, - 80 - ], - "max_color_temp_kelvin":4000, - "max_mireds":454, - "min_color_temp_kelvin":2202, - "min_mireds":250, - "rgb_color":[ - 255, - 149, - 46 - ], - "supported_color_modes":[ - "color_temp" - ], - "supported_features":44, - "xy_color":[ - 0.572, - 0.389 - ] - }, - "context":{ - "id":"01K2WNR8WT8VS6B5HTQ7ZEKSAP", - "parent_id":null, - "user_id":"d28bd745ad714c108c27c31a90406189" - }, - "entity_id":"light.office_ceiling_light", - "last_changed":"2025-08-17T18:45:35.203504+00:00", - "last_reported":"2025-08-17T18:48:00.253850+00:00", - "last_updated":"2025-08-17T18:48:00.253850+00:00", - "state":"on" - }, - "old_state": "removed_as_it_is_not_used" - }, - "event_type":"state_changed", - "origin":"LOCAL", - "time_fired":"2025-08-17T18:48:00.253850+00:00" - }, - "id":3, - "type":"event" - - } )"); - - light->home_assistant_event_callback(event_data); - - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 80); // We sent brightness 204, ie. 80% - EXPECT_EQ(light->get_color_temperature(), 2257); - EXPECT_EQ(light->get_color_temperature(), 2257); - - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 153; - event_data["event"]["data"]["new_state"]["attributes"]["color_temp_kelvin"] = 5000; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); // We sent brightness 153, ie. 60% - EXPECT_EQ(light->get_color_temperature(), 5000); - - event_data["event"]["data"]["new_state"]["state"] = "off"; - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 0; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 60); // Remember last brightness value - - // Change to RGB mode - event_data["event"]["data"]["new_state"]["state"] = "on"; - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 153; // 60% - event_data["event"]["data"]["new_state"]["attributes"]["color_mode"] = "xy"; // TODO: Update with correct color mode as sent from HA. - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); - EXPECT_EQ(light->get_hue(), 20); - EXPECT_EQ(light->get_saturation(), 80); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that changes to RGB/HSB values as correctly interpreted - event_data["event"]["data"]["new_state"]["attributes"]["hs_color"][0] = 30; - event_data["event"]["data"]["new_state"]["attributes"]["hs_color"][1] = 60; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); - EXPECT_EQ(light->get_hue(), 30); - EXPECT_EQ(light->get_saturation(), 60); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that we can revert back to color temp mode - event_data["event"]["data"]["new_state"]["state"] = "on"; - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 153; // 60% - event_data["event"]["data"]["new_state"]["attributes"]["color_mode"] = "color_temp"; - event_data["event"]["data"]["new_state"]["attributes"]["color_temp_kelvin"] = 4000; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); - EXPECT_EQ(light->get_color_temperature(), 4000); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); -} - -#define COLOR_TEMP_PERCENT_TO_KELVIN(min, max, kelvin_pct) (((max - min) / 100) * kelvin_pct + min) -TEST_F(HomeAssistantLightTest, verify_nspanel_command_compliance) { - // Enable optimistic mode for this test - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - uint32_t color_temp_min = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); - uint32_t color_temp_max = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX); - // spdlog::set_level(spdlog::level::trace); - - NSPanelMQTTManagerCommand cmd; - cmd.set_nspanel_id(0); - NSPanelMQTTManagerCommand_LightCommand *light_cmd = cmd.mutable_light_command(); - std::vector light_ids = {light->get_id()}; - light_cmd->add_light_ids(light->get_id()); - light_cmd->set_brightness(50); - light_cmd->set_has_brightness(true); - light_cmd->set_color_temperature(50); - light_cmd->set_has_color_temperature(true); - light_cmd->set_has_hue(false); - light_cmd->set_has_saturation(false); - - light->command_callback(cmd); - - // Check that light turns on, sets correct brightness and color temperature. - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 50)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - // Verify that only color temperature can be change. - light_cmd->set_has_brightness(false); - light_cmd->set_color_temperature(70); - light_cmd->set_has_color_temperature(true); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - // Verify that only brightness can be change. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(true); - light_cmd->set_brightness(70); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - // Verify that hue and saturation can be changed and the lights goes into RGB mode. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(false); - light_cmd->set_hue(30); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(60); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 30); - EXPECT_EQ(light->get_saturation(), 60); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that only hue can be changed. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(false); - light_cmd->set_hue(40); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(60); - light_cmd->set_has_saturation(false); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 40); - EXPECT_EQ(light->get_saturation(), 60); // Verify that value is unchanged. - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that only saturation can be changed. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(false); - light_cmd->set_hue(40); - light_cmd->set_has_hue(false); - light_cmd->set_saturation(70); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 40); // Verify that value is unchanged. - EXPECT_EQ(light->get_saturation(), 70); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that brightness can be changed and RGB is kept. - light_cmd->set_has_color_temperature(false); - light_cmd->set_brightness(100); - light_cmd->set_has_brightness(true); - light_cmd->set_hue(40); - light_cmd->set_has_hue(false); - light_cmd->set_saturation(70); - light_cmd->set_has_saturation(false); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 100); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 40); // Verify that value is unchanged. - EXPECT_EQ(light->get_saturation(), 70); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); -} - -TEST_F(HomeAssistantLightTest, verify_optimistic_mode_compliance) { - // Enable optimistic mode for this test - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - uint32_t color_temp_min = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); - uint32_t color_temp_max = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX); - // spdlog::set_level(spdlog::level::trace); - - NSPanelMQTTManagerCommand cmd; - cmd.set_nspanel_id(0); - NSPanelMQTTManagerCommand_LightCommand *light_cmd = cmd.mutable_light_command(); - std::vector light_ids = {light->get_id()}; - light_cmd->add_light_ids(light->get_id()); - light_cmd->set_brightness(50); - light_cmd->set_has_brightness(true); - light_cmd->set_color_temperature(50); - light_cmd->set_has_color_temperature(true); - light_cmd->set_has_hue(false); - light_cmd->set_has_saturation(false); - - light->command_callback(cmd); - - // Check that light turns on, sets correct brightness and color temperature. - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 50)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - light_cmd->set_has_brightness(false); - light_cmd->set_has_color_temperature(false); - light_cmd->set_hue(30); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(50); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - - EXPECT_EQ(light->get_state(), true); // Verify light is still on - EXPECT_EQ(light->get_brightness(), 50); // Verify light is still set to 50% brightness - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 50)); // Verify color temp has not changed. - EXPECT_EQ(light->get_hue(), 30); - EXPECT_EQ(light->get_saturation(), 50); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); // Light should have switched to RGB mode. -} - -TEST_F(HomeAssistantLightTest, verify_non_optimistic_mode_compliance) { - // Turn off optimistic mode and verify that no values change. - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "false"); - uint32_t color_temp_min = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); - uint32_t color_temp_max = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX); - // spdlog::set_level(spdlog::level::trace); - - NSPanelMQTTManagerCommand cmd; - cmd.set_nspanel_id(0); - NSPanelMQTTManagerCommand_LightCommand *light_cmd = cmd.mutable_light_command(); - std::vector light_ids = {light->get_id()}; - light_cmd->add_light_ids(light->get_id()); - light_cmd->set_brightness(50); - light_cmd->set_has_brightness(true); - light_cmd->set_color_temperature(50); - light_cmd->set_has_color_temperature(true); - light_cmd->set_has_hue(false); - light_cmd->set_has_saturation(false); - light->command_callback(cmd); - - // Verify light is still off, brightness is still 0 and hue and saturation did not change. - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); - EXPECT_EQ(light->get_color_temperature(), 0); - EXPECT_EQ(light->get_hue(), 0); - EXPECT_EQ(light->get_saturation(), 0); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); // Light should have switched to RGB mode. - - light_cmd->set_has_brightness(false); - light_cmd->set_has_color_temperature(false); - light_cmd->set_hue(30); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(30); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - - // Verify light is still off, brightness is still 0 and hue and saturation did not change. - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); - EXPECT_EQ(light->get_color_temperature(), 0); - EXPECT_EQ(light->get_hue(), 0); - EXPECT_EQ(light->get_saturation(), 0); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); // Light should have switched to RGB mode. -} - -#endif // !MQTT_MANAGER_HOME_ASSISTANT_LIGHT_TEST diff --git a/docker/MQTTManager/include/thermostat/openhab_thermostat.cpp b/docker/MQTTManager/include/thermostat/openhab_thermostat.cpp index 85ac4035..8e35a5bc 100644 --- a/docker/MQTTManager/include/thermostat/openhab_thermostat.cpp +++ b/docker/MQTTManager/include/thermostat/openhab_thermostat.cpp @@ -20,6 +20,13 @@ uint64_t CurrentTimeMilliseconds() { } OpenhabThermostat::OpenhabThermostat(uint32_t thermostat_id) : ThermostatEntity(thermostat_id) { + this->_last_target_temperature_change = 0; + this->_last_fan_mode_change = 0; + this->_last_mode_change = 0; + this->_last_preset_change = 0; + this->_last_swing_change = 0; + this->_last_swingh_change = 0; + // Process Home Assistant specific details. General thermostat data is loaded in the "ThermostatEntity" constructor. if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::OPENHAB) { SPDLOG_ERROR("OpenhabThermostat has not been recognized as controlled by OPENHAB. Will stop processing thermostat."); @@ -747,405 +754,3 @@ void OpenhabThermostat::openhab_swingh_event_callback(nlohmann::json data) { } } } - -// TESTING -#if defined(TEST_MODE) && TEST_MODE == 1 -#include - -class HomeAssistantLightTest : public ::testing::Test { -protected: - HomeAssistantLightTest() { - // Initialize any necessary resources or setup for the tests - } - - void SetUp() override { - - // Initialize any necessary resources or setup for the tests - database_manager::Entity light_entity; - light_entity.entity_type = "light"; - light_entity.friendly_name = "Unit test HA light (light)"; - light_entity.room_id = 1; - light_entity.room_view_position = 2; - light_entity.set_entity_data_json({{"can_color_temperature", true}, - {"can_dim", true}, - {"can_rgb", true}, - {"controlled_by_nspanel_main_page", true}, - {"controller", "home_assistant"}, - {"home_assistant_name", "light.office_ceiling_light"}, - {"is_ceiling_light", true}, - {"openhab_control_mode", "dimmer"}, - {"openhab_item_color_temp", ""}, - {"openhab_item_dimmer", ""}, - {"openhab_item_rgb", ""}, - {"openhab_item_switch", ""}, - {"openhab_name", ""}}); - ha_light_id = database_manager::database.insert(light_entity); - // light_entity = database_manager::database.get(ha_light_id); - - SPDLOG_INFO("HA Light created in DB. Creating instance of light object."); - light = new HomeAssistantLight(ha_light_id); - } - - void TearDown() override { - // Clean up any resources or teardown after the tests - - database_manager::database.remove(ha_light_id); - // database_manager::database.remove(ha_light_switch_id); - } - - HomeAssistantLight *light = nullptr; - - int32_t ha_light_id; - int32_t ha_light_switch_id; -}; - -TEST_F(HomeAssistantLightTest, is_off_by_default) { - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); -} - -TEST_F(HomeAssistantLightTest, is_not_on_after_turn_on_in_nonoptimistic_mode) { - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "false"); - light->turn_on(false); - light->set_brightness(50, true); - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); -} - -TEST_F(HomeAssistantLightTest, is_on_after_turn_on_in_optimistic_mode) { - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - light->turn_on(false); - light->set_brightness(50, true); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); -} - -TEST_F(HomeAssistantLightTest, light_reacts_to_state_changes_from_home_assistant) { - // Enable optimistic mode for this test - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - - nlohmann::json event_data = nlohmann::json::parse(R"( - { - - "event":{ - "context":{ - "id":"01K2WNR8WT8VS6B5HTQ7ZEKSAP", - "parent_id":null, - "user_id":"d28bd745ad714c108c27c31a90406189" - }, - "data":{ - "entity_id":"light.office_ceiling_light", - "new_state":{ - "attributes":{ - "brightness":204, - "color_mode":"color_temp", - "color_temp":443, - "color_temp_kelvin":2257, - "effect":null, - "effect_list":[ - "blink", - "breathe", - "okay", - "channel_change", - "finish_effect", - "stop_effect" - ], - "friendly_name":"office_ceiling_light", - "hs_color":[ - 20, - 80 - ], - "max_color_temp_kelvin":4000, - "max_mireds":454, - "min_color_temp_kelvin":2202, - "min_mireds":250, - "rgb_color":[ - 255, - 149, - 46 - ], - "supported_color_modes":[ - "color_temp" - ], - "supported_features":44, - "xy_color":[ - 0.572, - 0.389 - ] - }, - "context":{ - "id":"01K2WNR8WT8VS6B5HTQ7ZEKSAP", - "parent_id":null, - "user_id":"d28bd745ad714c108c27c31a90406189" - }, - "entity_id":"light.office_ceiling_light", - "last_changed":"2025-08-17T18:45:35.203504+00:00", - "last_reported":"2025-08-17T18:48:00.253850+00:00", - "last_updated":"2025-08-17T18:48:00.253850+00:00", - "state":"on" - }, - "old_state": "removed_as_it_is_not_used" - }, - "event_type":"state_changed", - "origin":"LOCAL", - "time_fired":"2025-08-17T18:48:00.253850+00:00" - }, - "id":3, - "type":"event" - - } )"); - - light->home_assistant_event_callback(event_data); - - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 80); // We sent brightness 204, ie. 80% - EXPECT_EQ(light->get_color_temperature(), 2257); - EXPECT_EQ(light->get_color_temperature(), 2257); - - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 153; - event_data["event"]["data"]["new_state"]["attributes"]["color_temp_kelvin"] = 5000; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); // We sent brightness 153, ie. 60% - EXPECT_EQ(light->get_color_temperature(), 5000); - - event_data["event"]["data"]["new_state"]["state"] = "off"; - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 0; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 60); // Remember last brightness value - - // Change to RGB mode - event_data["event"]["data"]["new_state"]["state"] = "on"; - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 153; // 60% - event_data["event"]["data"]["new_state"]["attributes"]["color_mode"] = "xy"; // TODO: Update with correct color mode as sent from HA. - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); - EXPECT_EQ(light->get_hue(), 20); - EXPECT_EQ(light->get_saturation(), 80); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that changes to RGB/HSB values as correctly interpreted - event_data["event"]["data"]["new_state"]["attributes"]["hs_color"][0] = 30; - event_data["event"]["data"]["new_state"]["attributes"]["hs_color"][1] = 60; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); - EXPECT_EQ(light->get_hue(), 30); - EXPECT_EQ(light->get_saturation(), 60); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that we can revert back to color temp mode - event_data["event"]["data"]["new_state"]["state"] = "on"; - event_data["event"]["data"]["new_state"]["attributes"]["brightness"] = 153; // 60% - event_data["event"]["data"]["new_state"]["attributes"]["color_mode"] = "color_temp"; - event_data["event"]["data"]["new_state"]["attributes"]["color_temp_kelvin"] = 4000; - light->home_assistant_event_callback(event_data); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 60); - EXPECT_EQ(light->get_color_temperature(), 4000); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); -} - -#define COLOR_TEMP_PERCENT_TO_KELVIN(min, max, kelvin_pct) (((max - min) / 100) * kelvin_pct + min) -TEST_F(HomeAssistantLightTest, verify_nspanel_command_compliance) { - // Enable optimistic mode for this test - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - uint32_t color_temp_min = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); - uint32_t color_temp_max = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX); - // spdlog::set_level(spdlog::level::trace); - - NSPanelMQTTManagerCommand cmd; - cmd.set_nspanel_id(0); - NSPanelMQTTManagerCommand_LightCommand *light_cmd = cmd.mutable_light_command(); - std::vector light_ids = {light->get_id()}; - light_cmd->add_light_ids(light->get_id()); - light_cmd->set_brightness(50); - light_cmd->set_has_brightness(true); - light_cmd->set_color_temperature(50); - light_cmd->set_has_color_temperature(true); - light_cmd->set_has_hue(false); - light_cmd->set_has_saturation(false); - - light->command_callback(cmd); - - // Check that light turns on, sets correct brightness and color temperature. - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 50)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - // Verify that only color temperature can be change. - light_cmd->set_has_brightness(false); - light_cmd->set_color_temperature(70); - light_cmd->set_has_color_temperature(true); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - // Verify that only brightness can be change. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(true); - light_cmd->set_brightness(70); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - // Verify that hue and saturation can be changed and the lights goes into RGB mode. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(false); - light_cmd->set_hue(30); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(60); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 30); - EXPECT_EQ(light->get_saturation(), 60); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that only hue can be changed. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(false); - light_cmd->set_hue(40); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(60); - light_cmd->set_has_saturation(false); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 40); - EXPECT_EQ(light->get_saturation(), 60); // Verify that value is unchanged. - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that only saturation can be changed. - light_cmd->set_has_color_temperature(false); - light_cmd->set_has_brightness(false); - light_cmd->set_hue(40); - light_cmd->set_has_hue(false); - light_cmd->set_saturation(70); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 70); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 40); // Verify that value is unchanged. - EXPECT_EQ(light->get_saturation(), 70); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - // Verify that brightness can be changed and RGB is kept. - light_cmd->set_has_color_temperature(false); - light_cmd->set_brightness(100); - light_cmd->set_has_brightness(true); - light_cmd->set_hue(40); - light_cmd->set_has_hue(false); - light_cmd->set_saturation(70); - light_cmd->set_has_saturation(false); - light->command_callback(cmd); - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 100); // Verify that value is unchanged. - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 70)); // Verify that value is unchanged. - EXPECT_EQ(light->get_hue(), 40); // Verify that value is unchanged. - EXPECT_EQ(light->get_saturation(), 70); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); - - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); -} - -TEST_F(HomeAssistantLightTest, verify_optimistic_mode_compliance) { - // Enable optimistic mode for this test - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "true"); - uint32_t color_temp_min = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); - uint32_t color_temp_max = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX); - // spdlog::set_level(spdlog::level::trace); - - NSPanelMQTTManagerCommand cmd; - cmd.set_nspanel_id(0); - NSPanelMQTTManagerCommand_LightCommand *light_cmd = cmd.mutable_light_command(); - std::vector light_ids = {light->get_id()}; - light_cmd->add_light_ids(light->get_id()); - light_cmd->set_brightness(50); - light_cmd->set_has_brightness(true); - light_cmd->set_color_temperature(50); - light_cmd->set_has_color_temperature(true); - light_cmd->set_has_hue(false); - light_cmd->set_has_saturation(false); - - light->command_callback(cmd); - - // Check that light turns on, sets correct brightness and color temperature. - EXPECT_EQ(light->get_state(), true); - EXPECT_EQ(light->get_brightness(), 50); - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 50)); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); - - light_cmd->set_has_brightness(false); - light_cmd->set_has_color_temperature(false); - light_cmd->set_hue(30); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(50); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - - EXPECT_EQ(light->get_state(), true); // Verify light is still on - EXPECT_EQ(light->get_brightness(), 50); // Verify light is still set to 50% brightness - EXPECT_EQ(light->get_color_temperature(), COLOR_TEMP_PERCENT_TO_KELVIN(color_temp_min, color_temp_max, 50)); // Verify color temp has not changed. - EXPECT_EQ(light->get_hue(), 30); - EXPECT_EQ(light->get_saturation(), 50); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::RGB); // Light should have switched to RGB mode. -} - -TEST_F(HomeAssistantLightTest, verify_non_optimistic_mode_compliance) { - // Turn off optimistic mode and verify that no values change. - MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE, "false"); - uint32_t color_temp_min = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); - uint32_t color_temp_max = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX); - // spdlog::set_level(spdlog::level::trace); - - NSPanelMQTTManagerCommand cmd; - cmd.set_nspanel_id(0); - NSPanelMQTTManagerCommand_LightCommand *light_cmd = cmd.mutable_light_command(); - std::vector light_ids = {light->get_id()}; - light_cmd->add_light_ids(light->get_id()); - light_cmd->set_brightness(50); - light_cmd->set_has_brightness(true); - light_cmd->set_color_temperature(50); - light_cmd->set_has_color_temperature(true); - light_cmd->set_has_hue(false); - light_cmd->set_has_saturation(false); - light->command_callback(cmd); - - // Verify light is still off, brightness is still 0 and hue and saturation did not change. - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); - EXPECT_EQ(light->get_color_temperature(), 0); - EXPECT_EQ(light->get_hue(), 0); - EXPECT_EQ(light->get_saturation(), 0); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); // Light should have switched to RGB mode. - - light_cmd->set_has_brightness(false); - light_cmd->set_has_color_temperature(false); - light_cmd->set_hue(30); - light_cmd->set_has_hue(true); - light_cmd->set_saturation(30); - light_cmd->set_has_saturation(true); - light->command_callback(cmd); - - // Verify light is still off, brightness is still 0 and hue and saturation did not change. - EXPECT_EQ(light->get_state(), false); - EXPECT_EQ(light->get_brightness(), 0); - EXPECT_EQ(light->get_color_temperature(), 0); - EXPECT_EQ(light->get_hue(), 0); - EXPECT_EQ(light->get_saturation(), 0); - EXPECT_EQ(light->get_mode(), MQTT_MANAGER_LIGHT_MODE::DEFAULT); // Light should have switched to RGB mode. -} - -#endif // !MQTT_MANAGER_HOME_ASSISTANT_LIGHT_TEST diff --git a/docker/MQTTManager/run_tests.sh b/docker/MQTTManager/run_tests.sh new file mode 100755 index 00000000..c4332655 --- /dev/null +++ b/docker/MQTTManager/run_tests.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Build and run the MQTTManager unit tests in Docker. +# +# ./run_tests.sh # everything +# ./run_tests.sh --gtest_filter='LightConfig.*' # extra arguments go to the test binary +# +# Output is also written to tests/last_run.log. The first run builds all Conan dependencies +# and takes a while; later runs reuse the nspm-mqttmanager-conan and -build Docker volumes. +# To start from scratch: docker volume rm nspm-mqttmanager-conan nspm-mqttmanager-build +# +# NSPM_TEST_CONAN_CACHE and NSPM_TEST_BUILD_DIR override those volumes with another volume name +# or an absolute host path (CI uses a host directory so it can cache it between runs). +# NSPM_TEST_RESULTS_DIR, an absolute host path, also writes JUnit XML results there. +cd "$(dirname "$0")" || exit 1 + +IMAGE=nspm-mqttmanager-tests +LOG=tests/last_run.log +CONAN_CACHE=${NSPM_TEST_CONAN_CACHE:-nspm-mqttmanager-conan} +BUILD_DIR=${NSPM_TEST_BUILD_DIR:-nspm-mqttmanager-build} + +RESULTS_MOUNT=() +if [ -n "${NSPM_TEST_RESULTS_DIR:-}" ]; then + mkdir -p "$NSPM_TEST_RESULTS_DIR" + RESULTS_MOUNT=(-v "$NSPM_TEST_RESULTS_DIR":/results) + set -- --gtest_output=xml:/results/mqttmanager-tests.xml "$@" +fi + +{ + docker build -q -t "$IMAGE" tests/docker && + docker run --rm \ + -v "$PWD":/src:ro \ + -v "$CONAN_CACHE":/root/.conan2/p \ + -v "$BUILD_DIR":/MQTTManager/build \ + "${RESULTS_MOUNT[@]}" \ + "$IMAGE" bash /src/tests/docker/run_in_container.sh "$@" +} 2>&1 | tee "$LOG" +status=${PIPESTATUS[0]} + +# When run with sudo, hand the log back to the invoking user. +if [ -n "${SUDO_UID:-}" ]; then + chown "$SUDO_UID:$SUDO_GID" "$LOG" +fi +exit "$status" diff --git a/docker/MQTTManager/tests/CMakeLists.txt b/docker/MQTTManager/tests/CMakeLists.txt new file mode 100644 index 00000000..b649bf8b --- /dev/null +++ b/docker/MQTTManager/tests/CMakeLists.txt @@ -0,0 +1,39 @@ +# Unit tests for MQTTManager. Built when NSPM_BUILD_TESTS is ON. +# +# Tests live in this directory (*_test.cpp). The older inline tests inside the library +# sources (wrapped in `#if TEST_MODE == 1`) are linked in too, so this one executable +# runs everything. + +file(GLOB NSPM_TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*_test.cpp) + +add_executable(nspm_mqttmanager_tests main.cpp ${NSPM_TEST_SOURCES}) +target_include_directories(nspm_mqttmanager_tests PRIVATE ${CMAKE_INCLUDE_SRC_DIRECTORY} ${CMAKE_CURRENT_SOURCE_DIR}) + +# Libraries containing inline tests. They are static libraries, and the linker only pulls in +# object files something references, so they are linked whole to make sure every inline test +# registers with GoogleTest. (CMake's WHOLE_ARCHIVE link feature cannot be used because the +# same libraries are also linked transitively. This is a single call because +# target_link_options drops repeated options, which would unwrap every library after the first.) +set(NSPM_LIBRARIES_WITH_INLINE_TESTS + MQTTManager_Config + MQTTManager_Light +) +set(NSPM_INLINE_TEST_ARCHIVES) +foreach(library ${NSPM_LIBRARIES_WITH_INLINE_TESTS}) + list(APPEND NSPM_INLINE_TEST_ARCHIVES "$") +endforeach() +target_link_options(nspm_mqttmanager_tests PRIVATE "LINKER:--whole-archive" ${NSPM_INLINE_TEST_ARCHIVES} "LINKER:--no-whole-archive") + +# Export the executable's symbols so the crash handler in main.cpp can name the functions in a backtrace. +target_link_options(nspm_mqttmanager_tests PRIVATE -rdynamic) + +target_link_libraries(nspm_mqttmanager_tests PRIVATE + ${NSPM_LIBRARIES_WITH_INLINE_TESTS} + MQTTManager_DatabaseManager MQTTManager_Entity + MQTT_Manager MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager MQTTManager_EntityManager + MQTTManager_Scene MQTTManager_Room MQTTManager_WebsocketServer MQTTManager_CommandManager + MQTTManager_Switch MQTTManager_Button MQTTManager_Thermostat MQTTManager_MediaPlayer MQTTManager_NSPanel + ixwebsocket::ixwebsocket gtest::gtest +) + +gtest_discover_tests(nspm_mqttmanager_tests DISCOVERY_TIMEOUT 60) diff --git a/docker/MQTTManager/tests/button_test.cpp b/docker/MQTTManager/tests/button_test.cpp new file mode 100644 index 00000000..956103c8 --- /dev/null +++ b/docker/MQTTManager/tests/button_test.cpp @@ -0,0 +1,111 @@ +#include "test_helpers.hpp" +#include