From d3918dc784d0680c1f69c2e0ed9c43112245292f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:39:05 -0600 Subject: [PATCH] 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,