Remove ThreadPool with async executor (#4154)

* Remove ThreadPool with async executor

* Fix zigbee

* update unittest

* fix remote api

* add pending task to remote

* fix lint

* remove unused import

* remove old stuff for lazy tests

* fix bug and add a exception handler to executor

* change executor handling

* change to wait from gather

* fix unittest
This commit is contained in:
Pascal Vizeli 2016-11-05 17:27:55 +01:00 committed by Paulus Schoutsen
parent b67f1fed52
commit ece58ce78f
10 changed files with 37 additions and 346 deletions

View File

@ -165,15 +165,6 @@ def _async_setup_component(hass: core.HomeAssistant,
hass.config.components.append(component.DOMAIN) hass.config.components.append(component.DOMAIN)
# Assumption: if a component does not depend on groups
# it communicates with devices
if (not async_comp and
'group' not in getattr(component, 'DEPENDENCIES', [])):
if hass.pool is None:
hass.async_init_pool()
if hass.pool.worker_count <= 10:
hass.pool.add_worker()
hass.bus.async_fire( hass.bus.async_fire(
EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN}
) )

View File

@ -55,7 +55,7 @@ class ZigBeeTemperatureSensor(Entity):
self._config = config self._config = config
self._temp = None self._temp = None
# Get initial state # Get initial state
hass.add_job(self.update_ha_state, True) hass.add_job(self.async_update_ha_state, True)
@property @property
def name(self): def name(self):

View File

@ -307,7 +307,7 @@ class ZigBeeDigitalIn(Entity):
subscribe(hass, handle_frame) subscribe(hass, handle_frame)
# Get initial state # Get initial state
hass.add_job(self.update_ha_state, True) hass.add_job(self.async_update_ha_state, True)
@property @property
def name(self): def name(self):
@ -433,7 +433,7 @@ class ZigBeeAnalogIn(Entity):
subscribe(hass, handle_frame) subscribe(hass, handle_frame)
# Get initial state # Get initial state
hass.add_job(self.update_ha_state, True) hass.add_job(self.async_update_ha_state, True)
@property @property
def name(self): def name(self):

View File

@ -14,7 +14,7 @@ import re
import signal import signal
import sys import sys
import threading import threading
import time import weakref
from types import MappingProxyType from types import MappingProxyType
from typing import Optional, Any, Callable, List # NOQA from typing import Optional, Any, Callable, List # NOQA
@ -53,16 +53,11 @@ TIMER_INTERVAL = 1 # seconds
# How long we wait for the result of a service call # How long we wait for the result of a service call
SERVICE_CALL_LIMIT = 10 # seconds SERVICE_CALL_LIMIT = 10 # seconds
# Define number of MINIMUM worker threads.
# During bootstrap of HA (see bootstrap._setup_component()) worker threads
# will be added for each component that polls devices.
MIN_WORKER_THREAD = 2
# Pattern for validating entity IDs (format: <domain>.<entity>) # Pattern for validating entity IDs (format: <domain>.<entity>)
ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$") ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$")
# Interval at which we check if the pool is getting busy # Size of a executor pool
MONITOR_POOL_INTERVAL = 30 EXECUTOR_POOL_SIZE = 10
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -115,7 +110,7 @@ class HomeAssistant(object):
self.executor = ThreadPoolExecutor(max_workers=5) self.executor = ThreadPoolExecutor(max_workers=5)
self.loop.set_default_executor(self.executor) self.loop.set_default_executor(self.executor)
self.loop.set_exception_handler(self._async_exception_handler) self.loop.set_exception_handler(self._async_exception_handler)
self.pool = None self._pending_tasks = weakref.WeakSet()
self.bus = EventBus(self) self.bus = EventBus(self)
self.services = ServiceRegistry(self.bus, self.async_add_job, self.services = ServiceRegistry(self.bus, self.async_add_job,
self.loop) self.loop)
@ -190,20 +185,16 @@ class HomeAssistant(object):
self.loop._thread_ident = threading.get_ident() self.loop._thread_ident = threading.get_ident()
_async_create_timer(self) _async_create_timer(self)
self.bus.async_fire(EVENT_HOMEASSISTANT_START) self.bus.async_fire(EVENT_HOMEASSISTANT_START)
if self.pool is not None:
yield from self.loop.run_in_executor(
None, self.pool.block_till_done)
self.state = CoreState.running self.state = CoreState.running
def add_job(self, target: Callable[..., None], *args: Any) -> None: def add_job(self, target: Callable[..., None], *args: Any) -> None:
"""Add job to the worker pool. """Add job to the executor pool.
target: target to call. target: target to call.
args: parameters for method to call. args: parameters for method to call.
""" """
if self.pool is None: run_callback_threadsafe(
run_callback_threadsafe(self.pool, self.async_init_pool).result() self.loop, self.async_add_job, target, *args).result()
self.pool.add_job((target,) + args)
@callback @callback
def async_add_job(self, target: Callable[..., None], *args: Any) -> None: def async_add_job(self, target: Callable[..., None], *args: Any) -> None:
@ -214,14 +205,18 @@ class HomeAssistant(object):
target: target to call. target: target to call.
args: parameters for method to call. args: parameters for method to call.
""" """
task = None
if is_callback(target): if is_callback(target):
self.loop.call_soon(target, *args) self.loop.call_soon(target, *args)
elif asyncio.iscoroutinefunction(target): elif asyncio.iscoroutinefunction(target):
self.loop.create_task(target(*args)) task = self.loop.create_task(target(*args))
else: else:
if self.pool is None: task = self.loop.run_in_executor(None, target, *args)
self.async_init_pool()
self.pool.add_job((target,) + args) # if a task is sheduled
if task is not None:
self._pending_tasks.add(task)
@callback @callback
def async_run_job(self, target: Callable[..., None], *args: Any) -> None: def async_run_job(self, target: Callable[..., None], *args: Any) -> None:
@ -249,38 +244,22 @@ class HomeAssistant(object):
def block_till_done(self) -> None: def block_till_done(self) -> None:
"""Block till all pending work is done.""" """Block till all pending work is done."""
complete = threading.Event() run_coroutine_threadsafe(
self.async_block_till_done(), loop=self.loop).result()
@asyncio.coroutine @asyncio.coroutine
def sleep_wait(): def async_block_till_done(self):
"""Sleep in thread pool.""" """Block till all pending work is done."""
yield from self.loop.run_in_executor(None, time.sleep, 0)
def notify_when_done():
"""Notify event loop when pool done."""
count = 0
while True: while True:
# Wait for the work queue to empty # Wait for the pending tasks are down
if self.pool is not None: if len(self._pending_tasks) > 0:
self.pool.block_till_done() yield from asyncio.wait(self._pending_tasks, loop=self.loop)
# Verify the loop is empty # Verify the loop is empty
if self._loop_empty(): ret = yield from self.loop.run_in_executor(None, self._loop_empty)
count += 1 if ret:
if count == 2:
break break
# sleep in the loop executor, this forces execution back into
# the event loop to avoid the block thread from starving the
# async loop
run_coroutine_threadsafe(sleep_wait(), self.loop).result()
complete.set()
threading.Thread(name="BlockThread", target=notify_when_done).start()
complete.wait()
def stop(self) -> None: def stop(self) -> None:
"""Stop Home Assistant and shuts down all threads.""" """Stop Home Assistant and shuts down all threads."""
run_coroutine_threadsafe(self.async_stop(), self.loop) run_coroutine_threadsafe(self.async_stop(), self.loop)
@ -293,10 +272,7 @@ class HomeAssistant(object):
""" """
self.state = CoreState.stopping self.state = CoreState.stopping
self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) self.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
if self.pool is not None: yield from self.async_block_till_done()
yield from self.loop.run_in_executor(
None, self.pool.block_till_done)
yield from self.loop.run_in_executor(None, self.pool.stop)
self.executor.shutdown() self.executor.shutdown()
if self._websession is not None: if self._websession is not None:
yield from self._websession.close() yield from self._websession.close()
@ -323,23 +299,17 @@ class HomeAssistant(object):
exc_info=exc_info exc_info=exc_info
) )
@callback
def async_init_pool(self):
"""Initialize the worker pool."""
self.pool = create_worker_pool()
_async_monitor_worker_pool(self)
@callback @callback
def _async_stop_handler(self, *args): def _async_stop_handler(self, *args):
"""Stop Home Assistant.""" """Stop Home Assistant."""
self.exit_code = 0 self.exit_code = 0
self.async_add_job(self.async_stop) self.loop.create_task(self.async_stop())
@callback @callback
def _async_restart_handler(self, *args): def _async_restart_handler(self, *args):
"""Restart Home Assistant.""" """Restart Home Assistant."""
self.exit_code = RESTART_EXIT_CODE self.exit_code = RESTART_EXIT_CODE
self.async_add_job(self.async_stop) self.loop.create_task(self.async_stop())
class EventOrigin(enum.Enum): class EventOrigin(enum.Enum):
@ -1196,65 +1166,3 @@ def _async_create_timer(hass, interval=TIMER_INTERVAL):
hass.loop.create_task(timer(interval, stop_event)) hass.loop.create_task(timer(interval, stop_event))
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_timer) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_timer)
def create_worker_pool(worker_count=None):
"""Create a worker pool."""
if worker_count is None:
worker_count = MIN_WORKER_THREAD
def job_handler(job):
"""Called whenever a job is available to do."""
try:
func, *args = job
func(*args)
except Exception: # pylint: disable=broad-except
# Catch any exception our service/event_listener might throw
# We do not want to crash our ThreadPool
_LOGGER.exception("BusHandler:Exception doing job")
return util.ThreadPool(job_handler, worker_count)
def _async_monitor_worker_pool(hass):
"""Create a monitor for the thread pool to check if pool is misbehaving."""
busy_threshold = hass.pool.worker_count * 3
handle = None
def schedule():
"""Schedule the monitor."""
nonlocal handle
handle = hass.loop.call_later(MONITOR_POOL_INTERVAL,
check_pool_threshold)
def check_pool_threshold():
"""Check pool size."""
nonlocal busy_threshold
pending_jobs = hass.pool.queue_size
if pending_jobs < busy_threshold:
schedule()
return
_LOGGER.warning(
"WorkerPool:All %d threads are busy and %d jobs pending",
hass.pool.worker_count, pending_jobs)
for start, job in hass.pool.current_jobs:
_LOGGER.warning("WorkerPool:Current job started at %s: %s",
dt_util.as_local(start).isoformat(), job)
busy_threshold *= 2
schedule()
schedule()
@callback
def stop_monitor(event):
"""Stop the monitor."""
handle.cancel()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_monitor)

View File

@ -16,6 +16,7 @@ import logging
import time import time
import threading import threading
import urllib.parse import urllib.parse
import weakref
from typing import Optional from typing import Optional
@ -127,7 +128,7 @@ class HomeAssistant(ha.HomeAssistant):
self.executor = ThreadPoolExecutor(max_workers=5) self.executor = ThreadPoolExecutor(max_workers=5)
self.loop.set_default_executor(self.executor) self.loop.set_default_executor(self.executor)
self.loop.set_exception_handler(self._async_exception_handler) self.loop.set_exception_handler(self._async_exception_handler)
self.pool = ha.create_worker_pool() self._pending_tasks = weakref.WeakSet()
self.bus = EventBus(remote_api, self) self.bus = EventBus(remote_api, self)
self.services = ha.ServiceRegistry(self.bus, self.add_job, self.loop) self.services = ha.ServiceRegistry(self.bus, self.add_job, self.loop)
@ -176,8 +177,6 @@ class HomeAssistant(ha.HomeAssistant):
self.bus.fire(ha.EVENT_HOMEASSISTANT_STOP, self.bus.fire(ha.EVENT_HOMEASSISTANT_STOP,
origin=ha.EventOrigin.remote) origin=ha.EventOrigin.remote)
self.pool.stop()
# Disconnect master event forwarding # Disconnect master event forwarding
disconnect_remote_events(self.remote_api, self.config.api) disconnect_remote_events(self.remote_api, self.config.api)
self.state = ha.CoreState.not_running self.state = ha.CoreState.not_running

View File

@ -2,7 +2,6 @@
from collections.abc import MutableSet from collections.abc import MutableSet
from itertools import chain from itertools import chain
import threading import threading
import queue
from datetime import datetime from datetime import datetime
import re import re
import enum import enum
@ -302,111 +301,3 @@ class Throttle(object):
throttle[0].release() throttle[0].release()
return wrapper return wrapper
class ThreadPool(object):
"""A priority queue-based thread pool."""
def __init__(self, job_handler, worker_count=0):
"""Initialize the pool.
job_handler: method to be called from worker thread to handle job
worker_count: number of threads to run that handle jobs
busy_callback: method to be called when queue gets too big.
Parameters: worker_count, list of current_jobs,
pending_jobs_count
"""
self._job_handler = job_handler
self.worker_count = 0
self._work_queue = queue.Queue()
self.current_jobs = []
self._quit_task = object()
self.running = True
for _ in range(worker_count):
self.add_worker()
@property
def queue_size(self):
"""Return estimated number of jobs that are waiting to be processed."""
return self._work_queue.qsize()
def add_worker(self):
"""Add worker to the thread pool and reset warning limit."""
if not self.running:
raise RuntimeError("ThreadPool not running")
threading.Thread(
target=self._worker, daemon=True,
name='ThreadPool Worker {}'.format(self.worker_count)).start()
self.worker_count += 1
def remove_worker(self):
"""Remove worker from the thread pool and reset warning limit."""
if not self.running:
raise RuntimeError("ThreadPool not running")
self._work_queue.put(self._quit_task)
self.worker_count -= 1
def add_job(self, job):
"""Add a job to the queue."""
if not self.running:
raise RuntimeError("ThreadPool not running")
self._work_queue.put(job)
def add_many_jobs(self, jobs):
"""Add a list of jobs to the queue."""
if not self.running:
raise RuntimeError("ThreadPool not running")
for job in jobs:
self._work_queue.put(job)
def block_till_done(self):
"""Block till current work is done."""
self._work_queue.join()
def stop(self):
"""Finish all the jobs and stops all the threads."""
self.block_till_done()
if not self.running:
return
# Tell the workers to quit
for _ in range(self.worker_count):
self.remove_worker()
self.running = False
# Wait till all workers have quit
self.block_till_done()
def _worker(self):
"""Handle jobs for the thread pool."""
while True:
# Get new item from work_queue
job = self._work_queue.get()
if job is self._quit_task:
self._work_queue.task_done()
return
# Add to current running jobs
job_log = (utcnow(), job)
self.current_jobs.append(job_log)
# Do the job
self._job_handler(job)
# Remove from current running job
self.current_jobs.remove(job_log)
# Tell work_queue the task is done
self._work_queue.task_done()

View File

@ -40,7 +40,6 @@ def get_test_home_assistant():
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
hass = loop.run_until_complete(async_test_home_assistant(loop)) hass = loop.run_until_complete(async_test_home_assistant(loop))
hass.allow_pool = True
# FIXME should not be a daemon. Means hass.stop() not called in teardown # FIXME should not be a daemon. Means hass.stop() not called in teardown
stop_event = threading.Event() stop_event = threading.Event()
@ -97,8 +96,6 @@ def async_test_home_assistant(loop):
hass.state = ha.CoreState.running hass.state = ha.CoreState.running
hass.allow_pool = False
# Mock async_start # Mock async_start
orig_start = hass.async_start orig_start = hass.async_start
@ -110,20 +107,6 @@ def async_test_home_assistant(loop):
hass.async_start = mock_async_start hass.async_start = mock_async_start
# Mock async_init_pool
orig_init = hass.async_init_pool
@ha.callback
def mock_async_init_pool():
"""Prevent worker pool from being initialized."""
if hass.allow_pool:
with patch('homeassistant.core._async_monitor_worker_pool'):
orig_init()
else:
assert False, 'Thread pool not allowed. Set hass.allow_pool = True'
hass.async_init_pool = mock_async_init_pool
return hass return hass

View File

@ -8,7 +8,6 @@ from homeassistant.bootstrap import setup_component
@asyncio.coroutine @asyncio.coroutine
def test_fetching_url(aioclient_mock, hass, test_client): def test_fetching_url(aioclient_mock, hass, test_client):
"""Test that it fetches the given url.""" """Test that it fetches the given url."""
hass.allow_pool = True
aioclient_mock.get('http://example.com', text='hello world') aioclient_mock.get('http://example.com', text='hello world')
def setup_platform(): def setup_platform():
@ -40,7 +39,6 @@ def test_fetching_url(aioclient_mock, hass, test_client):
@asyncio.coroutine @asyncio.coroutine
def test_limit_refetch(aioclient_mock, hass, test_client): def test_limit_refetch(aioclient_mock, hass, test_client):
"""Test that it fetches the given url.""" """Test that it fetches the given url."""
hass.allow_pool = True
aioclient_mock.get('http://example.com/5a', text='hello world') aioclient_mock.get('http://example.com/5a', text='hello world')
aioclient_mock.get('http://example.com/10a', text='hello world') aioclient_mock.get('http://example.com/10a', text='hello world')
aioclient_mock.get('http://example.com/15a', text='hello planet') aioclient_mock.get('http://example.com/15a', text='hello planet')

View File

@ -14,8 +14,6 @@ from tests.common import assert_setup_component, mock_http_component
@asyncio.coroutine @asyncio.coroutine
def test_loading_file(hass, test_client): def test_loading_file(hass, test_client):
"""Test that it loads image from disk.""" """Test that it loads image from disk."""
hass.allow_pool = True
@mock.patch('os.path.isfile', mock.Mock(return_value=True)) @mock.patch('os.path.isfile', mock.Mock(return_value=True))
@mock.patch('os.access', mock.Mock(return_value=True)) @mock.patch('os.access', mock.Mock(return_value=True))
def setup_platform(): def setup_platform():

View File

@ -56,7 +56,7 @@ def test_async_add_job_add_threaded_job_to_pool(mock_iscoro):
ha.HomeAssistant.async_add_job(hass, job) ha.HomeAssistant.async_add_job(hass, job)
assert len(hass.loop.call_soon.mock_calls) == 0 assert len(hass.loop.call_soon.mock_calls) == 0
assert len(hass.loop.create_task.mock_calls) == 0 assert len(hass.loop.create_task.mock_calls) == 0
assert len(hass.pool.add_job.mock_calls) == 1 assert len(hass.loop.run_in_executor.mock_calls) == 1
def test_async_run_job_calls_callback(): def test_async_run_job_calls_callback():
@ -195,7 +195,6 @@ class TestEventBus(unittest.TestCase):
def test_unsubscribe_listener(self): def test_unsubscribe_listener(self):
"""Test unsubscribe listener from returned function.""" """Test unsubscribe listener from returned function."""
self.hass.allow_pool = False
calls = [] calls = []
@ha.callback @ha.callback
@ -219,7 +218,6 @@ class TestEventBus(unittest.TestCase):
def test_listen_once_event_with_callback(self): def test_listen_once_event_with_callback(self):
"""Test listen_once_event method.""" """Test listen_once_event method."""
self.hass.allow_pool = False
runs = [] runs = []
@ha.callback @ha.callback
@ -237,7 +235,6 @@ class TestEventBus(unittest.TestCase):
def test_listen_once_event_with_coroutine(self): def test_listen_once_event_with_coroutine(self):
"""Test listen_once_event method.""" """Test listen_once_event method."""
self.hass.allow_pool = False
runs = [] runs = []
@asyncio.coroutine @asyncio.coroutine
@ -283,7 +280,6 @@ class TestEventBus(unittest.TestCase):
def test_callback_event_listener(self): def test_callback_event_listener(self):
"""Test a event listener listeners.""" """Test a event listener listeners."""
self.hass.allow_pool = False
callback_calls = [] callback_calls = []
@ha.callback @ha.callback
@ -297,7 +293,6 @@ class TestEventBus(unittest.TestCase):
def test_coroutine_event_listener(self): def test_coroutine_event_listener(self):
"""Test a event listener listeners.""" """Test a event listener listeners."""
self.hass.allow_pool = False
coroutine_calls = [] coroutine_calls = []
@asyncio.coroutine @asyncio.coroutine
@ -376,7 +371,6 @@ class TestStateMachine(unittest.TestCase):
self.states = self.hass.states self.states = self.hass.states
self.states.set("light.Bowl", "on") self.states.set("light.Bowl", "on")
self.states.set("switch.AC", "off") self.states.set("switch.AC", "off")
self.hass.allow_pool = False
# pylint: disable=invalid-name # pylint: disable=invalid-name
def tearDown(self): def tearDown(self):
@ -523,7 +517,6 @@ class TestServiceRegistry(unittest.TestCase):
def test_has_service(self): def test_has_service(self):
"""Test has_service method.""" """Test has_service method."""
self.hass.allow_pool = False
self.assertTrue( self.assertTrue(
self.services.has_service("tesT_domaiN", "tesT_servicE")) self.services.has_service("tesT_domaiN", "tesT_servicE"))
self.assertFalse( self.assertFalse(
@ -533,7 +526,6 @@ class TestServiceRegistry(unittest.TestCase):
def test_services(self): def test_services(self):
"""Test services.""" """Test services."""
self.hass.allow_pool = False
expected = { expected = {
'test_domain': {'test_service': {'description': '', 'fields': {}}} 'test_domain': {'test_service': {'description': '', 'fields': {}}}
} }
@ -556,7 +548,6 @@ class TestServiceRegistry(unittest.TestCase):
def test_call_non_existing_with_blocking(self): def test_call_non_existing_with_blocking(self):
"""Test non-existing with blocking.""" """Test non-existing with blocking."""
self.hass.allow_pool = False
prior = ha.SERVICE_CALL_LIMIT prior = ha.SERVICE_CALL_LIMIT
try: try:
ha.SERVICE_CALL_LIMIT = 0.01 ha.SERVICE_CALL_LIMIT = 0.01
@ -567,7 +558,6 @@ class TestServiceRegistry(unittest.TestCase):
def test_async_service(self): def test_async_service(self):
"""Test registering and calling an async service.""" """Test registering and calling an async service."""
self.hass.allow_pool = False
calls = [] calls = []
@asyncio.coroutine @asyncio.coroutine
@ -584,7 +574,6 @@ class TestServiceRegistry(unittest.TestCase):
def test_callback_service(self): def test_callback_service(self):
"""Test registering and calling an async service.""" """Test registering and calling an async service."""
self.hass.allow_pool = False
calls = [] calls = []
@ha.callback @ha.callback
@ -638,72 +627,6 @@ class TestConfig(unittest.TestCase):
self.assertEqual(expected, self.config.as_dict()) self.assertEqual(expected, self.config.as_dict())
class TestWorkerPool(unittest.TestCase):
"""Test WorkerPool methods."""
def test_exception_during_job(self):
"""Test exception during a job."""
pool = ha.create_worker_pool(1)
def malicious_job(_):
raise Exception("Test breaking worker pool")
calls = []
def register_call(_):
calls.append(1)
pool.add_job((malicious_job, None))
pool.block_till_done()
pool.add_job((register_call, None))
pool.block_till_done()
self.assertEqual(1, len(calls))
class TestWorkerPoolMonitor(object):
"""Test monitor_worker_pool."""
@patch('homeassistant.core._LOGGER.warning')
def test_worker_pool_monitor(self, mock_warning, event_loop):
"""Test we log an error and increase threshold."""
hass = MagicMock()
hass.pool.worker_count = 3
schedule_handle = MagicMock()
hass.loop.call_later.return_value = schedule_handle
ha._async_monitor_worker_pool(hass)
assert hass.loop.call_later.called
assert hass.bus.async_listen_once.called
assert not schedule_handle.called
check_threshold = hass.loop.call_later.mock_calls[0][1][1]
hass.pool.queue_size = 8
check_threshold()
assert not mock_warning.called
hass.pool.queue_size = 9
check_threshold()
assert mock_warning.called
mock_warning.reset_mock()
assert not mock_warning.called
check_threshold()
assert not mock_warning.called
hass.pool.queue_size = 17
check_threshold()
assert not mock_warning.called
hass.pool.queue_size = 18
check_threshold()
assert mock_warning.called
hass.bus.async_listen_once.mock_calls[0][1][1](None)
assert schedule_handle.cancel.called
class TestAsyncCreateTimer(object): class TestAsyncCreateTimer(object):
"""Test create timer.""" """Test create timer."""