This commit is contained in:
J. Nick Koston
2025-11-28 16:39:05 -06:00
parent 2060ed0a92
commit d3918dc784
6 changed files with 173 additions and 97 deletions

View File

@@ -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<void()> &&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<void()> &&callback) {
this->cleared_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_chime_callback(std::function<void()> &&callback) {
this->chime_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_ready_callback(std::function<void()> &&callback) {
this->ready_callback_.add(std::move(callback));
}
void AlarmControlPanel::arm_away(optional<std::string> code) {

View File

@@ -1,37 +1,17 @@
#pragma once
#include <vector>
#include <map>
#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<void()> &&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<void()> &&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<void()> &&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<void()> &&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<AlarmControlPanelStateListener *> state_listeners_;
// registered event listeners (chime/ready/etc)
std::vector<AlarmControlPanelEventListener *> event_listeners_;
// state callback - triggers check get_state() for specific state
CallbackManager<void()> state_callback_{};
// clear callback - fires when leaving TRIGGERED state
CallbackManager<void()> cleared_callback_{};
// chime callback
CallbackManager<void()> chime_callback_{};
// ready callback
CallbackManager<void()> ready_callback_{};
};
} // namespace alarm_control_panel

View File

@@ -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<AlarmControlPanelState State>
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<ACP_STATE_TRIGGERED>;
using ArmingTrigger = StateEnterTrigger<ACP_STATE_ARMING>;
using PendingTrigger = StateEnterTrigger<ACP_STATE_PENDING>;
using ArmedHomeTrigger = StateEnterTrigger<ACP_STATE_ARMED_HOME>;
using ArmedNightTrigger = StateEnterTrigger<ACP_STATE_ARMED_NIGHT>;
using ArmedAwayTrigger = StateEnterTrigger<ACP_STATE_ARMED_AWAY>;
using DisarmedTrigger = StateEnterTrigger<ACP_STATE_DISARMED>;
/// 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<typename... Ts> class ArmAwayAction : public Action<Ts...> {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,