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.""" """The Broadlink integration."""
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .const import DOMAIN from .const import DOMAIN
from .device import BroadlinkDevice from .device import BroadlinkDevice
from .heartbeat import BroadlinkHeartbeat
@dataclass @dataclass
@ -11,6 +14,7 @@ class BroadlinkData:
devices: dict = field(default_factory=dict) devices: dict = field(default_factory=dict)
platforms: dict = field(default_factory=dict) platforms: dict = field(default_factory=dict)
heartbeat: BroadlinkHeartbeat | None = None
async def async_setup(hass, config): async def async_setup(hass, config):
@ -21,11 +25,25 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, entry): async def async_setup_entry(hass, entry):
"""Set up a Broadlink device from a config 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) device = BroadlinkDevice(hass, entry)
return await device.async_setup() return await device.async_setup()
async def async_unload_entry(hass, entry): async def async_unload_entry(hass, entry):
"""Unload a config entry.""" """Unload a config entry."""
device = hass.data[DOMAIN].devices.pop(entry.entry_id) data = hass.data[DOMAIN]
return await device.async_unload()
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)