From ae11dc149d7f1a783b49d527a66fb460c2eba8cf Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 16 Oct 2026 15:46:21 -0700 Subject: [PATCH] Add a buzzer mute switch to Home Assistant discovery Each Sonoff panel gets a "Mute sounds" switch. While it is on, the manager sends the panel's config without any buzzer sounds, which silences every event; turning it off sends the configured sounds again. No protocol or firmware change is needed. The switch is registered with retain, so the broker keeps the last command and the mute survives manager restarts. The retained command is only cleared when the panel is removed. Co-Authored-By: Claude Opus 5.5 --- .../MQTTManager/include/nspanel/nspanel.cpp | 45 +++++++++- .../MQTTManager/include/nspanel/nspanel.hpp | 8 ++ docker/MQTTManager/tests/nspanel_test.cpp | 89 +++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) 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();