Convert dhcp watcher to asyncio (#109938)

This commit is contained in:
J. Nick Koston 2024-02-08 22:23:42 -06:00 committed by GitHub
parent 261f9c5d62
commit 122ac059bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 111 additions and 268 deletions

View File

@ -3,19 +3,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Callable, Iterable
import contextlib
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from fnmatch import translate
from functools import lru_cache
import itertools
import logging
import os
import re
import threading
from typing import TYPE_CHECKING, Any, Final, cast
from typing import Any, Final
import aiodhcpwatcher
from aiodiscover import DiscoverHosts
from aiodiscover.discovery import (
HOSTNAME as DISCOVERY_HOSTNAME,
@ -23,8 +21,6 @@ from aiodiscover.discovery import (
MAC_ADDRESS as DISCOVERY_MAC_ADDRESS,
)
from cached_ipaddress import cached_ip_addresses
from scapy.config import conf
from scapy.error import Scapy_Exception
from homeassistant import config_entries
from homeassistant.components.device_tracker import (
@ -61,20 +57,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp
from .const import DOMAIN
if TYPE_CHECKING:
from scapy.packet import Packet
from scapy.sendrecv import AsyncSniffer
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
FILTER = "udp and (port 67 or 68)"
REQUESTED_ADDR = "requested_addr"
MESSAGE_TYPE = "message-type"
HOSTNAME: Final = "hostname"
MAC_ADDRESS: Final = "macaddress"
IP_ADDRESS: Final = "ip"
REGISTERED_DEVICES: Final = "registered_devices"
DHCP_REQUEST = 3
SCAN_INTERVAL = timedelta(minutes=60)
@ -144,22 +133,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# everything else starts up or we will miss events
for passive_cls in (DeviceTrackerRegisteredWatcher, DeviceTrackerWatcher):
passive_watcher = passive_cls(hass, address_data, integration_matchers)
await passive_watcher.async_start()
passive_watcher.async_start()
watchers.append(passive_watcher)
async def _initialize(event: Event) -> None:
async def _async_initialize(event: Event) -> None:
await aiodhcpwatcher.async_init()
for active_cls in (DHCPWatcher, NetworkWatcher):
active_watcher = active_cls(hass, address_data, integration_matchers)
await active_watcher.async_start()
active_watcher.async_start()
watchers.append(active_watcher)
async def _async_stop(event: Event) -> None:
@callback
def _async_stop(event: Event) -> None:
for watcher in watchers:
await watcher.async_stop()
watcher.async_stop()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_initialize)
return True
@ -178,21 +170,20 @@ class WatcherBase(ABC):
self.hass = hass
self._integration_matchers = integration_matchers
self._address_data = address_data
self._unsub: Callable[[], None] | None = None
@callback
def async_stop(self) -> None:
"""Stop scanning for new devices on the network."""
if self._unsub:
self._unsub()
self._unsub = None
@abstractmethod
async def async_stop(self) -> None:
"""Stop the watcher."""
@abstractmethod
async def async_start(self) -> None:
@callback
def async_start(self) -> None:
"""Start the watcher."""
def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None:
"""Process a client."""
self.hass.loop.call_soon_threadsafe(
self.async_process_client, ip_address, hostname, mac_address
)
@callback
def async_process_client(
self, ip_address: str, hostname: str, mac_address: str
@ -291,20 +282,19 @@ class NetworkWatcher(WatcherBase):
) -> None:
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
self._unsub: Callable[[], None] | None = None
self._discover_hosts: DiscoverHosts | None = None
self._discover_task: asyncio.Task | None = None
async def async_stop(self) -> None:
@callback
def async_stop(self) -> None:
"""Stop scanning for new devices on the network."""
if self._unsub:
self._unsub()
self._unsub = None
super().async_stop()
if self._discover_task:
self._discover_task.cancel()
self._discover_task = None
async def async_start(self) -> None:
@callback
def async_start(self) -> None:
"""Start scanning for new devices on the network."""
self._discover_hosts = DiscoverHosts()
self._unsub = async_track_time_interval(
@ -336,23 +326,8 @@ class NetworkWatcher(WatcherBase):
class DeviceTrackerWatcher(WatcherBase):
"""Class to watch dhcp data from routers."""
def __init__(
self,
hass: HomeAssistant,
address_data: dict[str, dict[str, str]],
integration_matchers: DhcpMatchers,
) -> None:
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
self._unsub: Callable[[], None] | None = None
async def async_stop(self) -> None:
"""Stop watching for new device trackers."""
if self._unsub:
self._unsub()
self._unsub = None
async def async_start(self) -> None:
@callback
def async_start(self) -> None:
"""Stop watching for new device trackers."""
self._unsub = async_track_state_added_domain(
self.hass, [DEVICE_TRACKER_DOMAIN], self._async_process_device_event
@ -391,23 +366,8 @@ class DeviceTrackerWatcher(WatcherBase):
class DeviceTrackerRegisteredWatcher(WatcherBase):
"""Class to watch data from device tracker registrations."""
def __init__(
self,
hass: HomeAssistant,
address_data: dict[str, dict[str, str]],
integration_matchers: DhcpMatchers,
) -> None:
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
self._unsub: Callable[[], None] | None = None
async def async_stop(self) -> None:
"""Stop watching for device tracker registrations."""
if self._unsub:
self._unsub()
self._unsub = None
async def async_start(self) -> None:
@callback
def async_start(self) -> None:
"""Stop watching for device tracker registrations."""
self._unsub = async_dispatcher_connect(
self.hass, CONNECTED_DEVICE_REGISTERED, self._async_process_device_data
@ -429,114 +389,17 @@ class DeviceTrackerRegisteredWatcher(WatcherBase):
class DHCPWatcher(WatcherBase):
"""Class to watch dhcp requests."""
def __init__(
self,
hass: HomeAssistant,
address_data: dict[str, dict[str, str]],
integration_matchers: DhcpMatchers,
) -> None:
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
self._sniffer: AsyncSniffer | None = None
self._started = threading.Event()
async def async_stop(self) -> None:
"""Stop watching for new device trackers."""
await self.hass.async_add_executor_job(self._stop)
def _stop(self) -> None:
"""Stop the thread."""
if self._started.is_set():
assert self._sniffer is not None
self._sniffer.stop()
async def async_start(self) -> None:
"""Start watching for dhcp packets."""
await self.hass.async_add_executor_job(self._start)
def _start(self) -> None:
"""Start watching for dhcp packets."""
# Local import because importing from scapy has side effects such as opening
# sockets
from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401
from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel
from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel
from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel
#
# Importing scapy.sendrecv will cause a scapy resync which will
# import scapy.arch.read_routes which will import scapy.sendrecv
#
# We avoid this circular import by importing arch above to ensure
# the module is loaded and avoid the problem
#
from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel
AsyncSniffer,
@callback
def _async_process_dhcp_request(self, response: aiodhcpwatcher.DHCPRequest) -> None:
"""Process a dhcp request."""
self.async_process_client(
response.ip_address, response.hostname, _format_mac(response.mac_address)
)
def _handle_dhcp_packet(packet: Packet) -> None:
"""Process a dhcp packet."""
if DHCP not in packet:
return
options_dict = _dhcp_options_as_dict(packet[DHCP].options)
if options_dict.get(MESSAGE_TYPE) != DHCP_REQUEST:
# Not a DHCP request
return
ip_address = options_dict.get(REQUESTED_ADDR) or cast(str, packet[IP].src)
assert isinstance(ip_address, str)
hostname = ""
if (hostname_bytes := options_dict.get(HOSTNAME)) and isinstance(
hostname_bytes, bytes
):
with contextlib.suppress(AttributeError, UnicodeDecodeError):
hostname = hostname_bytes.decode()
mac_address = _format_mac(cast(str, packet[Ether].src))
if ip_address is not None and mac_address is not None:
self.process_client(ip_address, hostname, mac_address)
# disable scapy promiscuous mode as we do not need it
conf.sniff_promisc = 0
try:
_verify_l2socket_setup(FILTER)
except (Scapy_Exception, OSError) as ex:
if os.geteuid() == 0:
_LOGGER.error("Cannot watch for dhcp packets: %s", ex)
else:
_LOGGER.debug(
"Cannot watch for dhcp packets without root or CAP_NET_RAW: %s", ex
)
return
try:
_verify_working_pcap(FILTER)
except (Scapy_Exception, ImportError) as ex:
_LOGGER.error(
"Cannot watch for dhcp packets without a functional packet filter: %s",
ex,
)
return
self._sniffer = AsyncSniffer(
filter=FILTER,
started_callback=self._started.set,
prn=_handle_dhcp_packet,
store=0,
)
self._sniffer.start()
if self._sniffer.thread:
self._sniffer.thread.name = self.__class__.__name__
def _dhcp_options_as_dict(
dhcp_options: Iterable[tuple[str, int | bytes | None]],
) -> dict[str, str | int | bytes | None]:
"""Extract data from packet options as a dict."""
return {option[0]: option[1] for option in dhcp_options if len(option) >= 2}
@callback
def async_start(self) -> None:
"""Start watching for dhcp packets."""
self._unsub = aiodhcpwatcher.start(self._async_process_dhcp_request)
def _format_mac(mac_address: str) -> str:
@ -544,33 +407,6 @@ def _format_mac(mac_address: str) -> str:
return format_mac(mac_address).replace(":", "")
def _verify_l2socket_setup(cap_filter: str) -> None:
"""Create a socket using the scapy configured l2socket.
Try to create the socket
to see if we have permissions
since AsyncSniffer will do it another
thread so we will not be able to capture
any permission or bind errors.
"""
conf.L2socket(filter=cap_filter)
def _verify_working_pcap(cap_filter: str) -> None:
"""Verify we can create a packet filter.
If we cannot create a filter we will be listening for
all traffic which is too intensive.
"""
# Local import because importing from scapy has side effects such as opening
# sockets
from scapy.arch.common import ( # pylint: disable=import-outside-toplevel
compile_filter,
)
compile_filter(cap_filter)
@lru_cache(maxsize=4096, typed=True)
def _compile_fnmatch(pattern: str) -> re.Pattern:
"""Compile a fnmatch pattern."""

View File

@ -5,10 +5,16 @@
"documentation": "https://www.home-assistant.io/integrations/dhcp",
"integration_type": "system",
"iot_class": "local_push",
"loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"],
"loggers": [
"aiodiscover",
"aiodhcpwatcher",
"dnspython",
"pyroute2",
"scapy"
],
"quality_scale": "internal",
"requirements": [
"scapy==2.5.0",
"aiodhcpwatcher==0.8.0",
"aiodiscover==1.6.1",
"cached_ipaddress==0.3.0"
]

View File

@ -1,5 +1,6 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==0.8.0
aiodiscover==1.6.1
aiohttp-fast-url-dispatcher==0.3.0
aiohttp-zlib-ng==0.3.1
@ -51,7 +52,6 @@ PyTurboJPEG==1.7.1
pyudev==0.23.2
PyYAML==6.0.1
requests==2.31.0
scapy==2.5.0
SQLAlchemy==2.0.25
typing-extensions>=4.9.0,<5.0
ulid-transform==0.9.0

View File

@ -220,6 +220,9 @@ aiobotocore==2.9.1
# homeassistant.components.comelit
aiocomelit==0.8.3
# homeassistant.components.dhcp
aiodhcpwatcher==0.8.0
# homeassistant.components.dhcp
aiodiscover==1.6.1
@ -2485,9 +2488,6 @@ samsungtvws[async,encrypted]==2.6.0
# homeassistant.components.satel_integra
satel-integra==0.3.7
# homeassistant.components.dhcp
scapy==2.5.0
# homeassistant.components.screenlogic
screenlogicpy==0.10.0

View File

@ -199,6 +199,9 @@ aiobotocore==2.9.1
# homeassistant.components.comelit
aiocomelit==0.8.3
# homeassistant.components.dhcp
aiodhcpwatcher==0.8.0
# homeassistant.components.dhcp
aiodiscover==1.6.1
@ -1895,9 +1898,6 @@ samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
samsungtvws[async,encrypted]==2.6.0
# homeassistant.components.dhcp
scapy==2.5.0
# homeassistant.components.screenlogic
screenlogicpy==0.10.0

View File

@ -3,10 +3,14 @@ from collections.abc import Awaitable, Callable
import datetime
import threading
from typing import Any, cast
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import aiodhcpwatcher
import pytest
from scapy import arch # noqa: F401
from scapy import (
arch, # noqa: F401
interfaces,
)
from scapy.error import Scapy_Exception
from scapy.layers.dhcp import DHCP
from scapy.layers.l2 import Ether
@ -140,29 +144,18 @@ async def _async_get_handle_dhcp_packet(
{},
integration_matchers,
)
async_handle_dhcp_packet: Callable[[Any], Awaitable[None]] | None = None
with patch("aiodhcpwatcher.start"):
dhcp_watcher.async_start()
def _mock_sniffer(*args, **kwargs):
nonlocal async_handle_dhcp_packet
callback = kwargs["prn"]
def _async_handle_dhcp_request(request: aiodhcpwatcher.DHCPRequest) -> None:
dhcp_watcher._async_process_dhcp_request(request)
async def _async_handle_dhcp_packet(packet):
await hass.async_add_executor_job(callback, packet)
handler = aiodhcpwatcher.make_packet_handler(_async_handle_dhcp_request)
async_handle_dhcp_packet = _async_handle_dhcp_packet
return MagicMock()
async def _async_handle_dhcp_packet(packet):
handler(packet)
with patch(
"homeassistant.components.dhcp._verify_l2socket_setup",
), patch(
"scapy.arch.common.compile_filter",
), patch(
"scapy.sendrecv.AsyncSniffer",
_mock_sniffer,
):
await dhcp_watcher.async_start()
return cast("Callable[[Any], Awaitable[None]]", async_handle_dhcp_packet)
return cast("Callable[[Any], Awaitable[None]]", _async_handle_dhcp_packet)
async def test_dhcp_match_hostname_and_macaddress(hass: HomeAssistant) -> None:
@ -541,9 +534,10 @@ async def test_setup_and_stop(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
with patch("scapy.sendrecv.AsyncSniffer.start") as start_call, patch(
"homeassistant.components.dhcp._verify_l2socket_setup",
), patch("scapy.arch.common.compile_filter"), patch(
with patch.object(
interfaces,
"resolve_iface",
) as resolve_iface_call, patch("scapy.arch.common.compile_filter"), patch(
"homeassistant.components.dhcp.DiscoverHosts.async_discover"
):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
@ -552,7 +546,7 @@ async def test_setup_and_stop(hass: HomeAssistant) -> None:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
start_call.assert_called_once()
resolve_iface_call.assert_called_once()
async def test_setup_fails_as_root(
@ -569,8 +563,9 @@ async def test_setup_fails_as_root(
wait_event = threading.Event()
with patch("os.geteuid", return_value=0), patch(
"homeassistant.components.dhcp._verify_l2socket_setup",
with patch("os.geteuid", return_value=0), patch.object(
interfaces,
"resolve_iface",
side_effect=Scapy_Exception,
), patch("homeassistant.components.dhcp.DiscoverHosts.async_discover"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
@ -595,7 +590,10 @@ async def test_setup_fails_non_root(
await hass.async_block_till_done()
with patch("os.geteuid", return_value=10), patch(
"homeassistant.components.dhcp._verify_l2socket_setup",
"scapy.arch.common.compile_filter"
), patch.object(
interfaces,
"resolve_iface",
side_effect=Scapy_Exception,
), patch("homeassistant.components.dhcp.DiscoverHosts.async_discover"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
@ -618,10 +616,13 @@ async def test_setup_fails_with_broken_libpcap(
)
await hass.async_block_till_done()
with patch("homeassistant.components.dhcp._verify_l2socket_setup"), patch(
with patch(
"scapy.arch.common.compile_filter",
side_effect=ImportError,
) as compile_filter, patch("scapy.sendrecv.AsyncSniffer") as async_sniffer, patch(
) as compile_filter, patch.object(
interfaces,
"resolve_iface",
) as resolve_iface_call, patch(
"homeassistant.components.dhcp.DiscoverHosts.async_discover"
):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
@ -630,7 +631,7 @@ async def test_setup_fails_with_broken_libpcap(
await hass.async_block_till_done()
assert compile_filter.called
assert not async_sniffer.called
assert not resolve_iface_call.called
assert (
"Cannot watch for dhcp packets without a functional packet filter"
in caplog.text
@ -666,9 +667,9 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
@ -699,7 +700,7 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None:
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
async_dispatcher_send(
hass,
@ -718,7 +719,7 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None:
hostname="connect",
macaddress="b8b7f16db533",
)
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
@ -738,7 +739,7 @@ async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> N
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
async_dispatcher_send(
hass,
@ -748,7 +749,7 @@ async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> N
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
@ -771,7 +772,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start(
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
@ -784,7 +785,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start(
},
)
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
@ -818,7 +819,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home(
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
@ -831,7 +832,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home(
},
)
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
@ -848,7 +849,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router(
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
@ -861,7 +862,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router(
},
)
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
@ -878,7 +879,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
@ -890,7 +891,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi
},
)
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
@ -907,7 +908,7 @@ async def test_device_tracker_invalid_ip_address(
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
@ -919,7 +920,7 @@ async def test_device_tracker_invalid_ip_address(
},
)
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert "Ignoring invalid IP Address: invalid" in caplog.text
@ -955,9 +956,9 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start(
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
@ -988,9 +989,9 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None:
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
@ -1047,9 +1048,9 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname(
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 2
@ -1092,7 +1093,7 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) -
]
),
)
await device_tracker_watcher.async_start()
device_tracker_watcher.async_start()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
@ -1109,7 +1110,7 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) -
):
async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=65))
await hass.async_block_till_done()
await device_tracker_watcher.async_stop()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1