Fix entity removal leaks in manual_mqtt (#183863)

This commit is contained in:
Erik Montnemery
2026-10-01 10:31:21 +02:00
committed by GitHub
parent df86353e58
commit dfea4b8e5e
2 changed files with 139 additions and 16 deletions
@@ -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(
@@ -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(