From d43189cb076b74ce0085c084adf414ffef726df8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 12:14:19 -0600 Subject: [PATCH 1/9] Update esphome/components/camera/camera.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/camera/camera.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index a4f33de00b..9f46eb0c43 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -102,7 +102,7 @@ struct CameraImageSpec { }; /** Abstract camera base class. Collaborates with API. - * 1) API server starts and registers as a listener (add_image_listener) + * 1) API server starts and registers as a listener (add_listener) * to receive new images from the camera. * 2) New API client connects and creates a new image reader (create_image_reader). * 3) API connection receives protobuf CameraImageRequest and calls request_image. From e8bc19a07d32124aafe2d36ebce0855e8ac046b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:11:23 -0600 Subject: [PATCH 2/9] [alarm_control_panel] Replace callbacks with listener interface --- .../alarm_control_panel.cpp | 70 ++--------- .../alarm_control_panel/alarm_control_panel.h | 109 +++++------------- .../alarm_control_panel/automation.h | 91 ++++++++------- .../mqtt/mqtt_alarm_control_panel.cpp | 2 +- .../mqtt/mqtt_alarm_control_panel.h | 9 +- .../template_alarm_control_panel.cpp | 4 +- 6 files changed, 100 insertions(+), 185 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index c29e02c8ef..585401028a 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -35,29 +35,15 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { ESP_LOGD(TAG, "Set state to: %s, previous: %s", LOG_STR_ARG(alarm_control_panel_state_to_string(state)), LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; - this->state_callback_.call(); + + for (auto *listener : this->listeners_) { + listener->on_state(state, prev_state); + } + #if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_alarm_control_panel_update(this); #endif - if (state == ACP_STATE_TRIGGERED) { - this->triggered_callback_.call(); - } else if (state == ACP_STATE_ARMING) { - this->arming_callback_.call(); - } else if (state == ACP_STATE_PENDING) { - this->pending_callback_.call(); - } else if (state == ACP_STATE_ARMED_HOME) { - this->armed_home_callback_.call(); - } else if (state == ACP_STATE_ARMED_NIGHT) { - this->armed_night_callback_.call(); - } else if (state == ACP_STATE_ARMED_AWAY) { - this->armed_away_callback_.call(); - } else if (state == ACP_STATE_DISARMED) { - this->disarmed_callback_.call(); - } - if (prev_state == ACP_STATE_TRIGGERED) { - this->cleared_callback_.call(); - } if (state == this->desired_state_) { // only store when in the desired state this->pref_.save(&state); @@ -65,48 +51,14 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { } } -void AlarmControlPanel::add_on_state_callback(std::function &&callback) { - this->state_callback_.add(std::move(callback)); +void AlarmControlPanel::notify_chime() { + for (auto *listener : this->listeners_) + listener->on_chime(); } -void AlarmControlPanel::add_on_triggered_callback(std::function &&callback) { - this->triggered_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_arming_callback(std::function &&callback) { - this->arming_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_home_callback(std::function &&callback) { - this->armed_home_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_night_callback(std::function &&callback) { - this->armed_night_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_away_callback(std::function &&callback) { - this->armed_away_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_pending_callback(std::function &&callback) { - this->pending_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_disarmed_callback(std::function &&callback) { - this->disarmed_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_cleared_callback(std::function &&callback) { - this->cleared_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_chime_callback(std::function &&callback) { - this->chime_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_ready_callback(std::function &&callback) { - this->ready_callback_.add(std::move(callback)); +void AlarmControlPanel::notify_ready() { + for (auto *listener : this->listeners_) + listener->on_ready(); } void AlarmControlPanel::arm_away(optional code) { diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index 85c2b2148e..d9090e3c19 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -1,17 +1,32 @@ #pragma once -#include +#include #include "alarm_control_panel_call.h" #include "alarm_control_panel_state.h" -#include "esphome/core/automation.h" +#include "esphome/core/component.h" #include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" namespace esphome { namespace alarm_control_panel { +/// Listener interface for alarm control panel events. +/// Implement this interface and register with add_listener() to receive notifications. +class AlarmControlPanelListener { + public: + virtual ~AlarmControlPanelListener() = default; + /// Called when state changes. Check new_state to filter specific states. + virtual void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) {} + /// Called when a chime zone opens while disarmed. + virtual void on_chime() {} + /// Called when ready state changes. + virtual void on_ready() {} +}; + enum AlarmControlPanelFeature : uint8_t { // Matches Home Assistant values ACP_FEAT_ARM_HOME = 1 << 0, @@ -35,71 +50,19 @@ class AlarmControlPanel : public EntityBase { */ void publish_state(AlarmControlPanelState state); - /** Add a callback for when the state of the alarm_control_panel changes + /** Register a listener for alarm control panel events. * - * @param callback The callback function + * @param listener The listener to add (must remain valid for lifetime of panel) */ - void add_on_state_callback(std::function &&callback); + void add_listener(AlarmControlPanelListener *listener) { this->listeners_.push_back(listener); } - /** Add a callback for when the state of the alarm_control_panel chanes to triggered - * - * @param callback The callback function + /** Notify listeners of a chime event (zone opened while disarmed). */ - void add_on_triggered_callback(std::function &&callback); + void notify_chime(); - /** Add a callback for when the state of the alarm_control_panel chanes to arming - * - * @param callback The callback function + /** Notify listeners of a ready state change. */ - void add_on_arming_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to pending - * - * @param callback The callback function - */ - void add_on_pending_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_home - * - * @param callback The callback function - */ - void add_on_armed_home_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_night - * - * @param callback The callback function - */ - void add_on_armed_night_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_away - * - * @param callback The callback function - */ - void add_on_armed_away_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to disarmed - * - * @param callback The callback function - */ - void add_on_disarmed_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel clears from triggered - * - * @param callback The callback function - */ - void add_on_cleared_callback(std::function &&callback); - - /** Add a callback for when a chime zone goes from closed to open - * - * @param callback The callback function - */ - void add_on_chime_callback(std::function &&callback); - - /** Add a callback for when a ready state changes - * - * @param callback The callback function - */ - void add_on_ready_callback(std::function &&callback); + void notify_ready(); /** A numeric representation of the supported features as per HomeAssistant * @@ -172,28 +135,8 @@ class AlarmControlPanel : public EntityBase { uint32_t last_update_; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; - // state callback - CallbackManager state_callback_{}; - // trigger callback - CallbackManager triggered_callback_{}; - // arming callback - CallbackManager arming_callback_{}; - // pending callback - CallbackManager pending_callback_{}; - // armed_home callback - CallbackManager armed_home_callback_{}; - // armed_night callback - CallbackManager armed_night_callback_{}; - // armed_away callback - CallbackManager armed_away_callback_{}; - // disarmed callback - CallbackManager disarmed_callback_{}; - // clear callback - CallbackManager cleared_callback_{}; - // chime callback - CallbackManager chime_callback_{}; - // ready callback - CallbackManager ready_callback_{}; + // registered listeners + std::vector listeners_; }; } // namespace alarm_control_panel diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index db2ef78158..533f4892d1 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -6,81 +6,94 @@ namespace esphome { namespace alarm_control_panel { -class StateTrigger : public Trigger<> { +class StateTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { this->trigger(); }); + explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { this->trigger(); } +}; + +class TriggeredTrigger final : public Trigger<>, public AlarmControlPanelListener { + public: + explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_TRIGGERED) + this->trigger(); } }; -class TriggeredTrigger : public Trigger<> { +class ArmingTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_triggered_callback([this]() { this->trigger(); }); + explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_ARMING) + this->trigger(); } }; -class ArmingTrigger : public Trigger<> { +class PendingTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_arming_callback([this]() { this->trigger(); }); + explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_PENDING) + this->trigger(); } }; -class PendingTrigger : public Trigger<> { +class ArmedHomeTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_pending_callback([this]() { this->trigger(); }); + explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_ARMED_HOME) + this->trigger(); } }; -class ArmedHomeTrigger : public Trigger<> { +class ArmedNightTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_home_callback([this]() { this->trigger(); }); + explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_ARMED_NIGHT) + this->trigger(); } }; -class ArmedNightTrigger : public Trigger<> { +class ArmedAwayTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_night_callback([this]() { this->trigger(); }); + explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_ARMED_AWAY) + this->trigger(); } }; -class ArmedAwayTrigger : public Trigger<> { +class DisarmedTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_away_callback([this]() { this->trigger(); }); + explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (new_state == ACP_STATE_DISARMED) + this->trigger(); } }; -class DisarmedTrigger : public Trigger<> { +class ClearedTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_disarmed_callback([this]() { this->trigger(); }); + explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { + if (prev_state == ACP_STATE_TRIGGERED) + this->trigger(); } }; -class ClearedTrigger : public Trigger<> { +class ChimeTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_cleared_callback([this]() { this->trigger(); }); - } + explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_chime() override { this->trigger(); } }; -class ChimeTrigger : public Trigger<> { +class ReadyTrigger final : public Trigger<>, public AlarmControlPanelListener { public: - explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_chime_callback([this]() { this->trigger(); }); - } -}; - -class ReadyTrigger : public Trigger<> { - public: - explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_ready_callback([this]() { this->trigger(); }); - } + explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + void on_ready() override { this->trigger(); } }; template class ArmAwayAction : public Action { diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index dd3df5f8aa..c96d696862 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -16,7 +16,7 @@ using namespace esphome::alarm_control_panel; MQTTAlarmControlPanelComponent::MQTTAlarmControlPanelComponent(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} void MQTTAlarmControlPanelComponent::setup() { - this->alarm_control_panel_->add_on_state_callback([this]() { this->publish_state(); }); + this->alarm_control_panel_->add_listener(this); this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { auto call = this->alarm_control_panel_->make_call(); if (strcasecmp(payload.c_str(), "ARM_AWAY") == 0) { diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index 4ad37b7314..47d8610d0b 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -11,7 +11,8 @@ namespace esphome { namespace mqtt { -class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { +class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent, + public alarm_control_panel::AlarmControlPanelListener { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); @@ -25,6 +26,12 @@ class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { void dump_config() override; + // AlarmControlPanelListener interface + void on_state(alarm_control_panel::AlarmControlPanelState new_state, + alarm_control_panel::AlarmControlPanelState prev_state) override { + this->publish_state(); + } + protected: std::string component_type() const override; const EntityBase *get_entity() const override; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index f025435261..003acb06ff 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -133,7 +133,7 @@ void TemplateAlarmControlPanel::loop() { if ((!this->sensor_data_[info.store_index].last_chime_state) && (sensor->state)) { // Must be disarmed to chime if (this->current_state_ == ACP_STATE_DISARMED) { - this->chime_callback_.call(); + this->notify_chime(); } } // Record the sensor state change @@ -182,7 +182,7 @@ void TemplateAlarmControlPanel::loop() { // Call the ready state change callback if there was a change if (this->sensors_ready_ != sensors_ready) { this->sensors_ready_ = sensors_ready; - this->ready_callback_.call(); + this->notify_ready(); } #endif From 3c1c19da1c572f3ad60efc272513045cdc99c732 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:23:31 -0600 Subject: [PATCH 3/9] tweaks --- .../alarm_control_panel.cpp | 6 +- .../alarm_control_panel/alarm_control_panel.h | 35 +++++--- .../alarm_control_panel/automation.h | 81 +++++-------------- .../mqtt/mqtt_alarm_control_panel.h | 4 +- 4 files changed, 50 insertions(+), 76 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index 585401028a..733d255158 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -36,7 +36,7 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; - for (auto *listener : this->listeners_) { + for (auto *listener : this->state_listeners_) { listener->on_state(state, prev_state); } @@ -52,12 +52,12 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { } void AlarmControlPanel::notify_chime() { - for (auto *listener : this->listeners_) + for (auto *listener : this->event_listeners_) listener->on_chime(); } void AlarmControlPanel::notify_ready() { - for (auto *listener : this->listeners_) + for (auto *listener : this->event_listeners_) listener->on_ready(); } diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index d9090e3c19..80d3906aa3 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -14,16 +14,21 @@ namespace esphome { namespace alarm_control_panel { -/// Listener interface for alarm control panel events. -/// Implement this interface and register with add_listener() to receive notifications. -class AlarmControlPanelListener { +/// Listener interface for alarm control panel state changes. +class AlarmControlPanelStateListener { public: - virtual ~AlarmControlPanelListener() = default; - /// Called when state changes. Check new_state to filter specific states. - virtual void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) {} + virtual ~AlarmControlPanelStateListener() = default; + /// Called when state changes. Check new_state/prev_state to filter specific states. + virtual void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) = 0; +}; + +/// Listener interface for alarm events (chime, ready, etc). +class AlarmControlPanelEventListener { + public: + virtual ~AlarmControlPanelEventListener() = default; /// Called when a chime zone opens while disarmed. virtual void on_chime() {} - /// Called when ready state changes. + /// Called when zones ready state changes. virtual void on_ready() {} }; @@ -50,11 +55,17 @@ class AlarmControlPanel : public EntityBase { */ void publish_state(AlarmControlPanelState state); - /** Register a listener for alarm control panel events. + /** Register a listener for state changes. * * @param listener The listener to add (must remain valid for lifetime of panel) */ - void add_listener(AlarmControlPanelListener *listener) { this->listeners_.push_back(listener); } + void add_listener(AlarmControlPanelStateListener *listener) { this->state_listeners_.push_back(listener); } + + /** Register a listener for alarm events (chime/ready/etc). + * + * @param listener The listener to add (must remain valid for lifetime of panel) + */ + void add_listener(AlarmControlPanelEventListener *listener) { this->event_listeners_.push_back(listener); } /** Notify listeners of a chime event (zone opened while disarmed). */ @@ -135,8 +146,10 @@ class AlarmControlPanel : public EntityBase { uint32_t last_update_; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; - // registered listeners - std::vector listeners_; + // registered state listeners + std::vector state_listeners_; + // registered event listeners (chime/ready/etc) + std::vector event_listeners_; }; } // namespace alarm_control_panel diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 533f4892d1..19a3662e83 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -6,76 +6,35 @@ namespace esphome { namespace alarm_control_panel { -class StateTrigger final : public Trigger<>, public AlarmControlPanelListener { +/// Trigger on any state change +class StateTrigger final : public Trigger<>, public AlarmControlPanelStateListener { public: explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { this->trigger(); } }; -class TriggeredTrigger final : public Trigger<>, public AlarmControlPanelListener { +/// Template trigger that fires when entering a specific state +template +class StateEnterTrigger final : public Trigger<>, public AlarmControlPanelStateListener { public: - explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } + explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_TRIGGERED) + if (new_state == State) this->trigger(); } }; -class ArmingTrigger final : public Trigger<>, public AlarmControlPanelListener { - public: - explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_ARMING) - this->trigger(); - } -}; +// Type aliases for state-specific triggers +using TriggeredTrigger = StateEnterTrigger; +using ArmingTrigger = StateEnterTrigger; +using PendingTrigger = StateEnterTrigger; +using ArmedHomeTrigger = StateEnterTrigger; +using ArmedNightTrigger = StateEnterTrigger; +using ArmedAwayTrigger = StateEnterTrigger; +using DisarmedTrigger = StateEnterTrigger; -class PendingTrigger final : public Trigger<>, public AlarmControlPanelListener { - public: - explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_PENDING) - this->trigger(); - } -}; - -class ArmedHomeTrigger final : public Trigger<>, public AlarmControlPanelListener { - public: - explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_ARMED_HOME) - this->trigger(); - } -}; - -class ArmedNightTrigger final : public Trigger<>, public AlarmControlPanelListener { - public: - explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_ARMED_NIGHT) - this->trigger(); - } -}; - -class ArmedAwayTrigger final : public Trigger<>, public AlarmControlPanelListener { - public: - explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_ARMED_AWAY) - this->trigger(); - } -}; - -class DisarmedTrigger final : public Trigger<>, public AlarmControlPanelListener { - public: - explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == ACP_STATE_DISARMED) - this->trigger(); - } -}; - -class ClearedTrigger final : public Trigger<>, public AlarmControlPanelListener { +/// Trigger when leaving TRIGGERED state (alarm cleared) +class ClearedTrigger final : public Trigger<>, public AlarmControlPanelStateListener { public: explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { @@ -84,13 +43,15 @@ class ClearedTrigger final : public Trigger<>, public AlarmControlPanelListener } }; -class ChimeTrigger final : public Trigger<>, public AlarmControlPanelListener { +/// Trigger on chime event (zone opened while disarmed) +class ChimeTrigger final : public Trigger<>, public AlarmControlPanelEventListener { public: explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } void on_chime() override { this->trigger(); } }; -class ReadyTrigger final : public Trigger<>, public AlarmControlPanelListener { +/// Trigger on ready state change +class ReadyTrigger final : public Trigger<>, public AlarmControlPanelEventListener { public: explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } void on_ready() override { this->trigger(); } diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index 47d8610d0b..c64b8c575d 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -12,7 +12,7 @@ namespace esphome { namespace mqtt { class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent, - public alarm_control_panel::AlarmControlPanelListener { + public alarm_control_panel::AlarmControlPanelStateListener { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); @@ -26,7 +26,7 @@ class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent, void dump_config() override; - // AlarmControlPanelListener interface + // AlarmControlPanelStateListener interface void on_state(alarm_control_panel::AlarmControlPanelState new_state, alarm_control_panel::AlarmControlPanelState prev_state) override { this->publish_state(); From 2060ed0a92f59cfe58b3daa374cfac068491ae93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:32:28 -0600 Subject: [PATCH 4/9] tests --- ...alarm_control_panel_state_transitions.yaml | 106 ++++++ ...t_alarm_control_panel_state_transitions.py | 310 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 tests/integration/fixtures/alarm_control_panel_state_transitions.yaml create mode 100644 tests/integration/test_alarm_control_panel_state_transitions.py diff --git a/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml b/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml new file mode 100644 index 0000000000..1edb401a0d --- /dev/null +++ b/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml @@ -0,0 +1,106 @@ +esphome: + name: alarm-state-transitions + friendly_name: "Alarm Control Panel State Transitions Test" + +logger: + +host: + +globals: + - id: door_sensor_state + type: bool + initial_value: "false" + - id: chime_sensor_state + type: bool + initial_value: "false" + +switch: + # Switch to control the door sensor state + - platform: template + id: door_sensor_switch + name: "Door Sensor Switch" + optimistic: true + turn_on_action: + - globals.set: + id: door_sensor_state + value: "true" + turn_off_action: + - globals.set: + id: door_sensor_state + value: "false" + # Switch to control the chime sensor state + - platform: template + id: chime_sensor_switch + name: "Chime Sensor Switch" + optimistic: true + turn_on_action: + - globals.set: + id: chime_sensor_state + value: "true" + turn_off_action: + - globals.set: + id: chime_sensor_state + value: "false" + +binary_sensor: + - platform: template + id: door_sensor + name: "Door Sensor" + lambda: |- + return id(door_sensor_state); + - platform: template + id: chime_sensor + name: "Chime Sensor" + lambda: |- + return id(chime_sensor_state); + +alarm_control_panel: + - platform: template + id: test_alarm + name: "Test Alarm" + codes: + - "1234" + requires_code_to_arm: true + # Short timeouts for faster testing + arming_away_time: 50ms + arming_home_time: 50ms + arming_night_time: 50ms + pending_time: 50ms + trigger_time: 100ms + restore_mode: ALWAYS_DISARMED + binary_sensors: + - input: door_sensor + bypass_armed_home: false + bypass_armed_night: false + chime: false + trigger_mode: DELAYED + - input: chime_sensor + bypass_armed_home: true + bypass_armed_night: true + chime: true + trigger_mode: DELAYED + on_state: + - logger.log: "State changed" + on_disarmed: + - logger.log: "Alarm disarmed" + on_arming: + - logger.log: "Alarm arming" + on_armed_away: + - logger.log: "Alarm armed away" + on_armed_home: + - logger.log: "Alarm armed home" + on_armed_night: + - logger.log: "Alarm armed night" + on_pending: + - logger.log: "Alarm pending" + on_triggered: + - logger.log: "Alarm triggered" + on_cleared: + - logger.log: "Alarm cleared" + on_chime: + - logger.log: "Chime activated" + on_ready: + - logger.log: "Sensors ready state changed" + +api: + batch_delay: 0ms diff --git a/tests/integration/test_alarm_control_panel_state_transitions.py b/tests/integration/test_alarm_control_panel_state_transitions.py new file mode 100644 index 0000000000..f4521762df --- /dev/null +++ b/tests/integration/test_alarm_control_panel_state_transitions.py @@ -0,0 +1,310 @@ +"""Integration test for alarm control panel state transitions.""" + +from __future__ import annotations + +import asyncio +import re + +import aioesphomeapi +from aioesphomeapi import ( + AlarmControlPanelCommand, + AlarmControlPanelEntityState, + AlarmControlPanelInfo, + AlarmControlPanelState, + SwitchInfo, +) +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_alarm_control_panel_state_transitions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test alarm control panel state transitions. + + This comprehensive test verifies all state transitions and listener callbacks: + + 1. Basic arm/disarm sequences: + - DISARMED -> ARMING -> ARMED_AWAY -> DISARMED + - DISARMED -> ARMING -> ARMED_HOME -> DISARMED + - DISARMED -> ARMING -> ARMED_NIGHT -> DISARMED + + 2. Wrong code rejection + + 3. Sensor triggering while armed: + - ARMED_AWAY -> PENDING -> TRIGGERED (delayed sensor) + - TRIGGERED -> ARMED_AWAY (auto-reset after trigger_time, fires on_cleared) + + 4. Chime functionality: + - Sensor open while DISARMED triggers on_chime + + 5. Ready state: + - Sensor state changes trigger on_ready + """ + loop = asyncio.get_running_loop() + + # Track log messages for callback verification + log_lines: list[str] = [] + chime_future: asyncio.Future[bool] = loop.create_future() + ready_futures: list[asyncio.Future[bool]] = [] + cleared_future: asyncio.Future[bool] = loop.create_future() + + # Patterns to match log output from callbacks + chime_pattern = re.compile(r"Chime activated") + ready_pattern = re.compile(r"Sensors ready state changed") + cleared_pattern = re.compile(r"Alarm cleared") + + def on_log_line(line: str) -> None: + log_lines.append(line) + if not chime_future.done() and chime_pattern.search(line): + chime_future.set_result(True) + if ready_pattern.search(line): + # Create new future for each ready event + for fut in ready_futures: + if not fut.done(): + fut.set_result(True) + break + if not cleared_future.done() and cleared_pattern.search(line): + cleared_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + # Find entities + alarm_info: AlarmControlPanelInfo | None = None + door_switch_info: SwitchInfo | None = None + chime_switch_info: SwitchInfo | None = None + + for entity in entities: + if isinstance(entity, AlarmControlPanelInfo): + alarm_info = entity + elif isinstance(entity, SwitchInfo): + if entity.name == "Door Sensor Switch": + door_switch_info = entity + elif entity.name == "Chime Sensor Switch": + chime_switch_info = entity + + assert alarm_info is not None, "Alarm control panel not found" + assert door_switch_info is not None, "Door sensor switch not found" + assert chime_switch_info is not None, "Chime sensor switch not found" + + # Track state changes + states_received: list[AlarmControlPanelState] = [] + state_event = asyncio.Event() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, AlarmControlPanelEntityState) + and state.key == alarm_info.key + ): + states_received.append(state.state) + state_event.set() + + client.subscribe_states(on_state) + + # Helper to wait for specific state + async def wait_for_state( + expected: AlarmControlPanelState, timeout: float = 5.0 + ) -> None: + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError( + f"Timeout waiting for state {expected}, " + f"last state: {states_received[-1] if states_received else 'none'}" + ) + await asyncio.wait_for(state_event.wait(), timeout=remaining) + state_event.clear() + if states_received[-1] == expected: + return + + # Wait for initial DISARMED state + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test wrong code rejection ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="0000", # Wrong code + ) + + # Should NOT transition - wait a bit and verify still disarmed + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(state_event.wait(), timeout=0.5) + assert states_received[-1] == AlarmControlPanelState.DISARMED + + # ===== Test ARM_AWAY sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_AWAY) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test ARM_HOME sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_HOME, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_HOME) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test ARM_NIGHT sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_NIGHT, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_NIGHT) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # Verify basic state sequence + expected_states = [ + AlarmControlPanelState.DISARMED, # Initial + AlarmControlPanelState.ARMING, # Arm away + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.ARMING, # Arm home + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.ARMING, # Arm night + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.DISARMED, + ] + assert states_received == expected_states, ( + f"State sequence mismatch.\nExpected: {expected_states}\n" + f"Got: {states_received}" + ) + + # ===== Test PENDING -> TRIGGERED -> CLEARED sequence ===== + # This tests on_pending, on_triggered, and on_cleared callbacks + + # Arm away first + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_AWAY) + + # Trip the door sensor (delayed mode triggers PENDING first) + client.switch_command(door_switch_info.key, True) + + # Should go to PENDING (delayed sensor) + await wait_for_state(AlarmControlPanelState.PENDING) + + # Should go to TRIGGERED after pending_time (100ms) + await wait_for_state(AlarmControlPanelState.TRIGGERED) + + # Close the sensor + client.switch_command(door_switch_info.key, False) + + # Wait for trigger_time to expire and auto-reset (500ms) + # The alarm should go back to ARMED_AWAY after trigger_time + # This transition FROM TRIGGERED fires on_cleared + await wait_for_state(AlarmControlPanelState.ARMED_AWAY, timeout=2.0) + + # Verify on_cleared was logged + try: + await asyncio.wait_for(cleared_future, timeout=1.0) + except TimeoutError: + pytest.fail(f"on_cleared callback not fired. Log lines: {log_lines[-20:]}") + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # Verify trigger sequence was added + assert AlarmControlPanelState.PENDING in states_received + assert AlarmControlPanelState.TRIGGERED in states_received + + # ===== Test chime (sensor open while disarmed) ===== + # The chime_sensor has chime: true, so opening it while disarmed + # should trigger on_chime callback + + # We're currently DISARMED - open the chime sensor + client.switch_command(chime_switch_info.key, True) + + # Wait for chime callback to be logged + try: + await asyncio.wait_for(chime_future, timeout=2.0) + except TimeoutError: + pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}") + + # Close the chime sensor + client.switch_command(chime_switch_info.key, False) + + # ===== Test ready state changes ===== + # Opening/closing sensors while disarmed affects ready state + # The on_ready callback fires when sensors_ready changes + + # Set up futures for ready state changes + ready_future_1: asyncio.Future[bool] = loop.create_future() + ready_future_2: asyncio.Future[bool] = loop.create_future() + ready_futures.extend([ready_future_1, ready_future_2]) + + # Open door sensor (makes alarm not ready) + client.switch_command(door_switch_info.key, True) + + # Wait for first on_ready callback (not ready) + try: + await asyncio.wait_for(ready_future_1, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when sensor opened. " + f"Log lines: {log_lines[-20:]}" + ) + + # Close door sensor (makes alarm ready again) + client.switch_command(door_switch_info.key, False) + + # Wait for second on_ready callback (ready) + try: + await asyncio.wait_for(ready_future_2, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when sensor closed. " + f"Log lines: {log_lines[-20:]}" + ) + + # Final state should still be DISARMED + assert states_received[-1] == AlarmControlPanelState.DISARMED From d3918dc784d0680c1f69c2e0ed9c43112245292f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:39:05 -0600 Subject: [PATCH 5/9] reduce --- .../alarm_control_panel.cpp | 30 ++-- .../alarm_control_panel/alarm_control_panel.h | 61 +++----- .../alarm_control_panel/automation.h | 143 ++++++++++++++---- .../mqtt/mqtt_alarm_control_panel.cpp | 2 +- .../mqtt/mqtt_alarm_control_panel.h | 9 +- ...t_alarm_control_panel_state_transitions.py | 25 ++- 6 files changed, 173 insertions(+), 97 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index 733d255158..f938155dd3 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -35,15 +35,15 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { ESP_LOGD(TAG, "Set state to: %s, previous: %s", LOG_STR_ARG(alarm_control_panel_state_to_string(state)), LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; - - for (auto *listener : this->state_listeners_) { - listener->on_state(state, prev_state); - } - + // Single state callback - triggers check get_state() for specific states + this->state_callback_.call(); #if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_alarm_control_panel_update(this); #endif - + // Cleared fires when leaving TRIGGERED state + if (prev_state == ACP_STATE_TRIGGERED) { + this->cleared_callback_.call(); + } if (state == this->desired_state_) { // only store when in the desired state this->pref_.save(&state); @@ -51,14 +51,20 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { } } -void AlarmControlPanel::notify_chime() { - for (auto *listener : this->event_listeners_) - listener->on_chime(); +void AlarmControlPanel::add_on_state_callback(std::function &&callback) { + this->state_callback_.add(std::move(callback)); } -void AlarmControlPanel::notify_ready() { - for (auto *listener : this->event_listeners_) - listener->on_ready(); +void AlarmControlPanel::add_on_cleared_callback(std::function &&callback) { + this->cleared_callback_.add(std::move(callback)); +} + +void AlarmControlPanel::add_on_chime_callback(std::function &&callback) { + this->chime_callback_.add(std::move(callback)); +} + +void AlarmControlPanel::add_on_ready_callback(std::function &&callback) { + this->ready_callback_.add(std::move(callback)); } void AlarmControlPanel::arm_away(optional code) { diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index 80d3906aa3..c46edc11c2 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -1,37 +1,17 @@ #pragma once -#include +#include #include "alarm_control_panel_call.h" #include "alarm_control_panel_state.h" -#include "esphome/core/component.h" +#include "esphome/core/automation.h" #include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/core/preferences.h" namespace esphome { namespace alarm_control_panel { -/// Listener interface for alarm control panel state changes. -class AlarmControlPanelStateListener { - public: - virtual ~AlarmControlPanelStateListener() = default; - /// Called when state changes. Check new_state/prev_state to filter specific states. - virtual void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) = 0; -}; - -/// Listener interface for alarm events (chime, ready, etc). -class AlarmControlPanelEventListener { - public: - virtual ~AlarmControlPanelEventListener() = default; - /// Called when a chime zone opens while disarmed. - virtual void on_chime() {} - /// Called when zones ready state changes. - virtual void on_ready() {} -}; - enum AlarmControlPanelFeature : uint8_t { // Matches Home Assistant values ACP_FEAT_ARM_HOME = 1 << 0, @@ -55,25 +35,30 @@ class AlarmControlPanel : public EntityBase { */ void publish_state(AlarmControlPanelState state); - /** Register a listener for state changes. + /** Add a callback for when the state of the alarm_control_panel changes. + * Triggers can check get_state() to determine the new state. * - * @param listener The listener to add (must remain valid for lifetime of panel) + * @param callback The callback function */ - void add_listener(AlarmControlPanelStateListener *listener) { this->state_listeners_.push_back(listener); } + void add_on_state_callback(std::function &&callback); - /** Register a listener for alarm events (chime/ready/etc). + /** Add a callback for when the state of the alarm_control_panel clears from triggered * - * @param listener The listener to add (must remain valid for lifetime of panel) + * @param callback The callback function */ - void add_listener(AlarmControlPanelEventListener *listener) { this->event_listeners_.push_back(listener); } + void add_on_cleared_callback(std::function &&callback); - /** Notify listeners of a chime event (zone opened while disarmed). + /** Add a callback for when a chime zone goes from closed to open + * + * @param callback The callback function */ - void notify_chime(); + void add_on_chime_callback(std::function &&callback); - /** Notify listeners of a ready state change. + /** Add a callback for when a ready state changes + * + * @param callback The callback function */ - void notify_ready(); + void add_on_ready_callback(std::function &&callback); /** A numeric representation of the supported features as per HomeAssistant * @@ -146,10 +131,14 @@ class AlarmControlPanel : public EntityBase { uint32_t last_update_; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; - // registered state listeners - std::vector state_listeners_; - // registered event listeners (chime/ready/etc) - std::vector event_listeners_; + // state callback - triggers check get_state() for specific state + CallbackManager state_callback_{}; + // clear callback - fires when leaving TRIGGERED state + CallbackManager cleared_callback_{}; + // chime callback + CallbackManager chime_callback_{}; + // ready callback + CallbackManager ready_callback_{}; }; } // namespace alarm_control_panel diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 19a3662e83..b9a75faad8 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -7,54 +7,133 @@ namespace esphome { namespace alarm_control_panel { /// Trigger on any state change -class StateTrigger final : public Trigger<>, public AlarmControlPanelStateListener { +class StateTrigger : public Trigger<> { public: - explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { this->trigger(); } -}; - -/// Template trigger that fires when entering a specific state -template -class StateEnterTrigger final : public Trigger<>, public AlarmControlPanelStateListener { - public: - explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (new_state == State) - this->trigger(); + explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { this->trigger(); }); } }; -// Type aliases for state-specific triggers -using TriggeredTrigger = StateEnterTrigger; -using ArmingTrigger = StateEnterTrigger; -using PendingTrigger = StateEnterTrigger; -using ArmedHomeTrigger = StateEnterTrigger; -using ArmedNightTrigger = StateEnterTrigger; -using ArmedAwayTrigger = StateEnterTrigger; -using DisarmedTrigger = StateEnterTrigger; +/// Trigger when entering TRIGGERED state +class TriggeredTrigger : public Trigger<> { + public: + explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_TRIGGERED) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; + +/// Trigger when entering ARMING state +class ArmingTrigger : public Trigger<> { + public: + explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMING) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; + +/// Trigger when entering PENDING state +class PendingTrigger : public Trigger<> { + public: + explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_PENDING) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; + +/// Trigger when entering ARMED_HOME state +class ArmedHomeTrigger : public Trigger<> { + public: + explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMED_HOME) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; + +/// Trigger when entering ARMED_NIGHT state +class ArmedNightTrigger : public Trigger<> { + public: + explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMED_NIGHT) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; + +/// Trigger when entering ARMED_AWAY state +class ArmedAwayTrigger : public Trigger<> { + public: + explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMED_AWAY) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; + +/// Trigger when entering DISARMED state +class DisarmedTrigger : public Trigger<> { + public: + explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == ACP_STATE_DISARMED) + this->trigger(); + }); + } + + protected: + AlarmControlPanel *alarm_control_panel_; +}; /// Trigger when leaving TRIGGERED state (alarm cleared) -class ClearedTrigger final : public Trigger<>, public AlarmControlPanelStateListener { +class ClearedTrigger : public Trigger<> { public: - explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_state(AlarmControlPanelState new_state, AlarmControlPanelState prev_state) override { - if (prev_state == ACP_STATE_TRIGGERED) - this->trigger(); + explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { + alarm_control_panel->add_on_cleared_callback([this]() { this->trigger(); }); } }; /// Trigger on chime event (zone opened while disarmed) -class ChimeTrigger final : public Trigger<>, public AlarmControlPanelEventListener { +class ChimeTrigger : public Trigger<> { public: - explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_chime() override { this->trigger(); } + explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { + alarm_control_panel->add_on_chime_callback([this]() { this->trigger(); }); + } }; /// Trigger on ready state change -class ReadyTrigger final : public Trigger<>, public AlarmControlPanelEventListener { +class ReadyTrigger : public Trigger<> { public: - explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { alarm_control_panel->add_listener(this); } - void on_ready() override { this->trigger(); } + explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { + alarm_control_panel->add_on_ready_callback([this]() { this->trigger(); }); + } }; template class ArmAwayAction : public Action { diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index c96d696862..dd3df5f8aa 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -16,7 +16,7 @@ using namespace esphome::alarm_control_panel; MQTTAlarmControlPanelComponent::MQTTAlarmControlPanelComponent(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} void MQTTAlarmControlPanelComponent::setup() { - this->alarm_control_panel_->add_listener(this); + this->alarm_control_panel_->add_on_state_callback([this]() { this->publish_state(); }); this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { auto call = this->alarm_control_panel_->make_call(); if (strcasecmp(payload.c_str(), "ARM_AWAY") == 0) { diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index c64b8c575d..4ad37b7314 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -11,8 +11,7 @@ namespace esphome { namespace mqtt { -class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent, - public alarm_control_panel::AlarmControlPanelStateListener { +class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); @@ -26,12 +25,6 @@ class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent, void dump_config() override; - // AlarmControlPanelStateListener interface - void on_state(alarm_control_panel::AlarmControlPanelState new_state, - alarm_control_panel::AlarmControlPanelState prev_state) override { - this->publish_state(); - } - protected: std::string component_type() const override; const EntityBase *get_entity() const override; diff --git a/tests/integration/test_alarm_control_panel_state_transitions.py b/tests/integration/test_alarm_control_panel_state_transitions.py index f4521762df..06010aeaa7 100644 --- a/tests/integration/test_alarm_control_panel_state_transitions.py +++ b/tests/integration/test_alarm_control_panel_state_transitions.py @@ -15,6 +15,7 @@ from aioesphomeapi import ( ) import pytest +from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction @@ -107,7 +108,18 @@ async def test_alarm_control_panel_state_transitions( states_received.append(state.state) state_event.set() - client.subscribe_states(on_state) + # Use InitialStateHelper to handle initial state broadcast + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states from all entities + await initial_state_helper.wait_for_initial_states() + + # Verify alarm panel started in DISARMED state + initial_alarm_state = initial_state_helper.initial_states.get(alarm_info.key) + assert initial_alarm_state is not None, "No initial alarm state received" + assert isinstance(initial_alarm_state, AlarmControlPanelEntityState) + assert initial_alarm_state.state == AlarmControlPanelState.DISARMED # Helper to wait for specific state async def wait_for_state( @@ -126,9 +138,6 @@ async def test_alarm_control_panel_state_transitions( if states_received[-1] == expected: return - # Wait for initial DISARMED state - await wait_for_state(AlarmControlPanelState.DISARMED) - # ===== Test wrong code rejection ===== client.alarm_control_panel_command( alarm_info.key, @@ -136,10 +145,11 @@ async def test_alarm_control_panel_state_transitions( code="0000", # Wrong code ) - # Should NOT transition - wait a bit and verify still disarmed + # Should NOT transition - wait a bit and verify no state changes with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(state_event.wait(), timeout=0.5) - assert states_received[-1] == AlarmControlPanelState.DISARMED + # No state changes should have occurred (list is empty) + assert len(states_received) == 0, f"Unexpected state changes: {states_received}" # ===== Test ARM_AWAY sequence ===== client.alarm_control_panel_command( @@ -192,9 +202,8 @@ async def test_alarm_control_panel_state_transitions( ) await wait_for_state(AlarmControlPanelState.DISARMED) - # Verify basic state sequence + # Verify basic state sequence (initial DISARMED is handled by InitialStateHelper) expected_states = [ - AlarmControlPanelState.DISARMED, # Initial AlarmControlPanelState.ARMING, # Arm away AlarmControlPanelState.ARMED_AWAY, AlarmControlPanelState.DISARMED, From 4ab1911d82765b6742b70566df2f6d15bb448d26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:40:27 -0600 Subject: [PATCH 6/9] reduce --- .../alarm_control_panel/alarm_control_panel.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index c46edc11c2..08b7dc88be 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -60,6 +60,16 @@ class AlarmControlPanel : public EntityBase { */ void add_on_ready_callback(std::function &&callback); + /** Notify chime event listeners + * + */ + void notify_chime() { this->chime_callback_.call(); } + + /** Notify ready state change listeners + * + */ + void notify_ready() { this->ready_callback_.call(); } + /** A numeric representation of the supported features as per HomeAssistant * */ From 913581e7ee9416a56b1d4e7c2d16923594817d05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:42:03 -0600 Subject: [PATCH 7/9] reduce --- .../alarm_control_panel/alarm_control_panel.h | 10 ---------- .../template_alarm_control_panel.cpp | 4 ++-- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index 08b7dc88be..c46edc11c2 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -60,16 +60,6 @@ class AlarmControlPanel : public EntityBase { */ void add_on_ready_callback(std::function &&callback); - /** Notify chime event listeners - * - */ - void notify_chime() { this->chime_callback_.call(); } - - /** Notify ready state change listeners - * - */ - void notify_ready() { this->ready_callback_.call(); } - /** A numeric representation of the supported features as per HomeAssistant * */ diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 003acb06ff..f025435261 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -133,7 +133,7 @@ void TemplateAlarmControlPanel::loop() { if ((!this->sensor_data_[info.store_index].last_chime_state) && (sensor->state)) { // Must be disarmed to chime if (this->current_state_ == ACP_STATE_DISARMED) { - this->notify_chime(); + this->chime_callback_.call(); } } // Record the sensor state change @@ -182,7 +182,7 @@ void TemplateAlarmControlPanel::loop() { // Call the ready state change callback if there was a change if (this->sensors_ready_ != sensors_ready) { this->sensors_ready_ = sensors_ready; - this->notify_ready(); + this->ready_callback_.call(); } #endif From c7e8a3eea56c7aab64348d16280f0f6350411e6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:43:22 -0600 Subject: [PATCH 8/9] reduce --- .../alarm_control_panel/automation.h | 99 +++---------------- 1 file changed, 12 insertions(+), 87 deletions(-) diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index b9a75faad8..af4a14e27a 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -14,12 +14,12 @@ class StateTrigger : public Trigger<> { } }; -/// Trigger when entering TRIGGERED state -class TriggeredTrigger : public Trigger<> { +/// Template trigger that fires when entering a specific state +template class StateEnterTrigger : public Trigger<> { public: - explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_TRIGGERED) + if (this->alarm_control_panel_->get_state() == State) this->trigger(); }); } @@ -28,89 +28,14 @@ class TriggeredTrigger : public Trigger<> { AlarmControlPanel *alarm_control_panel_; }; -/// Trigger when entering ARMING state -class ArmingTrigger : public Trigger<> { - public: - explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMING) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -/// Trigger when entering PENDING state -class PendingTrigger : public Trigger<> { - public: - explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_PENDING) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -/// Trigger when entering ARMED_HOME state -class ArmedHomeTrigger : public Trigger<> { - public: - explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMED_HOME) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -/// Trigger when entering ARMED_NIGHT state -class ArmedNightTrigger : public Trigger<> { - public: - explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMED_NIGHT) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -/// Trigger when entering ARMED_AWAY state -class ArmedAwayTrigger : public Trigger<> { - public: - explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_ARMED_AWAY) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -/// Trigger when entering DISARMED state -class DisarmedTrigger : public Trigger<> { - public: - explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == ACP_STATE_DISARMED) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; +// Type aliases for state-specific triggers +using TriggeredTrigger = StateEnterTrigger; +using ArmingTrigger = StateEnterTrigger; +using PendingTrigger = StateEnterTrigger; +using ArmedHomeTrigger = StateEnterTrigger; +using ArmedNightTrigger = StateEnterTrigger; +using ArmedAwayTrigger = StateEnterTrigger; +using DisarmedTrigger = StateEnterTrigger; /// Trigger when leaving TRIGGERED state (alarm cleared) class ClearedTrigger : public Trigger<> { From b872d105832e5aa774906fd8cf7eac2296efb1d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:52:46 -0600 Subject: [PATCH 9/9] simplify --- esphome/components/camera/camera.h | 2 +- esphome/components/esp32_camera/esp32_camera.h | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index 9f46eb0c43..6e1fc8cc06 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -45,7 +45,7 @@ class CameraImage; */ class CameraListener { public: - virtual void on_camera_image(const std::shared_ptr &image) = 0; + virtual void on_camera_image(const std::shared_ptr &image) {} virtual void on_stream_start() {} virtual void on_stream_stop() {} }; diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 96b11db65c..54a7d6064a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -232,14 +232,12 @@ class ESP32CameraImageTrigger : public Trigger, public camera:: class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } - void on_camera_image(const std::shared_ptr &image) override {} void on_stream_start() override { this->trigger(); } }; class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } - void on_camera_image(const std::shared_ptr &image) override {} void on_stream_stop() override { this->trigger(); } };