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
This commit is contained in:
Felipe Martins Diel 2021-05-20 03:10:13 -03:00 committed by GitHub
parent a021fe301c
commit 7350942e9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 183 additions and 2 deletions

View File

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

View File

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

View File

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