[alarm_control_panel] Remove redundant per-state callbacks (#12171)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2025-12-19 11:48:14 -10:00
committed by GitHub
parent 988b888c63
commit ada6c42f3f
5 changed files with 453 additions and 147 deletions

View File

@@ -35,26 +35,12 @@ 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;
// 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
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();
}
// Cleared fires when leaving TRIGGERED state
if (prev_state == ACP_STATE_TRIGGERED) {
this->cleared_callback_.call();
}
@@ -69,34 +55,6 @@ void AlarmControlPanel::add_on_state_callback(std::function<void()> &&callback)
this->state_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_triggered_callback(std::function<void()> &&callback) {
this->triggered_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_arming_callback(std::function<void()> &&callback) {
this->arming_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_armed_home_callback(std::function<void()> &&callback) {
this->armed_home_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_armed_night_callback(std::function<void()> &&callback) {
this->armed_night_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_armed_away_callback(std::function<void()> &&callback) {
this->armed_away_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_pending_callback(std::function<void()> &&callback) {
this->pending_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_disarmed_callback(std::function<void()> &&callback) {
this->disarmed_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_cleared_callback(std::function<void()> &&callback) {
this->cleared_callback_.add(std::move(callback));
}

View File

@@ -35,54 +35,13 @@ class AlarmControlPanel : public EntityBase {
*/
void publish_state(AlarmControlPanelState state);
/** Add a callback for when the state of the alarm_control_panel 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 callback The callback function
*/
void add_on_state_callback(std::function<void()> &&callback);
/** Add a callback for when the state of the alarm_control_panel chanes to triggered
*
* @param callback The callback function
*/
void add_on_triggered_callback(std::function<void()> &&callback);
/** Add a callback for when the state of the alarm_control_panel chanes to arming
*
* @param callback The callback function
*/
void add_on_arming_callback(std::function<void()> &&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<void()> &&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<void()> &&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<void()> &&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<void()> &&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<void()> &&callback);
/** Add a callback for when the state of the alarm_control_panel clears from triggered
*
* @param callback The callback function
@@ -172,23 +131,9 @@ class AlarmControlPanel : public EntityBase {
uint32_t last_update_;
// the call control function
virtual void control(const AlarmControlPanelCall &call) = 0;
// state callback
// state callback - triggers check get_state() for specific state
CallbackManager<void()> state_callback_{};
// trigger callback
CallbackManager<void()> triggered_callback_{};
// arming callback
CallbackManager<void()> arming_callback_{};
// pending callback
CallbackManager<void()> pending_callback_{};
// armed_home callback
CallbackManager<void()> armed_home_callback_{};
// armed_night callback
CallbackManager<void()> armed_night_callback_{};
// armed_away callback
CallbackManager<void()> armed_away_callback_{};
// disarmed callback
CallbackManager<void()> disarmed_callback_{};
// clear callback
// clear callback - fires when leaving TRIGGERED state
CallbackManager<void()> cleared_callback_{};
// chime callback
CallbackManager<void()> chime_callback_{};

View File

@@ -6,6 +6,7 @@
namespace esphome {
namespace alarm_control_panel {
/// Trigger on any state change
class StateTrigger : public Trigger<> {
public:
explicit StateTrigger(AlarmControlPanel *alarm_control_panel) {
@@ -13,55 +14,30 @@ class StateTrigger : public Trigger<> {
}
};
class TriggeredTrigger : public Trigger<> {
/// Template trigger that fires when entering a specific state
template<AlarmControlPanelState State> class StateEnterTrigger : public Trigger<> {
public:
explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_triggered_callback([this]() { this->trigger(); });
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() == State)
this->trigger();
});
}
protected:
AlarmControlPanel *alarm_control_panel_;
};
class ArmingTrigger : public Trigger<> {
public:
explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_arming_callback([this]() { this->trigger(); });
}
};
class PendingTrigger : public Trigger<> {
public:
explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_pending_callback([this]() { this->trigger(); });
}
};
class ArmedHomeTrigger : public Trigger<> {
public:
explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_armed_home_callback([this]() { this->trigger(); });
}
};
class ArmedNightTrigger : public Trigger<> {
public:
explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_armed_night_callback([this]() { this->trigger(); });
}
};
class ArmedAwayTrigger : public Trigger<> {
public:
explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_armed_away_callback([this]() { this->trigger(); });
}
};
class DisarmedTrigger : public Trigger<> {
public:
explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_disarmed_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 leaving TRIGGERED state (alarm cleared)
class ClearedTrigger : public Trigger<> {
public:
explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) {
@@ -69,6 +45,7 @@ class ClearedTrigger : public Trigger<> {
}
};
/// Trigger on chime event (zone opened while disarmed)
class ChimeTrigger : public Trigger<> {
public:
explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) {
@@ -76,6 +53,7 @@ class ChimeTrigger : public Trigger<> {
}
};
/// Trigger on ready state change
class ReadyTrigger : public Trigger<> {
public:
explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) {

View File

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

View File

@@ -0,0 +1,319 @@
"""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 .state_utils import InitialStateHelper
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()
# 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(
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
# ===== 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 no state changes
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(state_event.wait(), timeout=0.5)
# 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(
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 (initial DISARMED is handled by InitialStateHelper)
expected_states = [
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 (50ms)
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 (100ms)
# 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