diff --git a/docker/MQTTManager/include/nspanel/nspanel.cpp b/docker/MQTTManager/include/nspanel/nspanel.cpp index 2017c7b3..f0d0ea09 100644 --- a/docker/MQTTManager/include/nspanel/nspanel.cpp +++ b/docker/MQTTManager/include/nspanel/nspanel.cpp @@ -226,10 +226,13 @@ void NSPanel::reload_config() { this->_mqtt_number_screen_brightness_topic = fmt::format("homeassistant/number/nspanelmanager/{}_screen_brightness/config", mqtt_register_mac); this->_mqtt_number_screensaver_brightness_topic = fmt::format("homeassistant/number/nspanelmanager/{}_screensaver_brightness/config", mqtt_register_mac); this->_mqtt_select_screensaver_topic = fmt::format("homeassistant/select/nspanelmanager/{}_screensaver_select/config", mqtt_register_mac); + this->_mqtt_switch_buzzer_mute_topic = fmt::format("homeassistant/switch/nspanelmanager/{}_buzzer_mute/config", mqtt_register_mac); this->_mqtt_relay1_command_topic = fmt::format("nspanel/{}/relay1_cmd", this->_mac); this->_mqtt_relay1_state_topic = fmt::format("nspanel/{}/relay1_state", this->_mac); this->_mqtt_relay2_command_topic = fmt::format("nspanel/{}/relay2_cmd", this->_mac); this->_mqtt_relay2_state_topic = fmt::format("nspanel/{}/relay2_state", this->_mac); + this->_mqtt_buzzer_mute_command_topic = fmt::format("nspanel/{}/buzzer_mute_cmd", this->_mac); + this->_mqtt_buzzer_mute_state_topic = fmt::format("nspanel/{}/buzzer_mute_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); @@ -256,6 +259,7 @@ void NSPanel::reload_config() { 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)); + MQTT_Manager::subscribe(this->_mqtt_buzzer_mute_command_topic, boost::bind(&NSPanel::mqtt_callback, this, _1, _2)); } if (name_changed || reregister_to_ha_mqtt_discovery) { @@ -482,6 +486,7 @@ void NSPanel::send_config() { } // Load buzzer sounds. A panel setting overrides the global one, an empty value means no sound for that event. + // While the buzzer is muted no sounds are sent, which silences every event. const std::vector> sound_settings = { {NSPanelConfig_NSPanelSoundEvent_TOUCH, "buzzer_sound_touch", MQTT_MANAGER_SETTING::BUZZER_SOUND_TOUCH}, {NSPanelConfig_NSPanelSoundEvent_SCENE_ACTIVATED, "buzzer_sound_scene_activated", MQTT_MANAGER_SETTING::BUZZER_SOUND_SCENE_ACTIVATED}, @@ -489,7 +494,7 @@ void NSPanel::send_config() { }; for (const auto &[event, nspanel_setting_key, global_setting] : sound_settings) { std::string rtttl = this->_get_nspanel_setting_with_default(nspanel_setting_key, MqttManagerConfig::get_setting_with_default(global_setting)); - if (!rtttl.empty()) { + if (!rtttl.empty() && !this->_buzzer_muted) { NSPanelConfig_Sound *sound = config.add_sounds(); sound->set_event(event); sound->set_rtttl(rtttl); @@ -518,6 +523,7 @@ void NSPanel::reset_mqtt_topics() { 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)); + MQTT_Manager::detach_callback(this->_mqtt_buzzer_mute_command_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); @@ -535,12 +541,16 @@ void NSPanel::reset_ha_mqtt_topics() { MQTT_Manager::clear_retain(this->_mqtt_number_screen_brightness_topic); MQTT_Manager::clear_retain(this->_mqtt_number_screensaver_brightness_topic); MQTT_Manager::clear_retain(this->_mqtt_select_screensaver_topic); + MQTT_Manager::clear_retain(this->_mqtt_switch_buzzer_mute_topic); } void NSPanel::erase() { this->reboot(); this->reset_mqtt_topics(); this->reset_ha_mqtt_topics(); + // Not cleared in reset_mqtt_topics() as that runs on every config reload, and the retained command is what remembers the mute. + MQTT_Manager::clear_retain(this->_mqtt_buzzer_mute_command_topic); + MQTT_Manager::clear_retain(this->_mqtt_buzzer_mute_state_topic); } uint NSPanel::get_id() { @@ -565,7 +575,19 @@ void NSPanel::mqtt_callback(std::string topic, std::string payload) { } try { - if (topic.compare(this->_mqtt_log_topic) == 0) { + if (topic.compare(this->_mqtt_buzzer_mute_command_topic) == 0) { + if (payload.compare("1") != 0 && payload.compare("0") != 0) { + SPDLOG_ERROR("Received unknown buzzer mute command '{}' for NSPanel {}::{}. Expected '1' or '0'.", payload, this->_id, this->_name); + return; + } + bool muted = payload.compare("1") == 0; + bool changed = this->_buzzer_muted.exchange(muted) != muted; + MQTT_Manager::publish(this->_mqtt_buzzer_mute_state_topic, payload, true); + if (changed) { + SPDLOG_INFO("NSPanel {}::{} buzzer {}. Sending config.", this->_id, this->_name, muted ? "muted" : "unmuted"); + this->send_config(); + } + } else if (topic.compare(this->_mqtt_log_topic) == 0) { // Split log message by semicolon to extract MAC, log level and message. std::string message = payload; std::vector message_parts; @@ -1591,6 +1613,25 @@ void NSPanel::register_to_home_assistant() { std::string screensaver_select_data_str = screensaver_select_data.dump(); SPDLOG_DEBUG("Registring screensaver mode select for NSPanel {}::{} to Home Assistant.", this->_id, this->_name); MQTT_Manager::publish(this->_mqtt_select_screensaver_topic, screensaver_select_data_str, true); + + // Register buzzer mute switch. Only the Sonoff panel has a buzzer. + if (this->_model == MQTT_MANAGER_NSPANEL_MODEL::SONOFF) { + nlohmann::json buzzer_mute_data = nlohmann::json(base_json); + buzzer_mute_data["device_class"] = "switch"; + buzzer_mute_data["name"] = "Mute sounds"; + buzzer_mute_data["icon"] = "mdi:volume-off"; + buzzer_mute_data["state_topic"] = this->_mqtt_buzzer_mute_state_topic; + buzzer_mute_data["command_topic"] = this->_mqtt_buzzer_mute_command_topic; + buzzer_mute_data["state_on"] = "1"; + buzzer_mute_data["state_off"] = "0"; + buzzer_mute_data["payload_on"] = "1"; + buzzer_mute_data["payload_off"] = "0"; + buzzer_mute_data["retain"] = true; // The retained command is what remembers the mute across manager restarts. + buzzer_mute_data["unique_id"] = fmt::format("{}_buzzer_mute", this->_mqtt_register_mac); + std::string buzzer_mute_data_str = buzzer_mute_data.dump(); + SPDLOG_DEBUG("Registring buzzer mute switch for NSPanel {}::{} to Home Assistant.", this->_id, this->_name); + MQTT_Manager::publish(this->_mqtt_switch_buzzer_mute_topic, buzzer_mute_data_str, true); + } } void NSPanel::set_relay_state(uint8_t relay, bool state) { diff --git a/docker/MQTTManager/include/nspanel/nspanel.hpp b/docker/MQTTManager/include/nspanel/nspanel.hpp index 331a03bd..79341946 100644 --- a/docker/MQTTManager/include/nspanel/nspanel.hpp +++ b/docker/MQTTManager/include/nspanel/nspanel.hpp @@ -2,6 +2,7 @@ #define MQTT_MANAGER_NSPANEL #include "protobuf_nspanel.pb.h" #include "websocket_server/websocket_server.hpp" +#include #include #include #include @@ -236,6 +237,12 @@ class NSPanel { std::string _mqtt_relay2_state_topic; // Wether or not relay2 is on bool _relay2_state = false; + // The topic Home Assistant sends the buzzer mute command to + std::string _mqtt_buzzer_mute_command_topic; + // The topic where the buzzer mute state is published + std::string _mqtt_buzzer_mute_state_topic; + // Wether or not the buzzer is muted. While muted, no buzzer sounds are sent in the config. + std::atomic _buzzer_muted = false; // The topic to capture logs from MQTT std::string _mqtt_log_topic; // The topic to capture status (online/offline) from MQTT @@ -268,6 +275,7 @@ class NSPanel { std::string _mqtt_number_screen_brightness_topic; std::string _mqtt_number_screensaver_brightness_topic; std::string _mqtt_select_screensaver_topic; + std::string _mqtt_switch_buzzer_mute_topic; // Topic where to send the NSPanelHomePageStatus protobuf state updates std::string _mqtt_topic_home_page_status; diff --git a/docker/MQTTManager/tests/nspanel_test.cpp b/docker/MQTTManager/tests/nspanel_test.cpp index a501772f..150514ee 100644 --- a/docker/MQTTManager/tests/nspanel_test.cpp +++ b/docker/MQTTManager/tests/nspanel_test.cpp @@ -462,6 +462,95 @@ TEST_F(NSPanelTest, panel_buzzer_settings_override_the_global_sounds) { EXPECT_EQ(config->sounds(1).rtttl(), "panel_saved:d=8,o=5,b=120:e"); } +TEST_F(NSPanelTest, muting_the_buzzer_sends_no_sounds_until_unmuted) { + OnExit reset{[] { + MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::BUZZER_SOUND_TOUCH, ""); + }}; + MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::BUZZER_SOUND_TOUCH, "touch:d=32,o=6,b=190:g6"); + load_panel(); + last_published_config(); + + panel->mqtt_callback("nspanel/" + PANEL_MAC + "/buzzer_mute_cmd", "1"); + + auto messages = MQTT_Manager::test_take_published_messages(); + auto state = std::find_if(messages.begin(), messages.end(), [](auto &message) { return message.topic == "nspanel/" + PANEL_MAC + "/buzzer_mute_state"; }); + ASSERT_NE(state, messages.end()); + EXPECT_EQ(state->payload, "1"); + EXPECT_TRUE(state->retain); + auto config = std::find_if(messages.begin(), messages.end(), [](auto &message) { return message.topic == "nspanel/" + PANEL_MAC + "/config"; }); + ASSERT_NE(config, messages.end()); + NSPanelConfig muted_config; + ASSERT_TRUE(muted_config.ParseFromString(config->payload)); + EXPECT_EQ(muted_config.sounds_size(), 0); + + panel->mqtt_callback("nspanel/" + PANEL_MAC + "/buzzer_mute_cmd", "0"); + + auto unmuted_config = last_published_config(); + ASSERT_TRUE(unmuted_config.has_value()); + ASSERT_EQ(unmuted_config->sounds_size(), 1); + EXPECT_EQ(unmuted_config->sounds(0).rtttl(), "touch:d=32,o=6,b=190:g6"); +} + +TEST_F(NSPanelTest, buzzer_mute_is_kept_across_config_reloads) { + OnExit reset{[] { + MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::BUZZER_SOUND_TOUCH, ""); + }}; + MqttManagerConfig::set_setting_value(MQTT_MANAGER_SETTING::BUZZER_SOUND_TOUCH, "touch:d=32,o=6,b=190:g6"); + load_panel(); + panel->mqtt_callback("nspanel/" + PANEL_MAC + "/buzzer_mute_cmd", "1"); + last_published_config(); + + panel->reload_config(); + + auto config = last_published_config(); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->sounds_size(), 0); +} + +TEST_F(NSPanelTest, unchanged_buzzer_mute_does_not_resend_the_config) { + load_panel(); + panel->mqtt_callback("nspanel/" + PANEL_MAC + "/buzzer_mute_cmd", "1"); + last_published_config(); + + // The broker replays the retained command on every resubscribe. + panel->mqtt_callback("nspanel/" + PANEL_MAC + "/buzzer_mute_cmd", "1"); + + EXPECT_FALSE(last_published_config().has_value()); +} + +TEST_F(NSPanelTest, unknown_buzzer_mute_command_is_ignored) { + load_panel(); + last_published_config(); + ScopedErrorLog log; + + panel->mqtt_callback("nspanel/" + PANEL_MAC + "/buzzer_mute_cmd", "ON"); + + EXPECT_FALSE(last_published_config().has_value()); + auto errors = log.errors(); + EXPECT_TRUE(std::any_of(errors.begin(), errors.end(), [](auto &error) { return error.find("buzzer mute") != std::string::npos; })); +} + +TEST_F(NSPanelTest, sonoff_panel_registers_a_retained_buzzer_mute_switch) { + load_panel(); + + auto messages = nspm_test::mqtt_published_to("homeassistant/switch/nspanelmanager/c0_49_ef_00_01_01_buzzer_mute/config"); + ASSERT_FALSE(messages.empty()); + auto discovery = nlohmann::json::parse(messages.back().payload); + EXPECT_EQ(discovery["command_topic"], "nspanel/" + PANEL_MAC + "/buzzer_mute_cmd"); + EXPECT_EQ(discovery["state_topic"], "nspanel/" + PANEL_MAC + "/buzzer_mute_state"); + EXPECT_EQ(discovery["retain"], true); +} + +TEST_F(NSPanelTest, custom_panel_does_not_register_a_buzzer_mute_switch) { + update_row([](auto &settings) { + settings.model = "custom"; + }); + + load_panel(); + + EXPECT_TRUE(nspm_test::mqtt_published_to("homeassistant/switch/nspanelmanager/c0_49_ef_00_01_01_buzzer_mute/config").empty()); +} + TEST_F(NSPanelTest, reload_config_sends_changed_settings) { load_panel(); last_published_config();