diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 8128ada4b5ba..f6d38e2b61e6 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -22,7 +22,13 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_TRIGGER_TIME, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -280,6 +286,7 @@ class ManualMQTTAlarm(AlarmControlPanelEntity): self._disarm_after_trigger = disarm_after_trigger self._previous_state = self._state self._state_ts = None + self._scheduled_update_unsubs: list[CALLBACK_TYPE] = [] self._delay_time_by_state = { state: config[state][CONF_DELAY_TIME] @@ -360,6 +367,7 @@ class ManualMQTTAlarm(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._async_validate_code(code, AlarmControlPanelState.DISARMED) + self._async_cancel_scheduled_updates() self._state = AlarmControlPanelState.DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() @@ -415,22 +423,31 @@ class ManualMQTTAlarm(AlarmControlPanelEntity): self._state_ts = dt_util.utcnow() self.async_write_ha_state() + # Deadlines derive from the current state, so earlier timers are obsolete. + self._async_cancel_scheduled_updates() pending_time = self._pending_time(state) if state == AlarmControlPanelState.TRIGGERED: - async_track_point_in_time( - self._hass, self.async_scheduled_update, self._state_ts + pending_time - ) + self._async_schedule_update(self._state_ts + pending_time) trigger_time = self._trigger_time_by_state[self._previous_state] - async_track_point_in_time( - self._hass, - self.async_scheduled_update, - self._state_ts + pending_time + trigger_time, - ) + self._async_schedule_update(self._state_ts + pending_time + trigger_time) elif state in SUPPORTED_PENDING_STATES and pending_time: + self._async_schedule_update(self._state_ts + pending_time) + + @callback + def _async_schedule_update(self, point_in_time: datetime.datetime) -> None: + """Schedule a state update at a point in time.""" + self._scheduled_update_unsubs.append( async_track_point_in_time( - self._hass, self.async_scheduled_update, self._state_ts + pending_time + self._hass, self.async_scheduled_update, point_in_time ) + ) + + @callback + def _async_cancel_scheduled_updates(self) -> None: + """Cancel pending scheduled state updates.""" + while self._scheduled_update_unsubs: + self._scheduled_update_unsubs.pop()() def _async_validate_code(self, code, state): """Validate given code.""" @@ -470,8 +487,11 @@ class ManualMQTTAlarm(AlarmControlPanelEntity): @override async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" - async_track_state_change_event( - self.hass, [self.entity_id], self._async_state_changed_listener + self.async_on_remove(self._async_cancel_scheduled_updates) + self.async_on_remove( + async_track_state_change_event( + self.hass, [self.entity_id], self._async_state_changed_listener + ) ) async def message_received(msg): @@ -492,8 +512,10 @@ class ManualMQTTAlarm(AlarmControlPanelEntity): _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - await mqtt.async_subscribe( - self.hass, self._command_topic, message_received, self._qos + self.async_on_remove( + await mqtt.async_subscribe( + self.hass, self._command_topic, message_received, self._qos + ) ) async def _async_state_changed_listener( diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8bfb5aacd8b1..3ad6b1915519 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -1,13 +1,14 @@ """The tests for the manual_mqtt Alarm Control Panel component.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import Mock, patch from freezegun import freeze_time import pytest from homeassistant.components import alarm_control_panel from homeassistant.components.alarm_control_panel import AlarmControlPanelState +from homeassistant.components.manual_mqtt.alarm_control_panel import ManualMQTTAlarm from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -17,8 +18,9 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_VACATION, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import DATA_INSTANCES from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1556,6 +1558,105 @@ async def test_state_changes_are_published_to_mqtt( ) +async def test_subscriptions_removed_with_entity( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test a removed alarm ignores commands and stops publishing its state.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + alarm_control_panel.DOMAIN: { + "platform": "manual_mqtt", + "name": "test", + "pending_time": 0, + "state_topic": "alarm/state", + "command_topic": "alarm/command", + } + }, + ) + await hass.async_block_till_done() + + entity_id = "alarm_control_panel.test" + entity = hass.data[DATA_INSTANCES][alarm_control_panel.DOMAIN].get_entity(entity_id) + await entity.async_remove() + assert hass.states.get(entity_id) is None + + async_fire_mqtt_message(hass, "alarm/command", "ARM_AWAY") + await hass.async_block_till_done() + assert entity.alarm_state == AlarmControlPanelState.DISARMED + + mqtt_mock.async_publish.reset_mock() + hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_AWAY) + await hass.async_block_till_done() + mqtt_mock.async_publish.assert_not_called() + + +async def _async_setup_timed_alarm(hass: HomeAssistant) -> ManualMQTTAlarm: + """Set up an alarm with pending and trigger times and return its entity.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + alarm_control_panel.DOMAIN: { + "platform": "manual_mqtt", + "name": "test", + "pending_time": 10, + "trigger_time": 10, + "delay_time": 0, + "code_arm_required": False, + "state_topic": "alarm/state", + "command_topic": "alarm/command", + } + }, + ) + await hass.async_block_till_done() + return hass.data[DATA_INSTANCES][alarm_control_panel.DOMAIN].get_entity( + "alarm_control_panel.test" + ) + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_scheduled_updates_cancelled_on_removal(hass: HomeAssistant) -> None: + """Test removing the alarm cancels its pending scheduled updates.""" + entity = await _async_setup_timed_alarm(hass) + + with patch.object( + entity, "async_scheduled_update", callback(Mock()) + ) as mock_scheduled_update: + await common.async_alarm_arm_away(hass, entity_id=entity.entity_id) + assert len(entity._scheduled_update_unsubs) == 1 + + await entity.async_remove() + assert entity._scheduled_update_unsubs == [] + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=11)) + await hass.async_block_till_done() + + mock_scheduled_update.assert_not_called() + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_scheduled_updates_replaced_on_state_change( + hass: HomeAssistant, +) -> None: + """Test state changes replace obsolete scheduled updates instead of piling up.""" + entity = await _async_setup_timed_alarm(hass) + + for _ in range(3): + await common.async_alarm_arm_home(hass, entity_id=entity.entity_id) + assert len(entity._scheduled_update_unsubs) == 1 + + await common.async_alarm_arm_away(hass, entity_id=entity.entity_id) + assert len(entity._scheduled_update_unsubs) == 1 + + await common.async_alarm_trigger(hass, entity_id=entity.entity_id) + assert len(entity._scheduled_update_unsubs) == 2 + + await common.async_alarm_disarm(hass, entity_id=entity.entity_id) + assert entity._scheduled_update_unsubs == [] + + async def test_no_mqtt(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test publishing of MQTT messages when state changes.""" assert await async_setup_component(