From 7350942e9e2d68803c5e51c7d6796c4234363b7d Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Thu, 20 May 2021 03:10:13 -0300 Subject: [PATCH] Implement heartbeat in the Broadlink integration (#43878) * Implement heartbeat in the Broadlink integration * Rename INTERVAL to HEARTBEAT_INTERVAL * Test that we log an error message when the heartbeat fails --- .../components/broadlink/__init__.py | 22 +++- .../components/broadlink/heartbeat.py | 55 +++++++++ tests/components/broadlink/test_heartbeat.py | 108 ++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/broadlink/heartbeat.py create mode 100644 tests/components/broadlink/test_heartbeat.py diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 501afaac930..eb39a057434 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -1,8 +1,11 @@ """The Broadlink integration.""" +from __future__ import annotations + from dataclasses import dataclass, field from .const import DOMAIN from .device import BroadlinkDevice +from .heartbeat import BroadlinkHeartbeat @dataclass @@ -11,6 +14,7 @@ class BroadlinkData: devices: dict = field(default_factory=dict) platforms: dict = field(default_factory=dict) + heartbeat: BroadlinkHeartbeat | None = None async def async_setup(hass, config): @@ -21,11 +25,25 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up a Broadlink device from a config entry.""" + data = hass.data[DOMAIN] + + if data.heartbeat is None: + data.heartbeat = BroadlinkHeartbeat(hass) + hass.async_create_task(data.heartbeat.async_setup()) + device = BroadlinkDevice(hass, entry) return await device.async_setup() async def async_unload_entry(hass, entry): """Unload a config entry.""" - device = hass.data[DOMAIN].devices.pop(entry.entry_id) - return await device.async_unload() + data = hass.data[DOMAIN] + + device = data.devices.pop(entry.entry_id) + result = await device.async_unload() + + if not data.devices: + await data.heartbeat.async_unload() + data.heartbeat = None + + return result diff --git a/homeassistant/components/broadlink/heartbeat.py b/homeassistant/components/broadlink/heartbeat.py new file mode 100644 index 00000000000..282df3ae6a8 --- /dev/null +++ b/homeassistant/components/broadlink/heartbeat.py @@ -0,0 +1,55 @@ +"""Heartbeats for Broadlink devices.""" +import datetime as dt +import logging + +import broadlink as blk + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import event + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BroadlinkHeartbeat: + """Manages heartbeats in the Broadlink integration. + + Some devices reboot when they cannot reach the cloud. This mechanism + feeds their watchdog timers so they can be used offline. + """ + + HEARTBEAT_INTERVAL = dt.timedelta(minutes=2) + + def __init__(self, hass): + """Initialize the heartbeat.""" + self._hass = hass + self._unsubscribe = None + + async def async_setup(self): + """Set up the heartbeat.""" + if self._unsubscribe is None: + await self.async_heartbeat(dt.datetime.now()) + self._unsubscribe = event.async_track_time_interval( + self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL + ) + + async def async_unload(self): + """Unload the heartbeat.""" + if self._unsubscribe is not None: + self._unsubscribe() + self._unsubscribe = None + + async def async_heartbeat(self, now): + """Send packets to feed watchdog timers.""" + hass = self._hass + config_entries = hass.config_entries.async_entries(DOMAIN) + + for entry in config_entries: + host = entry.data[CONF_HOST] + try: + await hass.async_add_executor_job(blk.ping, host) + except OSError as err: + _LOGGER.debug("Failed to send heartbeat to %s: %s", host, err) + else: + _LOGGER.debug("Heartbeat sent to %s", host) diff --git a/tests/components/broadlink/test_heartbeat.py b/tests/components/broadlink/test_heartbeat.py new file mode 100644 index 00000000000..8e52a562425 --- /dev/null +++ b/tests/components/broadlink/test_heartbeat.py @@ -0,0 +1,108 @@ +"""Tests for Broadlink heartbeats.""" +from unittest.mock import call, patch + +from homeassistant.components.broadlink.heartbeat import BroadlinkHeartbeat +from homeassistant.util import dt + +from . import get_device + +from tests.common import async_fire_time_changed + +DEVICE_PING = "homeassistant.components.broadlink.heartbeat.blk.ping" + + +async def test_heartbeat_trigger_startup(hass): + """Test that the heartbeat is initialized with the first config entry.""" + device = get_device("Office") + + with patch(DEVICE_PING) as mock_ping: + await device.setup_entry(hass) + await hass.async_block_till_done() + + assert mock_ping.call_count == 1 + assert mock_ping.call_args == call(device.host) + + +async def test_heartbeat_ignore_oserror(hass, caplog): + """Test that an OSError is ignored.""" + device = get_device("Office") + + with patch(DEVICE_PING, side_effect=OSError()): + await device.setup_entry(hass) + await hass.async_block_till_done() + + assert "Failed to send heartbeat to" in caplog.text + + +async def test_heartbeat_trigger_right_time(hass): + """Test that the heartbeat is triggered at the right time.""" + device = get_device("Office") + + await device.setup_entry(hass) + await hass.async_block_till_done() + + with patch(DEVICE_PING) as mock_ping: + async_fire_time_changed( + hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + ) + await hass.async_block_till_done() + + assert mock_ping.call_count == 1 + assert mock_ping.call_args == call(device.host) + + +async def test_heartbeat_do_not_trigger_before_time(hass): + """Test that the heartbeat is not triggered before the time.""" + device = get_device("Office") + + await device.setup_entry(hass) + await hass.async_block_till_done() + + with patch(DEVICE_PING) as mock_ping: + async_fire_time_changed( + hass, + dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL // 2, + ) + await hass.async_block_till_done() + + assert mock_ping.call_count == 0 + + +async def test_heartbeat_unload(hass): + """Test that the heartbeat is deactivated when the last config entry is removed.""" + device = get_device("Office") + + _, mock_entry = await device.setup_entry(hass) + await hass.async_block_till_done() + + await hass.config_entries.async_remove(mock_entry.entry_id) + await hass.async_block_till_done() + + with patch(DEVICE_PING) as mock_ping: + async_fire_time_changed( + hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + ) + + assert mock_ping.call_count == 0 + + +async def test_heartbeat_do_not_unload(hass): + """Test that the heartbeat is not deactivated until the last config entry is removed.""" + device_a = get_device("Office") + device_b = get_device("Bedroom") + + _, mock_entry_a = await device_a.setup_entry(hass) + await device_b.setup_entry(hass) + await hass.async_block_till_done() + + await hass.config_entries.async_remove(mock_entry_a.entry_id) + await hass.async_block_till_done() + + with patch(DEVICE_PING) as mock_ping: + async_fire_time_changed( + hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + ) + await hass.async_block_till_done() + + assert mock_ping.call_count == 1 + assert mock_ping.call_args == call(device_b.host)