From 6b03cb913c68dba5f0a344d063b687eca6c497a9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Feb 2018 15:35:24 -0800 Subject: [PATCH 001/191] Version bump to 0.65.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 10c29d19107..c1ced11da78 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 64 +MINOR_VERSION = 65 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 7dcb2ae24ca349035be4692932857f612cf78914 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 23 Feb 2018 00:40:58 +0100 Subject: [PATCH 002/191] Optimize logbook SQL query (#12608) --- homeassistant/components/logbook.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index e6e447884cb..1fc6d1587fc 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -47,6 +47,11 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +] + GROUP_BY_MINUTES = 15 CONTINUOUS_DOMAINS = ['proximity', 'sensor'] @@ -266,15 +271,18 @@ def humanify(events): def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by( - Events.time_fired).filter( - (Events.time_fired > start_day) & - (Events.time_fired < end_day)) + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter((States.last_updated == States.last_changed) + | (States.last_updated.is_(None))) events = execute(query) return humanify(_exclude_events(events, config)) From 156206dfee783854eae1a04d400e35a611f3e340 Mon Sep 17 00:00:00 2001 From: Scott Bradshaw Date: Fri, 23 Feb 2018 00:53:08 -0500 Subject: [PATCH 003/191] OpenGarage - correctly handle offline status (#12612) (#12613) --- homeassistant/components/cover/opengarage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index 38fbaf0acdb..d68021d7db3 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -115,7 +115,7 @@ class OpenGarageCover(CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - if self._state == STATE_UNKNOWN: + if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: return None return self._state in [STATE_CLOSED, STATE_OPENING] From 6ee3c1b3e57ff12ec27a9e466518d8ccf3e11efe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Feb 2018 23:22:27 -0800 Subject: [PATCH 004/191] Hello Python 3.5 (#12610) * Hello Python 3.5 * Fix test * Fix tests * Fix never awaited block till done warnings --- .travis.yml | 6 ++-- homeassistant/__main__.py | 8 +---- homeassistant/const.py | 3 +- homeassistant/core.py | 36 +++++++++---------- setup.py | 7 +--- .../device_tracker/test_automatic.py | 2 -- tests/components/emulated_hue/test_hue_api.py | 10 +++--- tests/components/mqtt/test_discovery.py | 6 ++-- tests/components/recorder/test_purge.py | 6 ++-- tests/test_main.py | 8 ++--- 10 files changed, 36 insertions(+), 56 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3d6789ea586..027c1f25c62 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,12 +6,10 @@ addons: matrix: fast_finish: true include: - - python: "3.4.2" + - python: "3.5.3" env: TOXENV=lint - - python: "3.4.2" + - python: "3.5.3" env: TOXENV=pylint - - python: "3.4.2" - env: TOXENV=py34 # - python: "3.5" # env: TOXENV=typing - python: "3.5.3" diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 1cf6ecf7b98..319d00e6de5 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -15,7 +15,6 @@ from homeassistant.const import ( __version__, EVENT_HOMEASSISTANT_START, REQUIRED_PYTHON_VER, - REQUIRED_PYTHON_VER_WIN, RESTART_EXIT_CODE, ) @@ -33,12 +32,7 @@ def attempt_use_uvloop(): def validate_python() -> None: """Validate that the right Python version is running.""" - if sys.platform == "win32" and \ - sys.version_info[:3] < REQUIRED_PYTHON_VER_WIN: - print("Home Assistant requires at least Python {}.{}.{}".format( - *REQUIRED_PYTHON_VER_WIN)) - sys.exit(1) - elif sys.version_info[:3] < REQUIRED_PYTHON_VER: + if sys.version_info[:3] < REQUIRED_PYTHON_VER: print("Home Assistant requires at least Python {}.{}.{}".format( *REQUIRED_PYTHON_VER)) sys.exit(1) diff --git a/homeassistant/const.py b/homeassistant/const.py index c1ced11da78..1d90e530702 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,8 +5,7 @@ MINOR_VERSION = 65 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) -REQUIRED_PYTHON_VER = (3, 4, 2) -REQUIRED_PYTHON_VER_WIN = (3, 5, 2) +REQUIRED_PYTHON_VER = (3, 5, 3) # Format for platforms PLATFORM_FORMAT = '{}.{}' diff --git a/homeassistant/core.py b/homeassistant/core.py index b1cf9c51efd..8ff9d9cfd81 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -164,8 +164,7 @@ class HomeAssistant(object): finally: self.loop.close() - @asyncio.coroutine - def async_start(self): + async def async_start(self): """Finalize startup from inside the event loop. This method is a coroutine. @@ -181,7 +180,7 @@ class HomeAssistant(object): # Only block for EVENT_HOMEASSISTANT_START listener self.async_stop_track_tasks() with timeout(TIMEOUT_EVENT_START, loop=self.loop): - yield from self.async_block_till_done() + await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( 'Something is blocking Home Assistant from wrapping up the ' @@ -190,7 +189,7 @@ class HomeAssistant(object): ', '.join(self.config.components)) # Allow automations to set up the start triggers before changing state - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) self.state = CoreState.running _async_create_timer(self) @@ -259,27 +258,25 @@ class HomeAssistant(object): run_coroutine_threadsafe( self.async_block_till_done(), loop=self.loop).result() - @asyncio.coroutine - def async_block_till_done(self): + async def async_block_till_done(self): """Block till all pending work is done.""" # To flush out any call_soon_threadsafe - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) while self._pending_tasks: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() if pending: - yield from asyncio.wait(pending, loop=self.loop) + await asyncio.wait(pending, loop=self.loop) else: - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" fire_coroutine_threadsafe(self.async_stop(), self.loop) - @asyncio.coroutine - def async_stop(self, exit_code=0) -> None: + async def async_stop(self, exit_code=0) -> None: """Stop Home Assistant and shuts down all threads. This method is a coroutine. @@ -288,12 +285,12 @@ class HomeAssistant(object): self.state = CoreState.stopping self.async_track_tasks() self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - yield from self.async_block_till_done() + await self.async_block_till_done() # stage 2 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - yield from self.async_block_till_done() + await self.async_block_till_done() self.executor.shutdown() self.exit_code = exit_code @@ -912,8 +909,8 @@ class ServiceRegistry(object): self._hass.loop ).result() - @asyncio.coroutine - def async_call(self, domain, service, service_data=None, blocking=False): + async def async_call(self, domain, service, service_data=None, + blocking=False): """ Call a service. @@ -956,14 +953,13 @@ class ServiceRegistry(object): self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data) if blocking: - done, _ = yield from asyncio.wait( + done, _ = await asyncio.wait( [fut], loop=self._hass.loop, timeout=SERVICE_CALL_LIMIT) success = bool(done) unsub() return success - @asyncio.coroutine - def _event_to_service_call(self, event): + async def _event_to_service_call(self, event): """Handle the SERVICE_CALLED events from the EventBus.""" service_data = event.data.get(ATTR_SERVICE_DATA) or {} domain = event.data.get(ATTR_DOMAIN).lower() @@ -1007,7 +1003,7 @@ class ServiceRegistry(object): service_handler.func(service_call) fire_service_executed() elif service_handler.is_coroutinefunction: - yield from service_handler.func(service_call) + await service_handler.func(service_call) fire_service_executed() else: def execute_service(): @@ -1015,7 +1011,7 @@ class ServiceRegistry(object): service_handler.func(service_call) fire_service_executed() - yield from self._hass.async_add_job(execute_service) + await self._hass.async_add_job(execute_service) except Exception: # pylint: disable=broad-except _LOGGER.exception('Error executing service %s', service_call) diff --git a/setup.py b/setup.py index bca49d33647..c3cf07223bc 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" -import sys - from setuptools import setup, find_packages import homeassistant.const as hass_const @@ -27,7 +25,6 @@ PROJECT_CLASSIFIERS = [ 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Topic :: Home Automation' @@ -64,9 +61,7 @@ REQUIRES = [ MIN_PY_VERSION = '.'.join(map( str, - hass_const.REQUIRED_PYTHON_VER_WIN - if sys.platform.startswith('win') - else hass_const.REQUIRED_PYTHON_VER)) + hass_const.REQUIRED_PYTHON_VER)) setup( name=PROJECT_PACKAGE_NAME, diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d90b5c0dd62..84ea84cdcad 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -114,8 +114,6 @@ def test_valid_credentials( result = hass.loop.run_until_complete( async_setup_scanner(hass, config, mock_see)) - hass.async_block_till_done() - assert result assert mock_create_session.called diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index cba3c835763..91988a76212 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -420,11 +420,11 @@ def test_proper_put_state_request(hue_client): # pylint: disable=invalid-name -def perform_put_test_on_ceiling_lights(hass_hue, hue_client, - content_type='application/json'): +async def perform_put_test_on_ceiling_lights(hass_hue, hue_client, + content_type='application/json'): """Test the setting of a light.""" # Turn the office light off first - yield from hass_hue.services.async_call( + await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_OFF, {const.ATTR_ENTITY_ID: 'light.ceiling_lights'}, blocking=True) @@ -433,14 +433,14 @@ def perform_put_test_on_ceiling_lights(hass_hue, hue_client, assert ceiling_lights.state == STATE_OFF # Go through the API to turn it on - office_result = yield from perform_put_light_state( + office_result = await perform_put_light_state( hass_hue, hue_client, 'light.ceiling_lights', True, 56, content_type) assert office_result.status == 200 assert 'application/json' in office_result.headers['content-type'] - office_result_json = yield from office_result.json() + office_result_json = await office_result.json() assert len(office_result_json) == 2 diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 995f7e891f9..1dd29909ffd 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -21,8 +21,8 @@ def test_subscribing_config_topic(hass, mqtt_mock): assert call_args[2] == 0 -@asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') +@asyncio.coroutine def test_invalid_topic(mock_load_platform, hass, mqtt_mock): """Test sending to invalid topic.""" mock_load_platform.return_value = mock_coro() @@ -34,8 +34,8 @@ def test_invalid_topic(mock_load_platform, hass, mqtt_mock): assert not mock_load_platform.called -@asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') +@asyncio.coroutine def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): """Test sending in invalid JSON.""" mock_load_platform.return_value = mock_coro() @@ -48,8 +48,8 @@ def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): assert not mock_load_platform.called -@asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') +@asyncio.coroutine def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog): """Test for a valid component.""" mock_load_platform.return_value = mock_coro() diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 2ae039b6712..7bac7c25e7e 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -165,7 +165,7 @@ class TestRecorderPurge(unittest.TestCase): # run purge method - no service data, use defaults self.hass.services.call('recorder', 'purge') - self.hass.async_block_till_done() + self.hass.block_till_done() # Small wait for recorder thread self.hass.data[DATA_INSTANCE].block_till_done() @@ -177,7 +177,7 @@ class TestRecorderPurge(unittest.TestCase): # run purge method - correct service data self.hass.services.call('recorder', 'purge', service_data=service_data) - self.hass.async_block_till_done() + self.hass.block_till_done() # Small wait for recorder thread self.hass.data[DATA_INSTANCE].block_till_done() @@ -203,6 +203,6 @@ class TestRecorderPurge(unittest.TestCase): self.assertFalse(self.hass.data[DATA_INSTANCE].did_vacuum) self.hass.services.call('recorder', 'purge', service_data=service_data) - self.hass.async_block_till_done() + self.hass.block_till_done() self.hass.data[DATA_INSTANCE].block_till_done() self.assertTrue(self.hass.data[DATA_INSTANCE].did_vacuum) diff --git a/tests/test_main.py b/tests/test_main.py index d3bd3cf751b..4518146c8cf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,20 +22,20 @@ def test_validate_python(mock_exit): mock_exit.reset_mock() with patch('sys.version_info', - new_callable=PropertyMock(return_value=(3, 4, 1))): + new_callable=PropertyMock(return_value=(3, 4, 2))): main.validate_python() assert mock_exit.called is True mock_exit.reset_mock() with patch('sys.version_info', - new_callable=PropertyMock(return_value=(3, 4, 2))): + new_callable=PropertyMock(return_value=(3, 5, 2))): main.validate_python() - assert mock_exit.called is False + assert mock_exit.called is True mock_exit.reset_mock() with patch('sys.version_info', - new_callable=PropertyMock(return_value=(3, 5, 1))): + new_callable=PropertyMock(return_value=(3, 5, 3))): main.validate_python() assert mock_exit.called is False From 1e672b93e7a3dea566b04f68c7138bfdade56978 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Feb 2018 23:29:42 -0800 Subject: [PATCH 005/191] Fix voluptuous breaking change things (#12611) * Fix voluptuous breaking change things * Change xiaomi aqara back --- homeassistant/components/binary_sensor/knx.py | 2 +- .../components/media_player/braviatv_psk.py | 2 +- tests/helpers/test_config_validation.py | 14 +++++--------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index d63a5a5b400..2b33d6850d6 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -35,7 +35,7 @@ DEPENDENCIES = ['knx'] AUTOMATION_SCHEMA = vol.Schema({ vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, - vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA }) AUTOMATIONS_SCHEMA = vol.All( diff --git a/homeassistant/components/media_player/braviatv_psk.py b/homeassistant/components/media_player/braviatv_psk.py index c78951e91b7..122eb3b9739 100755 --- a/homeassistant/components/media_player/braviatv_psk.py +++ b/homeassistant/components/media_player/braviatv_psk.py @@ -42,7 +42,7 @@ TV_NO_INFO = 'No info: TV resumed after pause' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PSK): cv.string, - vol.Optional(CONF_MAC, default=None): cv.string, + vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_AMP, default=False): cv.boolean, vol.Optional(CONF_ANDROID, default=True): cv.boolean, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 26262f50ac4..66f0597fc93 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -524,18 +524,14 @@ def test_enum(): def test_socket_timeout(): # pylint: disable=invalid-name """Test socket timeout validator.""" - TEST_CONF_TIMEOUT = 'timeout' # pylint: disable=invalid-name - - schema = vol.Schema( - {vol.Required(TEST_CONF_TIMEOUT, default=None): cv.socket_timeout}) + schema = vol.Schema(cv.socket_timeout) with pytest.raises(vol.Invalid): - schema({TEST_CONF_TIMEOUT: 0.0}) + schema(0.0) with pytest.raises(vol.Invalid): - schema({TEST_CONF_TIMEOUT: -1}) + schema(-1) - assert _GLOBAL_DEFAULT_TIMEOUT == schema({TEST_CONF_TIMEOUT: - None})[TEST_CONF_TIMEOUT] + assert _GLOBAL_DEFAULT_TIMEOUT == schema(None) - assert schema({TEST_CONF_TIMEOUT: 1})[TEST_CONF_TIMEOUT] == 1.0 + assert schema(1) == 1.0 From 1b22f2d8b888f124f5ffb1412ae59b7ef08a3d78 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 23 Feb 2018 10:12:40 +0100 Subject: [PATCH 006/191] Move recorder query out of event loop (#12615) --- homeassistant/components/recorder/__init__.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 53be6f33837..bffe29ec59b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -256,28 +256,6 @@ class Recorder(threading.Thread): self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, notify_hass_started) - if self.keep_days and self.purge_interval: - @callback - def async_purge(now): - """Trigger the purge and schedule the next run.""" - self.queue.put( - PurgeTask(self.keep_days, repack=not self.did_vacuum)) - self.hass.helpers.event.async_track_point_in_time( - async_purge, now + timedelta(days=self.purge_interval)) - - earliest = dt_util.utcnow() + timedelta(minutes=30) - run = latest = dt_util.utcnow() + \ - timedelta(days=self.purge_interval) - with session_scope(session=self.get_session()) as session: - event = session.query(Events).first() - if event is not None: - session.expunge(event) - run = dt_util.as_utc(event.time_fired) + \ - timedelta(days=self.keep_days+self.purge_interval) - run = min(latest, max(run, earliest)) - self.hass.helpers.event.async_track_point_in_time( - async_purge, run) - self.hass.add_job(register) result = hass_started.result() @@ -285,6 +263,29 @@ class Recorder(threading.Thread): if result is shutdown_task: return + # Start periodic purge + if self.keep_days and self.purge_interval: + @callback + def async_purge(now): + """Trigger the purge and schedule the next run.""" + self.queue.put( + PurgeTask(self.keep_days, repack=not self.did_vacuum)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, now + timedelta(days=self.purge_interval)) + + earliest = dt_util.utcnow() + timedelta(minutes=30) + run = latest = dt_util.utcnow() + \ + timedelta(days=self.purge_interval) + with session_scope(session=self.get_session()) as session: + event = session.query(Events).first() + if event is not None: + session.expunge(event) + run = dt_util.as_utc(event.time_fired) + timedelta( + days=self.keep_days+self.purge_interval) + run = min(latest, max(run, earliest)) + + self.hass.helpers.event.track_point_in_time(async_purge, run) + while True: event = self.queue.get() From daa00bc65a4f4f6f19132557e269606dda281d5c Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Fri, 23 Feb 2018 13:03:00 +0100 Subject: [PATCH 007/191] Add Tahoma scenes (#12498) * add scenes to platform * add scene.tahoma * requires tahoma-api 0.0.12 * update requirements_all.txt * hound * fix pylint error --- homeassistant/components/scene/tahoma.py | 48 ++++++++++++++++++++++++ homeassistant/components/tahoma.py | 11 ++++-- requirements_all.txt | 2 +- 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/scene/tahoma.py diff --git a/homeassistant/components/scene/tahoma.py b/homeassistant/components/scene/tahoma.py new file mode 100644 index 00000000000..39206623901 --- /dev/null +++ b/homeassistant/components/scene/tahoma.py @@ -0,0 +1,48 @@ +""" +Support for Tahoma scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.tahoma/ +""" +import logging + +from homeassistant.components.scene import Scene +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tahoma scenes.""" + controller = hass.data[TAHOMA_DOMAIN]['controller'] + scenes = [] + for scene in hass.data[TAHOMA_DOMAIN]['scenes']: + scenes.append(TahomaScene(scene, controller)) + add_devices(scenes, True) + + +class TahomaScene(Scene): + """Representation of a Tahoma scene entity.""" + + def __init__(self, tahoma_scene, controller): + """Initialize the scene.""" + self.tahoma_scene = tahoma_scene + self.controller = controller + self._name = self.tahoma_scene.name + + def activate(self): + """Activate the scene.""" + self.controller.launch_action_group(self.tahoma_scene.oid) + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'tahoma_scene_oid': self.tahoma_scene.oid} diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 00ebc78a40b..b288a704d74 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['tahoma-api==0.0.11'] +REQUIREMENTS = ['tahoma-api==0.0.12'] _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) TAHOMA_COMPONENTS = [ - 'sensor', 'cover' + 'scene', 'sensor', 'cover' ] TAHOMA_TYPES = { @@ -63,13 +63,15 @@ def setup(hass, config): try: api.get_setup() devices = api.get_devices() + scenes = api.get_action_groups() except RequestException: _LOGGER.exception("Error when getting devices from the Tahoma API") return False hass.data[DOMAIN] = { 'controller': api, - 'devices': defaultdict(list) + 'devices': defaultdict(list), + 'scenes': [] } for device in devices: @@ -82,6 +84,9 @@ def setup(hass, config): continue hass.data[DOMAIN]['devices'][device_type].append(_device) + for scene in scenes: + hass.data[DOMAIN]['scenes'].append(scene) + for component in TAHOMA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) diff --git a/requirements_all.txt b/requirements_all.txt index 6742e842657..38b90875870 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1159,7 +1159,7 @@ steamodd==4.21 suds-py3==1.3.3.0 # homeassistant.components.tahoma -tahoma-api==0.0.11 +tahoma-api==0.0.12 # homeassistant.components.sensor.tank_utility tank_utility==1.4.0 From 042f292e4fe681fb33bc0a2e0cb7281fb4adcca0 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 23 Feb 2018 14:57:49 +0100 Subject: [PATCH 008/191] Fix CODEOWNERS permissions (#12621) --- CODEOWNERS | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS old mode 100755 new mode 100644 From 230b73d14a3948c377180f4fd49e63ecd6f845c8 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 23 Feb 2018 18:31:22 +0100 Subject: [PATCH 009/191] Cast unique_id and async discovery (#12474) * Cast unique_id and async discovery * Lazily load chromecasts * Lint * Fixes & Improvements * Fixes * Improve disconnects cast.disconnect with blocking=False does **not** do I/O; it simply sets an event for the socket client looper * Add tests * Remove unnecessary calls * Lint * Fix use of hass object --- homeassistant/components/media_player/cast.py | 240 +++++++++++---- tests/components/media_player/test_cast.py | 279 +++++++++++++----- 2 files changed, 391 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index f011e86ecf9..a07ff74ccae 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -5,10 +5,16 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.cast/ """ # pylint: disable=import-error +import asyncio import logging +import threading import voluptuous as vol +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import (dispatcher_send, + async_dispatcher_connect) from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, @@ -16,7 +22,7 @@ from homeassistant.components.media_player import ( SUPPORT_STOP, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN) + STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util @@ -33,7 +39,13 @@ SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY -KNOWN_HOSTS_KEY = 'cast_known_hosts' +INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running' +# UUID -> CastDevice mapping; cast devices without UUID are not stored +ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' +# Stores every discovered (host, port, uuid) +KNOWN_CHROMECASTS_KEY = 'cast_all_chromecasts' + +SIGNAL_CAST_DISCOVERED = 'cast_discovered' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, @@ -41,67 +53,144 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +def _setup_internal_discovery(hass: HomeAssistantType) -> None: + """Set up the pychromecast internal discovery.""" + hass.data.setdefault(INTERNAL_DISCOVERY_RUNNING_KEY, threading.Lock()) + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): + # Internal discovery is already running + return + + import pychromecast + + def internal_callback(name): + """Called when zeroconf has discovered a new chromecast.""" + mdns = listener.services[name] + ip_address, port, uuid, _, _ = mdns + key = (ip_address, port, uuid) + + if key in hass.data[KNOWN_CHROMECASTS_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", mdns) + return + + _LOGGER.debug("Discovered new chromecast %s", mdns) + try: + # pylint: disable=protected-access + chromecast = pychromecast._get_chromecast_from_host( + mdns, blocking=True) + except pychromecast.ChromecastConnectionError: + _LOGGER.debug("Can't set up cast with mDNS info %s. " + "Assuming it's not a Chromecast", mdns) + return + hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, chromecast) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery(internal_callback) + + def stop_discovery(event): + """Stop discovery of new chromecasts.""" + pychromecast.stop_discovery(browser) + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) + + +@callback +def _async_create_cast_device(hass, chromecast): + """Create a CastDevice Entity from the chromecast object. + + Returns None if the cast device has already been added. Additionally, + automatically updates existing chromecast entities. + """ + if chromecast.uuid is None: + # Found a cast without UUID, we don't store it because we won't be able + # to update it anyway. + return CastDevice(chromecast) + + # Found a cast with UUID + added_casts = hass.data[ADDED_CAST_DEVICES_KEY] + old_cast_device = added_casts.get(chromecast.uuid) + if old_cast_device is None: + # -> New cast device + cast_device = CastDevice(chromecast) + added_casts[chromecast.uuid] = cast_device + return cast_device + + old_key = (old_cast_device.cast.host, + old_cast_device.cast.port, + old_cast_device.cast.uuid) + new_key = (chromecast.host, chromecast.port, chromecast.uuid) + + if old_key == new_key: + # Re-discovered with same data, ignore + return None + + # -> Cast device changed host + # Remove old pychromecast.Chromecast from global list, because it isn't + # valid anymore + old_cast_device.async_set_chromecast(chromecast) + return None + + +@asyncio.coroutine +def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the cast platform.""" import pychromecast # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) + hass.data.setdefault(ADDED_CAST_DEVICES_KEY, {}) + hass.data.setdefault(KNOWN_CHROMECASTS_KEY, {}) - known_hosts = hass.data.get(KNOWN_HOSTS_KEY) - if known_hosts is None: - known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] - + # None -> use discovery; (host, port) -> manually specify chromecast. + want_host = None if discovery_info: - host = (discovery_info.get('host'), discovery_info.get('port')) - - if host in known_hosts: - return - - hosts = [host] - + want_host = (discovery_info.get('host'), discovery_info.get('port')) elif CONF_HOST in config: - host = (config.get(CONF_HOST), DEFAULT_PORT) + want_host = (config.get(CONF_HOST), DEFAULT_PORT) - if host in known_hosts: - return + enable_discovery = False + if want_host is None: + # We were explicitly told to enable pychromecast discovery. + enable_discovery = True + elif want_host[1] != DEFAULT_PORT: + # We're trying to add a group, so we have to use pychromecast's + # discovery to get the correct friendly name. + enable_discovery = True - hosts = [host] + if enable_discovery: + @callback + def async_cast_discovered(chromecast): + """Callback for when a new chromecast is discovered.""" + if want_host is not None and \ + (chromecast.host, chromecast.port) != want_host: + return # for groups, only add requested device + cast_device = _async_create_cast_device(hass, chromecast) + if cast_device is not None: + async_add_devices([cast_device]) + + async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + # Re-play the callback for all past chromecasts, store the objects in + # a list to avoid concurrent modification resulting in exception. + for chromecast in list(hass.data[KNOWN_CHROMECASTS_KEY].values()): + async_cast_discovered(chromecast) + + hass.async_add_job(_setup_internal_discovery, hass) else: - hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts() - if tuple(dev[:2]) not in known_hosts] - - casts = [] - - # get_chromecasts() returns Chromecast objects with the correct friendly - # name for grouped devices - all_chromecasts = pychromecast.get_chromecasts() - - for host in hosts: - (_, port) = host - found = [device for device in all_chromecasts - if (device.host, device.port) == host] - if found: - try: - casts.append(CastDevice(found[0])) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - # do not add groups using pychromecast.Chromecast as it leads to names - # collision since pychromecast.Chromecast will get device name instead - # of group name - elif port == DEFAULT_PORT: - try: - # add the device anyway, get_chromecasts couldn't find it - casts.append(CastDevice(pychromecast.Chromecast(*host))) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - add_devices(casts) + # Manually add a "normal" Chromecast, we can do that without discovery. + try: + chromecast = pychromecast.Chromecast(*want_host) + except pychromecast.ChromecastConnectionError: + _LOGGER.warning("Can't set up chromecast on %s", want_host[0]) + raise + key = (chromecast.host, chromecast.port, chromecast.uuid) + cast_device = _async_create_cast_device(hass, chromecast) + if cast_device is not None: + hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast + async_add_devices([cast_device]) class CastDevice(MediaPlayerDevice): @@ -109,16 +198,13 @@ class CastDevice(MediaPlayerDevice): def __init__(self, chromecast): """Initialize the Cast device.""" - self.cast = chromecast - - self.cast.socket_client.receiver_controller.register_status_listener( - self) - self.cast.socket_client.media_controller.register_status_listener(self) - - self.cast_status = self.cast.status - self.media_status = self.cast.media_controller.status + self.cast = None # type: pychromecast.Chromecast + self.cast_status = None + self.media_status = None self.media_status_received = None + self.async_set_chromecast(chromecast) + @property def should_poll(self): """No polling needed.""" @@ -325,3 +411,39 @@ class CastDevice(MediaPlayerDevice): self.media_status = status self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + if self.cast.uuid is not None: + return str(self.cast.uuid) + return None + + @callback + def async_set_chromecast(self, chromecast): + """Set the internal Chromecast object and disconnect the previous.""" + self._async_disconnect() + + self.cast = chromecast + + self.cast.socket_client.receiver_controller.register_status_listener( + self) + self.cast.socket_client.media_controller.register_status_listener(self) + + self.cast_status = self.cast.status + self.media_status = self.cast.media_controller.status + + @asyncio.coroutine + def async_will_remove_from_hass(self): + """Disconnect Chromecast object when removed.""" + self._async_disconnect() + + @callback + def _async_disconnect(self): + """Disconnect Chromecast object if it is set.""" + if self.cast is None: + return + _LOGGER.debug("Disconnecting existing chromecast object") + old_key = (self.cast.host, self.cast.port, self.cast.uuid) + self.hass.data[KNOWN_CHROMECASTS_KEY].pop(old_key) + self.cast.disconnect(blocking=False) diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 0bcfc9b9a1a..6eeb9136b07 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,12 +1,15 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access -import unittest -from unittest.mock import patch, MagicMock +import asyncio +from typing import Optional +from unittest.mock import patch, MagicMock, Mock +from uuid import UUID import pytest +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.media_player import cast -from tests.common import get_test_home_assistant @pytest.fixture(autouse=True) @@ -18,83 +21,221 @@ def cast_mock(): yield -class FakeChromeCast(object): - """A fake Chrome Cast.""" - - def __init__(self, host, port): - """Initialize the fake Chrome Cast.""" - self.host = host - self.port = port +# pylint: disable=invalid-name +FakeUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e2') -class TestCastMediaPlayer(unittest.TestCase): - """Test the media_player module.""" +def get_fake_chromecast(host='192.168.178.42', port=8009, + uuid: Optional[UUID] = FakeUUID): + """Generate a Fake Chromecast object with the specified arguments.""" + return MagicMock(host=host, port=port, uuid=uuid) - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +@asyncio.coroutine +def async_setup_cast(hass, config=None, discovery_info=None): + """Helper to setup the cast platform.""" + if config is None: + config = {} + add_devices = Mock() - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - def test_filter_duplicates(self, mock_get_chromecasts, mock_device): - """Test filtering of duplicates.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + yield from cast.async_setup_platform(hass, config, add_devices, + discovery_info=discovery_info) + yield from hass.async_block_till_done() - # Test chromecasts as if they were hardcoded in configuration.yaml - cast.setup_platform(self.hass, { - 'host': 'some_host' - }, lambda _: _) + return add_devices - assert mock_device.called - mock_device.reset_mock() - assert not mock_device.called +@asyncio.coroutine +def async_setup_cast_internal_discovery(hass, config=None, + discovery_info=None, + no_from_host_patch=False): + """Setup the cast platform and the discovery.""" + listener = MagicMock(services={}) - # Test chromecasts as if they were automatically discovered - cast.setup_platform(self.hass, {}, lambda _: _, { - 'host': 'some_host', - 'port': cast.DEFAULT_PORT, - }) - assert not mock_device.called + with patch('pychromecast.start_discovery', + return_value=(listener, None)) as start_discovery: + add_devices = yield from async_setup_cast(hass, config, discovery_info) + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - @patch('pychromecast.Chromecast') - def test_fallback_cast(self, mock_chromecast, mock_get_chromecasts, - mock_device): - """Test falling back to creating Chromecast when not discovered.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + assert start_discovery.call_count == 1 - # Test chromecasts as if they were hardcoded in configuration.yaml - cast.setup_platform(self.hass, { - 'host': 'some_other_host' - }, lambda _: _) + discovery_callback = start_discovery.call_args[0][0] - assert mock_chromecast.called - assert mock_device.called + def discover_chromecast(service_name, chromecast): + """Discover a chromecast device.""" + listener.services[service_name] = ( + chromecast.host, chromecast.port, chromecast.uuid, None, None) + if no_from_host_patch: + discovery_callback(service_name) + else: + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast): + discovery_callback(service_name) - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - @patch('pychromecast.Chromecast') - def test_fallback_cast_group(self, mock_chromecast, mock_get_chromecasts, - mock_device): - """Test not creating Cast Group when not discovered.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + return discover_chromecast, add_devices - # Test chromecasts as if they were automatically discovered - cast.setup_platform(self.hass, {}, lambda _: _, { - 'host': 'some_other_host', - 'port': 43546, - }) - assert not mock_chromecast.called - assert not mock_device.called + +@asyncio.coroutine +def test_start_discovery_called_once(hass): + """Test pychromecast.start_discovery called exactly once.""" + with patch('pychromecast.start_discovery', + return_value=(None, None)) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + yield from async_setup_cast(hass) + assert start_discovery.call_count == 1 + + +@asyncio.coroutine +def test_stop_discovery_called_on_stop(hass): + """Test pychromecast.stop_discovery called on shutdown.""" + with patch('pychromecast.start_discovery', + return_value=(None, 'the-browser')) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + with patch('pychromecast.stop_discovery') as stop_discovery: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + yield from hass.async_block_till_done() + + stop_discovery.assert_called_once_with('the-browser') + + with patch('pychromecast.start_discovery', + return_value=(None, 'the-browser')) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + +@asyncio.coroutine +def test_internal_discovery_callback_only_generates_once(hass): + """Test _get_chromecast_from_host only called once per device.""" + discover_cast, _ = yield from async_setup_cast_internal_discovery( + hass, no_from_host_patch=True) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast) as gen_chromecast: + discover_cast('the-service', chromecast) + mdns = (chromecast.host, chromecast.port, chromecast.uuid, None, None) + gen_chromecast.assert_called_once_with(mdns, blocking=True) + + discover_cast('the-service', chromecast) + gen_chromecast.reset_mock() + assert gen_chromecast.call_count == 0 + + +@asyncio.coroutine +def test_internal_discovery_callback_calls_dispatcher(hass): + """Test internal discovery calls dispatcher.""" + discover_cast, _ = yield from async_setup_cast_internal_discovery(hass) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', chromecast) + yield from hass.async_block_till_done() + + signal.assert_called_once_with(chromecast) + + +@asyncio.coroutine +def test_internal_discovery_callback_with_connection_error(hass): + """Test internal discovery not calling dispatcher on ConnectionError.""" + import pychromecast # imports mock pychromecast + + pychromecast.ChromecastConnectionError = IOError + + discover_cast, _ = yield from async_setup_cast_internal_discovery( + hass, no_from_host_patch=True) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + side_effect=pychromecast.ChromecastConnectionError): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', chromecast) + yield from hass.async_block_till_done() + + assert signal.call_count == 0 + + +def test_create_cast_device_without_uuid(hass): + """Test create a cast device without a UUID.""" + chromecast = get_fake_chromecast(uuid=None) + cast_device = cast._async_create_cast_device(hass, chromecast) + assert cast_device is not None + + +def test_create_cast_device_with_uuid(hass): + """Test create cast devices with UUID.""" + added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + chromecast = get_fake_chromecast() + cast_device = cast._async_create_cast_device(hass, chromecast) + assert cast_device is not None + assert chromecast.uuid in added_casts + + with patch.object(cast_device, 'async_set_chromecast') as mock_set: + assert cast._async_create_cast_device(hass, chromecast) is None + assert mock_set.call_count == 0 + + chromecast = get_fake_chromecast(host='192.168.178.1') + assert cast._async_create_cast_device(hass, chromecast) is None + assert mock_set.call_count == 1 + mock_set.assert_called_once_with(chromecast) + + +@asyncio.coroutine +def test_normal_chromecast_not_starting_discovery(hass): + """Test cast platform not starting discovery when not required.""" + chromecast = get_fake_chromecast() + + with patch('pychromecast.Chromecast', return_value=chromecast): + add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + assert add_devices.call_count == 1 + + # Same entity twice + add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + assert add_devices.call_count == 0 + + hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + add_devices = yield from async_setup_cast( + hass, discovery_info={'host': 'host1', 'port': 8009}) + assert add_devices.call_count == 1 + + hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + add_devices = yield from async_setup_cast( + hass, discovery_info={'host': 'host1', 'port': 42}) + assert add_devices.call_count == 0 + + +@asyncio.coroutine +def test_replay_past_chromecasts(hass): + """Test cast platform re-playing past chromecasts when adding new one.""" + cast_group1 = get_fake_chromecast(host='host1', port=42) + cast_group2 = get_fake_chromecast(host='host2', port=42, uuid=UUID( + '9462202c-e747-4af5-a66b-7dce0e1ebc09')) + + discover_cast, add_dev1 = yield from async_setup_cast_internal_discovery( + hass, discovery_info={'host': 'host1', 'port': 42}) + discover_cast('service2', cast_group2) + yield from hass.async_block_till_done() + assert add_dev1.call_count == 0 + + discover_cast('service1', cast_group1) + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() # having jobs that add jobs + assert add_dev1.call_count == 1 + + add_dev2 = yield from async_setup_cast( + hass, discovery_info={'host': 'host2', 'port': 42}) + yield from hass.async_block_till_done() + assert add_dev2.call_count == 1 From c3d322f26c2c4d1dd321c2ceb14baf3c63d4048f Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 23 Feb 2018 19:11:54 +0100 Subject: [PATCH 010/191] The name of the enum must be used here because of the speed_list. (#12625) The fan.set_speed example value is lower-case and led to confusion. Both spellings are possible now: Idle & idle --- homeassistant/components/fan/xiaomi_miio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 264962b9d56..b9bc54b5c79 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -302,7 +302,7 @@ class XiaomiAirPurifier(FanEntity): yield from self._try_command( "Setting operation mode of the air purifier failed.", - self._air_purifier.set_mode, OperationMode(speed)) + self._air_purifier.set_mode, OperationMode[speed.title()]) @asyncio.coroutine def async_set_buzzer_on(self): From 5a80d4e5ea890c3501283192c7e2e9888795c546 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 23 Feb 2018 19:13:04 +0100 Subject: [PATCH 011/191] Hassio update timeout filter list (#12617) * Update timeout filter list * Update http.py --- homeassistant/components/hassio/http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index d94826653e8..9dd6427ec38 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -31,6 +31,8 @@ NO_TIMEOUT = { re.compile(r'^addons/[^/]*/rebuild$'), re.compile(r'^snapshots/.*/full$'), re.compile(r'^snapshots/.*/partial$'), + re.compile(r'^snapshots/[^/]*/upload$'), + re.compile(r'^snapshots/[^/]*/download$'), } NO_AUTH = { From 485979cd860c67ac60ff67631bd22ed7041e6ac6 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 23 Feb 2018 22:33:51 +0100 Subject: [PATCH 012/191] Xiaomi Aqara Gateway: Service descriptions added (#12631) * Service descriptions of the Xiaomi Aqara Gateway added. * Descriptions added. --- homeassistant/components/services.yaml | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 522939a213a..519d3b98704 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -509,3 +509,38 @@ homeassistant: entity_id: description: The entity_id of the device to turn off. example: light.living_room + +xiaomi_aqara: + play_ringtone: + description: Play a specific ringtone. The version of the gateway firmware must be 1.4.1_145 at least. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + ringtone_id: + description: One of the allowed ringtone ids. + example: 8 + ringtone_vol: + description: The volume in percent. + example: 30 + stop_ringtone: + description: Stops a playing ringtone immediately. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + add_device: + description: Enables the join permission of the Xiaomi Aqara Gateway for 30 seconds. A new device can be added afterwards by pressing the pairing button once. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + remove_device: + description: Removes a specific device. The removal is required if a device shall be paired with another gateway. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + device_id: + description: Hardware address of the device to remove. + example: 158d0000000000 From 7a44eee093bd1c07331cd5659abf80289de48564 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Fri, 23 Feb 2018 23:33:12 +0100 Subject: [PATCH 013/191] Fix mclimate accounts with not only melissa components (#12427) * Fixes for mclimate accounts with not only melissa components * Fixes melissa sensor to only use HVAC * Bumping version to 1.0.3 and remove OP_MODE that is not supported * Removes STATE_AUTO from translation and tests --- homeassistant/components/climate/melissa.py | 15 ++++++--------- homeassistant/components/melissa.py | 2 +- homeassistant/components/sensor/melissa.py | 5 +++-- requirements_all.txt | 2 +- tests/components/climate/test_melissa.py | 4 +--- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index 96bd66d05a5..9c005b62dcc 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -26,7 +26,7 @@ SUPPORT_FLAGS = (SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE) OP_MODES = [ - STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT + STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT ] FAN_MODES = [ @@ -42,8 +42,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): all_devices = [] for device in devices: - all_devices.append(MelissaClimate( - api, device['serial_number'], device)) + if device['type'] == 'melissa': + all_devices.append(MelissaClimate( + api, device['serial_number'], device)) add_devices(all_devices) @@ -199,9 +200,7 @@ class MelissaClimate(ClimateDevice): def melissa_op_to_hass(self, mode): """Translate Melissa modes to hass states.""" - if mode == self._api.MODE_AUTO: - return STATE_AUTO - elif mode == self._api.MODE_HEAT: + if mode == self._api.MODE_HEAT: return STATE_HEAT elif mode == self._api.MODE_COOL: return STATE_COOL @@ -228,9 +227,7 @@ class MelissaClimate(ClimateDevice): def hass_mode_to_melissa(self, mode): """Translate hass states to melissa modes.""" - if mode == STATE_AUTO: - return self._api.MODE_AUTO - elif mode == STATE_HEAT: + if mode == STATE_HEAT: return self._api.MODE_HEAT elif mode == STATE_COOL: return self._api.MODE_COOL diff --git a/homeassistant/components/melissa.py b/homeassistant/components/melissa.py index ae82b96222e..f5a757dbcf3 100644 --- a/homeassistant/components/melissa.py +++ b/homeassistant/components/melissa.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ["py-melissa-climate==1.0.1"] +REQUIREMENTS = ["py-melissa-climate==1.0.6"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/melissa.py b/homeassistant/components/sensor/melissa.py index 58313428861..f67722b0198 100644 --- a/homeassistant/components/sensor/melissa.py +++ b/homeassistant/components/sensor/melissa.py @@ -22,8 +22,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = api.fetch_devices().values() for device in devices: - sensors.append(MelissaTemperatureSensor(device, api)) - sensors.append(MelissaHumiditySensor(device, api)) + if device['type'] == 'melissa': + sensors.append(MelissaTemperatureSensor(device, api)) + sensors.append(MelissaHumiditySensor(device, api)) add_devices(sensors) diff --git a/requirements_all.txt b/requirements_all.txt index 38b90875870..7de7131b562 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ py-canary==0.4.0 py-cpuinfo==3.3.0 # homeassistant.components.melissa -py-melissa-climate==1.0.1 +py-melissa-climate==1.0.6 # homeassistant.components.camera.synology py-synology==0.1.5 diff --git a/tests/components/climate/test_melissa.py b/tests/components/climate/test_melissa.py index 446eec9aba1..5022c556b7d 100644 --- a/tests/components/climate/test_melissa.py +++ b/tests/components/climate/test_melissa.py @@ -122,7 +122,7 @@ class TestMelissa(unittest.TestCase): def test_operation_list(self): """Test the operation list.""" self.assertEqual( - [STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT], + [STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT], self.thermostat.operation_list ) @@ -226,7 +226,6 @@ class TestMelissa(unittest.TestCase): def test_melissa_op_to_hass(self): """Test for translate melissa operations to hass.""" - self.assertEqual(STATE_AUTO, self.thermostat.melissa_op_to_hass(0)) self.assertEqual(STATE_FAN_ONLY, self.thermostat.melissa_op_to_hass(1)) self.assertEqual(STATE_HEAT, self.thermostat.melissa_op_to_hass(2)) self.assertEqual(STATE_COOL, self.thermostat.melissa_op_to_hass(3)) @@ -245,7 +244,6 @@ class TestMelissa(unittest.TestCase): @mock.patch('homeassistant.components.climate.melissa._LOGGER.warning') def test_hass_mode_to_melissa(self, mocked_warning): """Test for hass operations to melssa.""" - self.assertEqual(0, self.thermostat.hass_mode_to_melissa(STATE_AUTO)) self.assertEqual( 1, self.thermostat.hass_mode_to_melissa(STATE_FAN_ONLY)) self.assertEqual(2, self.thermostat.hass_mode_to_melissa(STATE_HEAT)) From c076b805e7d2b3e3d42d9155eedc6c4ddb7654fd Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sat, 24 Feb 2018 00:13:48 +0100 Subject: [PATCH 014/191] Fix cast doing I/O in event loop (#12632) --- homeassistant/components/media_player/cast.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index a07ff74ccae..40e09ea328c 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -182,7 +182,8 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType, else: # Manually add a "normal" Chromecast, we can do that without discovery. try: - chromecast = pychromecast.Chromecast(*want_host) + chromecast = yield from hass.async_add_job( + pychromecast.Chromecast, *want_host) except pychromecast.ChromecastConnectionError: _LOGGER.warning("Can't set up chromecast on %s", want_host[0]) raise From 3713dfe1393cc4b65d83c5b629f91ca04baeeba1 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sat, 24 Feb 2018 19:24:33 +0100 Subject: [PATCH 015/191] Removing asyncio.coroutine syntax from some components (#12507) * Removing asyncio.coroutine syntax (first steps) * merge conflict * fixed small bug * pylint --- .../components/binary_sensor/__init__.py | 7 +- homeassistant/components/binary_sensor/knx.py | 10 +-- homeassistant/components/climate/__init__.py | 74 ++++++++----------- homeassistant/components/climate/knx.py | 22 +++--- homeassistant/components/cover/__init__.py | 12 ++- homeassistant/components/cover/knx.py | 35 ++++----- homeassistant/components/knx.py | 25 +++---- homeassistant/components/light/__init__.py | 23 +++--- homeassistant/components/light/knx.py | 24 +++--- homeassistant/components/notify/knx.py | 22 +++--- homeassistant/components/scene/__init__.py | 10 +-- homeassistant/components/scene/velux.py | 11 +-- homeassistant/components/sensor/__init__.py | 7 +- homeassistant/components/sensor/knx.py | 10 +-- homeassistant/components/switch/__init__.py | 16 ++-- homeassistant/components/switch/knx.py | 20 ++--- homeassistant/components/velux.py | 11 +-- 17 files changed, 140 insertions(+), 199 deletions(-) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index df271a7ebac..919abc678e4 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -4,7 +4,7 @@ Component to interface with binary sensors. For more details about this component, please refer to the documentation at https://home-assistant.io/components/binary_sensor/ """ -import asyncio + from datetime import timedelta import logging @@ -47,13 +47,12 @@ DEVICE_CLASSES = [ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for binary sensors.""" component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) return True diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b33d6850d6..1802ae34454 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -import asyncio import voluptuous as vol @@ -53,8 +52,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -111,11 +110,10 @@ class KNXBinarySensor(BinarySensorDevice): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index e1a5f71af83..7ea23f4fd65 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -237,14 +237,12 @@ def set_swing_mode(hass, swing_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up climate devices.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_away_mode_set_service(service): + async def async_away_mode_set_service(service): """Set away mode on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -253,23 +251,22 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: if away_mode: - yield from climate.async_turn_away_mode_on() + await climate.async_turn_away_mode_on() else: - yield from climate.async_turn_away_mode_off() + await climate.async_turn_away_mode_off() if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service, schema=SET_AWAY_MODE_SCHEMA) - @asyncio.coroutine - def async_hold_mode_set_service(service): + async def async_hold_mode_set_service(service): """Set hold mode on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -277,21 +274,20 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_hold_mode(hold_mode) + await climate.async_set_hold_mode(hold_mode) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, schema=SET_HOLD_MODE_SCHEMA) - @asyncio.coroutine - def async_aux_heat_set_service(service): + async def async_aux_heat_set_service(service): """Set auxiliary heater on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -300,23 +296,22 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: if aux_heat: - yield from climate.async_turn_aux_heat_on() + await climate.async_turn_aux_heat_on() else: - yield from climate.async_turn_aux_heat_off() + await climate.async_turn_aux_heat_off() if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service, schema=SET_AUX_HEAT_SCHEMA) - @asyncio.coroutine - def async_temperature_set_service(service): + async def async_temperature_set_service(service): """Set temperature on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -333,21 +328,20 @@ def async_setup(hass, config): else: kwargs[value] = temp - yield from climate.async_set_temperature(**kwargs) + await climate.async_set_temperature(**kwargs) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service, schema=SET_TEMPERATURE_SCHEMA) - @asyncio.coroutine - def async_humidity_set_service(service): + async def async_humidity_set_service(service): """Set humidity on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -355,20 +349,19 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_humidity(humidity) + await climate.async_set_humidity(humidity) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service, schema=SET_HUMIDITY_SCHEMA) - @asyncio.coroutine - def async_fan_mode_set_service(service): + async def async_fan_mode_set_service(service): """Set fan mode on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -376,20 +369,19 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_fan_mode(fan) + await climate.async_set_fan_mode(fan) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service, schema=SET_FAN_MODE_SCHEMA) - @asyncio.coroutine - def async_operation_set_service(service): + async def async_operation_set_service(service): """Set operating mode on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -397,20 +389,19 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_operation_mode(operation_mode) + await climate.async_set_operation_mode(operation_mode) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service, schema=SET_OPERATION_MODE_SCHEMA) - @asyncio.coroutine - def async_swing_set_service(service): + async def async_swing_set_service(service): """Set swing mode on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -418,36 +409,35 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_swing_mode(swing_mode) + await climate.async_set_swing_mode(swing_mode) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service, schema=SET_SWING_MODE_SCHEMA) - @asyncio.coroutine - def async_on_off_service(service): + async def async_on_off_service(service): """Handle on/off calls.""" target_climate = component.async_extract_from_service(service) update_tasks = [] for climate in target_climate: if service.service == SERVICE_TURN_ON: - yield from climate.async_turn_on() + await climate.async_turn_on() elif service.service == SERVICE_TURN_OFF: - yield from climate.async_turn_off() + await climate.async_turn_off() if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_on_off_service, diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 1bbc5b789fb..5ce6cc2fa7a 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP climate devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import asyncio import voluptuous as vol @@ -61,8 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up climate(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -135,11 +134,10 @@ class KNXClimate(ClimateDevice): def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -187,14 +185,13 @@ class KNXClimate(ClimateDevice): """Return the maximum temperature.""" return self.device.target_temperature_max - @asyncio.coroutine - def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - yield from self.device.set_target_temperature(temperature) - yield from self.async_update_ha_state() + await self.device.set_target_temperature(temperature) + await self.async_update_ha_state() @property def current_operation(self): @@ -210,10 +207,9 @@ class KNXClimate(ClimateDevice): operation_mode in self.device.get_supported_operation_modes()] - @asyncio.coroutine - def async_set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" if self.device.supports_operation_mode: from xknx.knx import HVACOperationMode knx_operation_mode = HVACOperationMode(operation_mode) - yield from self.device.set_operation_mode(knx_operation_mode) + await self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 1dfa0028ab8..b24361d8293 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -150,16 +150,14 @@ def stop_cover_tilt(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for covers.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_cover_service(service): + async def async_handle_cover_service(service): """Handle calls to the cover services.""" covers = component.async_extract_from_service(service) method = SERVICE_TO_METHOD.get(service.service) @@ -169,13 +167,13 @@ def async_setup(hass, config): # call method update_tasks = [] for cover in covers: - yield from getattr(cover, method['method'])(**params) + await getattr(cover, method['method'])(**params) if not cover.should_poll: continue update_tasks.append(cover.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) for service_name in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service_name].get( diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 730a2b29a2e..83668924268 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import asyncio import voluptuous as vol @@ -50,8 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up cover(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -106,11 +105,10 @@ class KNXCover(CoverDevice): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -147,32 +145,28 @@ class KNXCover(CoverDevice): """Return if the cover is closed.""" return self.device.is_closed() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" if not self.device.is_closed(): - yield from self.device.set_down() + await self.device.set_down() self.start_auto_updater() - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" if not self.device.is_open(): - yield from self.device.set_up() + await self.device.set_up() self.start_auto_updater() - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - yield from self.device.set_position(position) + await self.device.set_position(position) self.start_auto_updater() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - yield from self.device.stop() + await self.device.stop() self.stop_auto_updater() @property @@ -182,12 +176,11 @@ class KNXCover(CoverDevice): return None return self.device.current_angle() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: tilt_position = kwargs[ATTR_TILT_POSITION] - yield from self.device.set_angle(tilt_position) + await self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index a90a5246759..63407fd6246 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -4,7 +4,7 @@ Connects to KNX platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ """ -import asyncio + import logging import voluptuous as vol @@ -66,13 +66,12 @@ SERVICE_KNX_SEND_SCHEMA = vol.Schema({ }) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the KNX component.""" from xknx.exceptions import XKNXException try: hass.data[DATA_KNX] = KNXModule(hass, config) - yield from hass.data[DATA_KNX].start() + await hass.data[DATA_KNX].start() except XKNXException as ex: _LOGGER.warning("Can't connect to KNX interface: %s", ex) @@ -128,20 +127,18 @@ class KNXModule(object): from xknx import XKNX self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop) - @asyncio.coroutine - def start(self): + async def start(self): """Start KNX object. Connect to tunneling or Routing device.""" connection_config = self.connection_config() - yield from self.xknx.start( + await self.xknx.start( state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER], connection_config=connection_config) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) self.connected = True - @asyncio.coroutine - def stop(self, event): + async def stop(self, event): """Stop KNX object. Disconnect from tunneling or Routing device.""" - yield from self.xknx.stop() + await self.xknx.stop() def config_file(self): """Resolve and return the full path of xknx.yaml if configured.""" @@ -202,8 +199,7 @@ class KNXModule(object): self.xknx.telegram_queue.register_telegram_received_cb( self.telegram_received_cb, address_filters) - @asyncio.coroutine - def telegram_received_cb(self, telegram): + async def telegram_received_cb(self, telegram): """Call invoked after a KNX telegram was received.""" self.hass.bus.fire('knx_event', { 'address': telegram.group_address.str(), @@ -212,8 +208,7 @@ class KNXModule(object): # False signals XKNX to proceed with processing telegrams. return False - @asyncio.coroutine - def service_send_to_knx_bus(self, call): + async def service_send_to_knx_bus(self, call): """Service for sending an arbitrary KNX message to the KNX bus.""" from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) @@ -230,7 +225,7 @@ class KNXModule(object): telegram = Telegram() telegram.payload = payload telegram.group_address = address - yield from self.xknx.telegrams.put(telegram) + await self.xknx.telegrams.put(telegram) class KNXAutomation(): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index cfeceb0c991..b7e8966394e 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -240,20 +240,18 @@ def preprocess_turn_on_alternatives(params): params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Expose light control via state machine and services.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) - yield from component.async_setup(config) + await component.async_setup(config) # load profiles from files - profiles_valid = yield from Profiles.load_profiles(hass) + profiles_valid = await Profiles.load_profiles(hass) if not profiles_valid: return False - @asyncio.coroutine - def async_handle_light_service(service): + async def async_handle_light_service(service): """Handle a turn light on or off service call.""" # Get the validated data params = service.data.copy() @@ -267,18 +265,18 @@ def async_setup(hass, config): update_tasks = [] for light in target_lights: if service.service == SERVICE_TURN_ON: - yield from light.async_turn_on(**params) + await light.async_turn_on(**params) elif service.service == SERVICE_TURN_OFF: - yield from light.async_turn_off(**params) + await light.async_turn_off(**params) else: - yield from light.async_toggle(**params) + await light.async_toggle(**params) if not light.should_poll: continue update_tasks.append(light.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) # Listen for light on and light off service calls. hass.services.async_register( @@ -302,8 +300,7 @@ class Profiles: _all = None @classmethod - @asyncio.coroutine - def load_profiles(cls, hass): + async def load_profiles(cls, hass): """Load and cache profiles.""" def load_profile_data(hass): """Load built-in profiles and custom profiles.""" @@ -333,7 +330,7 @@ class Profiles: return None return profiles - cls._all = yield from hass.async_add_job(load_profile_data, hass) + cls._all = await hass.async_add_job(load_profile_data, hass) return cls._all is not None @classmethod diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 020184b8501..1b14ff75ecc 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.knx/ """ -import asyncio import voluptuous as vol @@ -37,8 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up lights for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -86,11 +85,10 @@ class KNXLight(Light): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -162,17 +160,15 @@ class KNXLight(Light): flags |= SUPPORT_RGB_COLOR return flags - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: - yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) elif ATTR_RGB_COLOR in kwargs: - yield from self.device.set_color(kwargs[ATTR_RGB_COLOR]) + await self.device.set_color(kwargs[ATTR_RGB_COLOR]) else: - yield from self.device.set_on() + await self.device.set_on() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the light off.""" - yield from self.device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py index e6bb400d421..750e3945569 100644 --- a/homeassistant/components/notify/knx.py +++ b/homeassistant/components/notify/knx.py @@ -4,7 +4,7 @@ KNX/IP notification service. For more details about this platform, please refer to the documentation https://home-assistant.io/components/notify.knx/ """ -import asyncio + import voluptuous as vol from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES @@ -24,8 +24,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the KNX notification service.""" return async_get_service_discovery(hass, discovery_info) \ if discovery_info is not None else \ @@ -72,23 +71,20 @@ class KNXNotificationService(BaseNotificationService): ret[device.name] = device.name return ret - @asyncio.coroutine - def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message="", **kwargs): """Send a notification to knx bus.""" if "target" in kwargs: - yield from self._async_send_to_device(message, kwargs["target"]) + await self._async_send_to_device(message, kwargs["target"]) else: - yield from self._async_send_to_all_devices(message) + await self._async_send_to_all_devices(message) - @asyncio.coroutine - def _async_send_to_all_devices(self, message): + async def _async_send_to_all_devices(self, message): """Send a notification to knx bus to all connected devices.""" for device in self.devices: - yield from device.set(message) + await device.set(message) - @asyncio.coroutine - def _async_send_to_device(self, message, names): + async def _async_send_to_device(self, message, names): """Send a notification to knx bus to device with given names.""" for device in self.devices: if device.name in names: - yield from device.set(message) + await device.set(message) diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index fbfe2b6959a..8f0b9d5c7ab 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -68,22 +68,20 @@ def activate(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TURN_ON, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the scenes.""" logger = logging.getLogger(__name__) component = EntityComponent(logger, DOMAIN, hass) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_scene_service(service): + async def async_handle_scene_service(service): """Handle calls to the switch services.""" target_scenes = component.async_extract_from_service(service) tasks = [scene.async_activate() for scene in target_scenes] if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_scene_service, diff --git a/homeassistant/components/scene/velux.py b/homeassistant/components/scene/velux.py index 86d71153a2b..63bb23b1086 100644 --- a/homeassistant/components/scene/velux.py +++ b/homeassistant/components/scene/velux.py @@ -4,7 +4,6 @@ Support for VELUX scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.velux/ """ -import asyncio from homeassistant.components.scene import Scene from homeassistant.components.velux import _LOGGER, DATA_VELUX @@ -13,9 +12,8 @@ from homeassistant.components.velux import _LOGGER, DATA_VELUX DEPENDENCIES = ['velux'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the scenes for velux platform.""" entities = [] for scene in hass.data[DATA_VELUX].pyvlx.scenes: @@ -36,7 +34,6 @@ class VeluxScene(Scene): """Return the name of the scene.""" return self.scene.name - @asyncio.coroutine - def async_activate(self): + async def async_activate(self): """Activate the scene.""" - yield from self.scene.run() + await self.scene.run() diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 92b874cf2c8..e0bf3c86b05 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -4,7 +4,7 @@ Component to interface with various sensors that can be monitored. For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor/ """ -import asyncio + from datetime import timedelta import logging @@ -20,11 +20,10 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' SCAN_INTERVAL = timedelta(seconds=30) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for sensors.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) return True diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index bdceb729e89..8eeb75fb0f1 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.knx/ """ -import asyncio import voluptuous as vol @@ -28,8 +27,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up sensor(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -72,11 +71,10 @@ class KNXSensor(Entity): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 66a416c5bea..9a35198628a 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -93,33 +93,31 @@ def toggle(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TOGGLE, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for switches.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_switch_service(service): + async def async_handle_switch_service(service): """Handle calls to the switch services.""" target_switches = component.async_extract_from_service(service) update_tasks = [] for switch in target_switches: if service.service == SERVICE_TURN_ON: - yield from switch.async_turn_on() + await switch.async_turn_on() elif service.service == SERVICE_TOGGLE: - yield from switch.async_toggle() + await switch.async_toggle() else: - yield from switch.async_turn_off() + await switch.async_turn_off() if not switch.should_poll: continue update_tasks.append(switch.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_switch_service, diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 86a9adf0495..a96f96a9c5c 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ -import asyncio import voluptuous as vol @@ -27,8 +26,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up switch(es) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -71,11 +70,10 @@ class KNXSwitch(SwitchDevice): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -98,12 +96,10 @@ class KNXSwitch(SwitchDevice): """Return true if device is on.""" return self.device.state - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - yield from self.device.set_on() + await self.device.set_on() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - yield from self.device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/velux.py b/homeassistant/components/velux.py index ad541ee9cfe..47daf17f2a9 100644 --- a/homeassistant/components/velux.py +++ b/homeassistant/components/velux.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/velux/ """ import logging -import asyncio import voluptuous as vol @@ -28,13 +27,12 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the velux component.""" from pyvlx import PyVLXException try: hass.data[DATA_VELUX] = VeluxModule(hass, config) - yield from hass.data[DATA_VELUX].async_start() + await hass.data[DATA_VELUX].async_start() except PyVLXException as ex: _LOGGER.exception("Can't connect to velux interface: %s", ex) @@ -58,7 +56,6 @@ class VeluxModule: host=host, password=password) - @asyncio.coroutine - def async_start(self): + async def async_start(self): """Start velux component.""" - yield from self.pyvlx.load_scenes() + await self.pyvlx.load_scenes() From 2821820281b38647778295d054db2ab6982320d0 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sat, 24 Feb 2018 19:27:44 +0100 Subject: [PATCH 016/191] Cast automatically drop connection (#12635) --- homeassistant/components/media_player/cast.py | 17 +++++++++++------ tests/components/media_player/test_cast.py | 12 +++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 40e09ea328c..d3cf2f7b501 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -8,9 +8,11 @@ https://home-assistant.io/components/media_player.cast/ import asyncio import logging import threading +import functools import voluptuous as vol +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.core import callback from homeassistant.helpers.dispatcher import (dispatcher_send, @@ -34,6 +36,7 @@ CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' DEFAULT_PORT = 8009 +SOCKET_CLIENT_RETRIES = 10 SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ @@ -76,7 +79,7 @@ def _setup_internal_discovery(hass: HomeAssistantType) -> None: try: # pylint: disable=protected-access chromecast = pychromecast._get_chromecast_from_host( - mdns, blocking=True) + mdns, blocking=True, tries=SOCKET_CLIENT_RETRIES) except pychromecast.ChromecastConnectionError: _LOGGER.debug("Can't set up cast with mDNS info %s. " "Assuming it's not a Chromecast", mdns) @@ -182,11 +185,13 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType, else: # Manually add a "normal" Chromecast, we can do that without discovery. try: - chromecast = yield from hass.async_add_job( - pychromecast.Chromecast, *want_host) - except pychromecast.ChromecastConnectionError: - _LOGGER.warning("Can't set up chromecast on %s", want_host[0]) - raise + func = functools.partial(pychromecast.Chromecast, *want_host, + tries=SOCKET_CLIENT_RETRIES) + chromecast = yield from hass.async_add_job(func) + except pychromecast.ChromecastConnectionError as err: + _LOGGER.warning("Can't set up chromecast on %s: %s", + want_host[0], err) + raise PlatformNotReady key = (chromecast.host, chromecast.port, chromecast.uuid) cast_device = _async_create_cast_device(hass, chromecast) if cast_device is not None: diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 6eeb9136b07..aaaad47d8dc 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -7,6 +7,7 @@ from uuid import UUID import pytest +from homeassistant.exceptions import PlatformNotReady from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.media_player import cast @@ -122,7 +123,7 @@ def test_internal_discovery_callback_only_generates_once(hass): return_value=chromecast) as gen_chromecast: discover_cast('the-service', chromecast) mdns = (chromecast.host, chromecast.port, chromecast.uuid, None, None) - gen_chromecast.assert_called_once_with(mdns, blocking=True) + gen_chromecast.assert_called_once_with(mdns, blocking=True, tries=10) discover_cast('the-service', chromecast) gen_chromecast.reset_mock() @@ -196,6 +197,10 @@ def test_create_cast_device_with_uuid(hass): @asyncio.coroutine def test_normal_chromecast_not_starting_discovery(hass): """Test cast platform not starting discovery when not required.""" + import pychromecast # imports mock pychromecast + + pychromecast.ChromecastConnectionError = IOError + chromecast = get_fake_chromecast() with patch('pychromecast.Chromecast', return_value=chromecast): @@ -216,6 +221,11 @@ def test_normal_chromecast_not_starting_discovery(hass): hass, discovery_info={'host': 'host1', 'port': 42}) assert add_devices.call_count == 0 + with patch('pychromecast.Chromecast', + side_effect=pychromecast.ChromecastConnectionError): + with pytest.raises(PlatformNotReady): + yield from async_setup_cast(hass, {'host': 'host3'}) + @asyncio.coroutine def test_replay_past_chromecasts(hass): From 6d431c3fc3f6a8c9bf73c73396ddd0c47dd7aeff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 24 Feb 2018 10:53:59 -0800 Subject: [PATCH 017/191] Allow renaming entities in entity registry (#12636) * Allow renaming entities in entity registry * Lint --- homeassistant/components/config/__init__.py | 3 +- .../components/config/entity_registry.py | 55 +++++++ homeassistant/components/light/demo.py | 16 ++- homeassistant/helpers/entity.py | 6 + homeassistant/helpers/entity_platform.py | 11 +- homeassistant/helpers/entity_registry.py | 71 +++++++++- tests/common.py | 29 +++- .../components/config/test_entity_registry.py | 134 ++++++++++++++++++ tests/helpers/test_entity_platform.py | 55 +++---- 9 files changed, 333 insertions(+), 47 deletions(-) create mode 100644 homeassistant/components/config/entity_registry.py create mode 100644 tests/components/config/test_entity_registry.py diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 39c35205619..601b12ffe4a 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -13,7 +13,8 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script', + 'entity_registry') ON_DEMAND = ('zwave',) FEATURE_FLAGS = ('config_entries',) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py new file mode 100644 index 00000000000..4b9a2c89da0 --- /dev/null +++ b/homeassistant/components/config/entity_registry.py @@ -0,0 +1,55 @@ +"""HTTP views to interact with the entity registry.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers.entity_registry import async_get_registry + + +async def async_setup(hass): + """Enable the Entity Registry views.""" + hass.http.register_view(ConfigManagerEntityView) + return True + + +class ConfigManagerEntityView(HomeAssistantView): + """View to interact with an entity registry entry.""" + + url = '/api/config/entity_registry/{entity_id}' + name = 'api:config:entity_registry:entity' + + async def get(self, request, entity_id): + """Get the entity registry settings for an entity.""" + hass = request.app['hass'] + registry = await async_get_registry(hass) + entry = registry.entities.get(entity_id) + + if entry is None: + return self.json_message('Entry not found', 404) + + return self.json(_entry_dict(entry)) + + @RequestDataValidator(vol.Schema({ + # If passed in, we update value. Passing None will remove old value. + vol.Optional('name'): vol.Any(str, None), + })) + async def post(self, request, entity_id, data): + """Update the entity registry settings for an entity.""" + hass = request.app['hass'] + registry = await async_get_registry(hass) + + if entity_id not in registry.entities: + return self.json_message('Entry not found', 404) + + entry = registry.async_update_entity(entity_id, **data) + return self.json(_entry_dict(entry)) + + +@callback +def _entry_dict(entry): + """Helper to convert entry to API format.""" + return { + 'entity_id': entry.entity_id, + 'name': entry.name + } diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index d01611716eb..acc70a57ff4 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -28,11 +28,11 @@ SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the demo light platform.""" add_devices_callback([ - DemoLight("Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST, + DemoLight(1, "Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0]), - DemoLight("Ceiling Lights", True, True, + DemoLight(2, "Ceiling Lights", True, True, LIGHT_COLORS[0], LIGHT_TEMPS[1]), - DemoLight("Kitchen Lights", True, True, + DemoLight(3, "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0]) ]) @@ -40,10 +40,11 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class DemoLight(Light): """Representation of a demo light.""" - def __init__(self, name, state, available=False, rgb=None, ct=None, - brightness=180, xy_color=(.5, .5), white=200, + def __init__(self, unique_id, name, state, available=False, rgb=None, + ct=None, brightness=180, xy_color=(.5, .5), white=200, effect_list=None, effect=None): """Initialize the light.""" + self._unique_id = unique_id self._name = name self._state = state self._rgb = rgb @@ -64,6 +65,11 @@ class DemoLight(Light): """Return the name of the light if any.""" return self._name + @property + def unique_id(self): + """Return unique ID for light.""" + return self._unique_id + @property def available(self) -> bool: """Return availability.""" diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 04719e89187..9168c459f74 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -340,6 +340,12 @@ class Entity(object): else: self.hass.states.async_remove(self.entity_id) + @callback + def async_registry_updated(self, old, new): + """Called when the entity registry has been updated.""" + self.registry_name = new.name + self.async_schedule_update_ha_state() + def __eq__(self, other): """Return the comparison.""" if not isinstance(other, self.__class__): diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e17e178bcfb..f627ccd24b1 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -10,12 +10,11 @@ from homeassistant.util.async import ( import homeassistant.util.dt as dt_util from .event import async_track_time_interval, async_track_point_in_time -from .entity_registry import EntityRegistry +from .entity_registry import async_get_registry SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 PLATFORM_NOT_READY_RETRIES = 10 -DATA_REGISTRY = 'entity_registry' class EntityPlatform(object): @@ -156,12 +155,7 @@ class EntityPlatform(object): hass = self.hass component_entities = set(hass.states.async_entity_ids(self.domain)) - registry = hass.data.get(DATA_REGISTRY) - - if registry is None: - registry = hass.data[DATA_REGISTRY] = EntityRegistry(hass) - - yield from registry.async_ensure_loaded() + registry = yield from async_get_registry(hass) tasks = [ self._async_add_entity(entity, update_before_add, @@ -226,6 +220,7 @@ class EntityPlatform(object): entity.entity_id = entry.entity_id entity.registry_name = entry.name + entry.add_update_listener(entity) # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 89719b0b823..c6eafa91335 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -15,17 +15,20 @@ from collections import OrderedDict from itertools import chain import logging import os +import weakref import attr from ..core import callback, split_entity_id +from ..loader import bind_hass from ..util import ensure_unique_string, slugify from ..util.yaml import load_yaml, save_yaml PATH_REGISTRY = 'entity_registry.yaml' +DATA_REGISTRY = 'entity_registry' SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) - +_UNDEF = object() DISABLED_HASS = 'hass' DISABLED_USER = 'user' @@ -34,6 +37,8 @@ DISABLED_USER = 'user' class RegistryEntry: """Entity Registry Entry.""" + # pylint: disable=no-member + entity_id = attr.ib(type=str) unique_id = attr.ib(type=str) platform = attr.ib(type=str) @@ -41,17 +46,27 @@ class RegistryEntry: disabled_by = attr.ib( type=str, default=None, validator=attr.validators.in_((DISABLED_HASS, DISABLED_USER, None))) - domain = attr.ib(type=str, default=None, init=False, repr=False) + update_listeners = attr.ib(type=list, default=attr.Factory(list), + repr=False) + domain = attr.ib(type=str, init=False, repr=False) - def __attrs_post_init__(self): - """Computed properties.""" - object.__setattr__(self, "domain", split_entity_id(self.entity_id)[0]) + @domain.default + def _domain_default(self): + """Compute domain value.""" + return split_entity_id(self.entity_id)[0] @property def disabled(self): """Return if entry is disabled.""" return self.disabled_by is not None + def add_update_listener(self, listener): + """Listen for when entry is updated. + + Listener: Callback function(old_entry, new_entry) + """ + self.update_listeners.append(weakref.ref(listener)) + class EntityRegistry: """Class to hold a registry of entities.""" @@ -102,6 +117,39 @@ class EntityRegistry: self.async_schedule_save() return entity + @callback + def async_update_entity(self, entity_id, *, name=_UNDEF): + """Update properties of an entity.""" + old = self.entities[entity_id] + + changes = {} + + if name is not _UNDEF and name != old.name: + changes['name'] = name + + if not changes: + return old + + new = self.entities[entity_id] = attr.evolve(old, **changes) + + to_remove = [] + for listener_ref in new.update_listeners: + listener = listener_ref() + if listener is None: + to_remove.append(listener) + else: + try: + listener.async_registry_updated(old, new) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error calling update listener') + + for ref in to_remove: + new.update_listeners.remove(ref) + + self.async_schedule_save() + + return new + @asyncio.coroutine def async_ensure_loaded(self): """Load the registry from disk.""" @@ -154,7 +202,20 @@ class EntityRegistry: data[entry.entity_id] = { 'unique_id': entry.unique_id, 'platform': entry.platform, + 'name': entry.name, } yield from self.hass.async_add_job( save_yaml, self.hass.config.path(PATH_REGISTRY), data) + + +@bind_hass +async def async_get_registry(hass) -> EntityRegistry: + """Return entity registry instance.""" + registry = hass.data.get(DATA_REGISTRY) + + if registry is None: + registry = hass.data[DATA_REGISTRY] = EntityRegistry(hass) + + await registry.async_ensure_loaded() + return registry diff --git a/tests/common.py b/tests/common.py index 6fee7b1bec0..15ce80a9552 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,5 +1,6 @@ """Test the helper method for writing tests.""" import asyncio +from datetime import timedelta import functools as ft import os import sys @@ -298,7 +299,7 @@ def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) registry.entities = mock_entries or {} - hass.data[entity_platform.DATA_REGISTRY] = registry + hass.data[entity_registry.DATA_REGISTRY] = registry return registry @@ -361,6 +362,32 @@ class MockPlatform(object): self.async_setup_platform = mock_coro_func() +class MockEntityPlatform(entity_platform.EntityPlatform): + """Mock class with some mock defaults.""" + + def __init__( + self, hass, + logger=None, + domain='test_domain', + platform_name='test_platform', + scan_interval=timedelta(seconds=15), + parallel_updates=0, + entity_namespace=None, + async_entities_added_callback=lambda: None + ): + """Initialize a mock entity platform.""" + super().__init__( + hass=hass, + logger=logger, + domain=domain, + platform_name=platform_name, + scan_interval=scan_interval, + parallel_updates=parallel_updates, + entity_namespace=entity_namespace, + async_entities_added_callback=async_entities_added_callback, + ) + + class MockToggleDevice(entity.ToggleEntity): """Provide a mock toggle device.""" diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py new file mode 100644 index 00000000000..aa7a5ce5f0e --- /dev/null +++ b/tests/components/config/test_entity_registry.py @@ -0,0 +1,134 @@ +"""Test entity_registry API.""" +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.components.config import entity_registry +from tests.common import mock_registry, MockEntity, MockEntityPlatform + + +@pytest.fixture +def client(hass, test_client): + """Fixture that can interact with the config manager API.""" + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) + hass.loop.run_until_complete(entity_registry.async_setup(hass)) + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + +async def test_get_entity(hass, client): + """Test get entry.""" + mock_registry(hass, { + 'test_domain.name': RegistryEntry( + entity_id='test_domain.name', + unique_id='1234', + platform='test_platform', + name='Hello World' + ), + 'test_domain.no_name': RegistryEntry( + entity_id='test_domain.no_name', + unique_id='6789', + platform='test_platform', + ), + }) + + resp = await client.get( + '/api/config/entity_registry/test_domain.name') + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.name', + 'name': 'Hello World' + } + + resp = await client.get( + '/api/config/entity_registry/test_domain.no_name') + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.no_name', + 'name': None + } + + +async def test_update_entity(hass, client): + """Test get entry.""" + mock_registry(hass, { + 'test_domain.world': RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='before update' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'before update' + + resp = await client.post( + '/api/config/entity_registry/test_domain.world', json={ + 'name': 'after update' + }) + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.world', + 'name': 'after update' + } + + state = hass.states.get('test_domain.world') + assert state.name == 'after update' + + +async def test_update_entity_no_changes(hass, client): + """Test get entry.""" + mock_registry(hass, { + 'test_domain.world': RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='name of entity' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'name of entity' + + resp = await client.post( + '/api/config/entity_registry/test_domain.world', json={ + 'name': 'name of entity' + }) + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.world', + 'name': 'name of entity' + } + + state = hass.states.get('test_domain.world') + assert state.name == 'name of entity' + + +async def test_get_nonexisting_entity(client): + """Test get entry.""" + resp = await client.get( + '/api/config/entity_registry/test_domain.non_existing') + assert resp.status == 404 + + +async def test_update_nonexisting_entity(client): + """Test get entry.""" + resp = await client.post( + '/api/config/entity_registry/test_domain.non_existing', json={ + 'name': 'some name' + }) + assert resp.status == 404 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 0681691ed67..8c085e4abb1 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -15,39 +15,13 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, fire_time_changed, mock_registry, - MockEntity) + MockEntity, MockEntityPlatform) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" PLATFORM = 'test_platform' -class MockEntityPlatform(entity_platform.EntityPlatform): - """Mock class with some mock defaults.""" - - def __init__( - self, hass, - logger=None, - domain=DOMAIN, - platform_name=PLATFORM, - scan_interval=timedelta(seconds=15), - parallel_updates=0, - entity_namespace=None, - async_entities_added_callback=lambda: None - ): - """Initialize a mock entity platform.""" - super().__init__( - hass=hass, - logger=logger, - domain=domain, - platform_name=platform_name, - scan_interval=scan_interval, - parallel_updates=parallel_updates, - entity_namespace=entity_namespace, - async_entities_added_callback=async_entities_added_callback, - ) - - class TestHelpersEntityPlatform(unittest.TestCase): """Test homeassistant.helpers.entity_component module.""" @@ -510,3 +484,30 @@ def test_registry_respect_entity_disabled(hass): yield from platform.async_add_entities([entity]) assert entity.entity_id is None assert hass.states.async_entity_ids() == [] + + +async def test_entity_registry_updates(hass): + """Test that updates on the entity registry update platform entities.""" + registry = mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='before update' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'before update' + + registry.async_update_entity('test_domain.world', name='after update') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('test_domain.world') + assert state.name == 'after update' From f51a3738aa4041e7c4bd199d4f17c6f54129d0f0 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Feb 2018 22:11:49 -0500 Subject: [PATCH 018/191] Check if $files is empty, don't try to execute it (#12651) --- script/lint | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/lint b/script/lint index b16b92a45b4..a024562e824 100755 --- a/script/lint +++ b/script/lint @@ -8,7 +8,7 @@ if [ "$1" = "--changed" ]; then echo "=================================================" echo "FILES CHANGED (git diff upstream/dev... --name-only)" echo "=================================================" - if $files >/dev/null; then + if [ -z "$files" ] ; then echo "No python file changed" exit fi From da832dbda29b95bb3f581a8ab6b33ce57475bb62 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sun, 25 Feb 2018 08:05:20 +0100 Subject: [PATCH 019/191] Removed py34 (#12648) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index fafc149f624..86acefe9b3f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py34, py35, py36, lint, requirements, typing +envlist = py35, py36, lint, requirements, typing skip_missing_interpreters = True [testenv] From eacfbc048a9ffaccc6379056a0b27d3c2c20287b Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sun, 25 Feb 2018 10:58:13 +0100 Subject: [PATCH 020/191] Improved Homekit tests (#12647) * Spelling and typos * Updated 'test_homekit_pyhap_interaction' * Patch ip_address --- homeassistant/components/homekit/__init__.py | 18 +++--- homeassistant/components/homekit/const.py | 2 +- tests/components/homekit/test_covers.py | 2 +- tests/components/homekit/test_homekit.py | 62 ++++++++++---------- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 021c682466e..1af56a81137 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,4 +1,4 @@ -"""Support for Apple Homekit. +"""Support for Apple HomeKit. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/homekit/ @@ -30,7 +30,7 @@ HOMEKIT_FILE = '.homekit.state' def valid_pin(value): - """Validate pincode value.""" + """Validate pin code value.""" match = _RE_VALID_PINCODE.findall(value.strip()) if match == []: raise vol.Invalid("Pin must be in the format: '123-45-678'") @@ -47,14 +47,14 @@ CONFIG_SCHEMA = vol.Schema({ @asyncio.coroutine def async_setup(hass, config): - """Setup the homekit component.""" - _LOGGER.debug("Begin setup homekit") + """Setup the HomeKit component.""" + _LOGGER.debug("Begin setup HomeKit") conf = config[DOMAIN] port = conf.get(CONF_PORT) pin = str.encode(conf.get(CONF_PIN_CODE)) - homekit = Homekit(hass, port) + homekit = HomeKit(hass, port) homekit.setup_bridge(pin) hass.bus.async_listen_once( @@ -63,7 +63,7 @@ def async_setup(hass, config): def import_types(): - """Import all types from files in the homekit dir.""" + """Import all types from files in the HomeKit dir.""" _LOGGER.debug("Import type files.") # pylint: disable=unused-variable from .covers import Window # noqa F401 @@ -90,11 +90,11 @@ def get_accessory(hass, state): return None -class Homekit(): - """Class to handle all actions between homekit and Home Assistant.""" +class HomeKit(): + """Class to handle all actions between HomeKit and Home Assistant.""" def __init__(self, hass, port): - """Initialize a homekit object.""" + """Initialize a HomeKit object.""" self._hass = hass self._port = port self.bridge = None diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 6c58b7fe45f..02b2e6b8f67 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,4 +1,4 @@ -"""Constants used be the homekit component.""" +"""Constants used be the HomeKit component.""" MANUFACTURER = 'HomeAssistant' # Service: AccessoryInfomation diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py index b6e8334346a..f665a92682c 100644 --- a/tests/components/homekit/test_covers.py +++ b/tests/components/homekit/test_covers.py @@ -28,7 +28,7 @@ class TestHomekitSensors(unittest.TestCase): self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) def tearDown(self): - """Stop down everthing that was started.""" + """Stop down everything that was started.""" self.hass.stop() def test_window_set_cover_position(self): diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 06cb8096140..ca6bf8a8510 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1,16 +1,14 @@ -"""Tests for the homekit component.""" +"""Tests for the HomeKit component.""" import unittest -from unittest.mock import patch +from unittest.mock import call, patch import voluptuous as vol from homeassistant import setup from homeassistant.core import Event from homeassistant.components.homekit import ( - CONF_PIN_CODE, BRIDGE_NAME, Homekit, valid_pin) -from homeassistant.components.homekit.covers import Window -from homeassistant.components.homekit.sensors import TemperatureSensor + CONF_PIN_CODE, BRIDGE_NAME, HOMEKIT_FILE, HomeKit, valid_pin) from homeassistant.const import ( CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -27,20 +25,20 @@ CONFIG = { } -class TestHomekit(unittest.TestCase): - """Test the Multicover component.""" +class TestHomeKit(unittest.TestCase): + """Test setup of HomeKit component and HomeKit class.""" def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() def tearDown(self): - """Stop down everthing that was started.""" + """Stop down everything that was started.""" self.hass.stop() - @patch(HOMEKIT_PATH + '.Homekit.start_driver') - @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') - @patch(HOMEKIT_PATH + '.Homekit.__init__') + @patch(HOMEKIT_PATH + '.HomeKit.start_driver') + @patch(HOMEKIT_PATH + '.HomeKit.setup_bridge') + @patch(HOMEKIT_PATH + '.HomeKit.__init__') def test_setup_min(self, mock_homekit, mock_setup_bridge, mock_start_driver): """Test async_setup with minimal config option.""" @@ -57,9 +55,9 @@ class TestHomekit(unittest.TestCase): self.hass.block_till_done() self.assertEqual(mock_start_driver.call_count, 1) - @patch(HOMEKIT_PATH + '.Homekit.start_driver') - @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') - @patch(HOMEKIT_PATH + '.Homekit.__init__') + @patch(HOMEKIT_PATH + '.HomeKit.start_driver') + @patch(HOMEKIT_PATH + '.HomeKit.setup_bridge') + @patch(HOMEKIT_PATH + '.HomeKit.__init__') def test_setup_parameters(self, mock_homekit, mock_setup_bridge, mock_start_driver): """Test async_setup with full config option.""" @@ -82,20 +80,17 @@ class TestHomekit(unittest.TestCase): for value in ('123-45-678', '234-56-789'): self.assertTrue(schema(value)) - @patch('pyhap.accessory_driver.AccessoryDriver.persist') - @patch('pyhap.accessory_driver.AccessoryDriver.stop') - @patch('pyhap.accessory_driver.AccessoryDriver.start') + @patch('pyhap.accessory_driver.AccessoryDriver') + @patch('pyhap.accessory.Bridge.add_accessory') @patch(HOMEKIT_PATH + '.import_types') @patch(HOMEKIT_PATH + '.get_accessory') def test_homekit_pyhap_interaction( self, mock_get_accessory, mock_import_types, - mock_driver_start, mock_driver_stop, mock_file_persist): - """Test the interaction between the homekit class and pyhap.""" - acc1 = TemperatureSensor(self.hass, 'sensor.temp', 'Temperature') - acc2 = Window(self.hass, 'cover.hall_window', 'Cover') - mock_get_accessory.side_effect = [acc1, acc2] + mock_add_accessory, mock_acc_driver): + """Test interaction between the HomeKit class and pyhap.""" + mock_get_accessory.side_effect = ['TemperatureSensor', 'Window'] - homekit = Homekit(self.hass, 51826) + homekit = HomeKit(self.hass, 51826) homekit.setup_bridge(b'123-45-678') self.assertEqual(homekit.bridge.display_name, BRIDGE_NAME) @@ -106,19 +101,24 @@ class TestHomekit(unittest.TestCase): self.hass.start() self.hass.block_till_done() - homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) + with patch('homeassistant.util.get_local_ip', + return_value='127.0.0.1'): + homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) + + ip_address = '127.0.0.1' + path = self.hass.config.path(HOMEKIT_FILE) self.assertEqual(mock_get_accessory.call_count, 2) self.assertEqual(mock_import_types.call_count, 1) - self.assertEqual(mock_driver_start.call_count, 1) + self.assertEqual(mock_acc_driver.mock_calls, + [call(homekit.bridge, 51826, ip_address, path), + call().start()]) - accessories = homekit.bridge.accessories - self.assertEqual(accessories[2], acc1) - self.assertEqual(accessories[3], acc2) - - mock_driver_stop.assert_not_called() + self.assertEqual(mock_add_accessory.mock_calls, + [call('TemperatureSensor'), call('Window')]) self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() - self.assertEqual(mock_driver_stop.call_count, 1) + self.assertEqual(mock_acc_driver.mock_calls[2], call().stop()) + self.assertEqual(len(mock_acc_driver.mock_calls), 3) From 16cb7388eeb2b183951f4b1879671470267c66f3 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sun, 25 Feb 2018 12:38:46 +0100 Subject: [PATCH 021/191] Removing asyncio.coroutine syntax from HASS core (#12509) * changed asyncio.coroutine syntax to new async def/await * removed py34 from tox environment * reverted some changes within entity.py * - * reverted changes within bootstrap.py * reverted changes within discovery.py * switched decorators * Reverted change within aiohttp_client.py * reverted change within logging.py * switched decorators * Await lock properly * removed asyncio.coroutine from test --- .../components/config/config_entries.py | 4 +- homeassistant/config.py | 21 +++---- homeassistant/config_entries.py | 57 ++++++++----------- homeassistant/helpers/aiohttp_client.py | 13 ++--- homeassistant/helpers/entity.py | 5 +- homeassistant/helpers/entity_component.py | 36 +++++------- homeassistant/helpers/entity_platform.py | 49 +++++++--------- homeassistant/helpers/entity_registry.py | 17 +++--- homeassistant/helpers/intent.py | 15 ++--- homeassistant/helpers/restore_state.py | 16 +++--- homeassistant/helpers/script.py | 12 ++-- homeassistant/helpers/service.py | 13 ++--- homeassistant/helpers/state.py | 10 ++-- homeassistant/requirements.py | 7 +-- homeassistant/scripts/benchmark/__init__.py | 15 ++--- homeassistant/scripts/check_config.py | 8 +-- homeassistant/setup.py | 41 ++++++------- homeassistant/util/package.py | 8 +-- script/test | 2 +- tests/util/test_logging.py | 1 - 20 files changed, 148 insertions(+), 202 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index ebfa095372a..7c4dcbd1602 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -97,10 +97,10 @@ class ConfigManagerFlowIndexView(HomeAssistantView): flow for flow in hass.config_entries.flow.async_progress() if flow['source'] != config_entries.SOURCE_USER]) - @asyncio.coroutine @RequestDataValidator(vol.Schema({ vol.Required('domain'): str, })) + @asyncio.coroutine def post(self, request, data): """Handle a POST request.""" hass = request.app['hass'] @@ -139,8 +139,8 @@ class ConfigManagerFlowResourceView(HomeAssistantView): return self.json(result) - @asyncio.coroutine @RequestDataValidator(vol.Schema(dict), allow_empty=True) + @asyncio.coroutine def post(self, request, flow_id, data): """Handle a POST request.""" hass = request.app['hass'] diff --git a/homeassistant/config.py b/homeassistant/config.py index 6507e2a74f6..1c8ca10f8c6 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -260,8 +260,7 @@ def create_default_config(config_dir, detect_location=True): return None -@asyncio.coroutine -def async_hass_config_yaml(hass): +async def async_hass_config_yaml(hass): """Load YAML from a Home Assistant configuration file. This function allow a component inside the asyncio loop to reload its @@ -274,7 +273,7 @@ def async_hass_config_yaml(hass): conf = load_yaml_config_file(path) return conf - conf = yield from hass.async_add_job(_load_hass_yaml_config) + conf = await hass.async_add_job(_load_hass_yaml_config) return conf @@ -373,8 +372,7 @@ def async_log_exception(ex, domain, config, hass): _LOGGER.error(message) -@asyncio.coroutine -def async_process_ha_core_config(hass, config): +async def async_process_ha_core_config(hass, config): """Process the [homeassistant] section from the configuration. This method is a coroutine. @@ -461,7 +459,7 @@ def async_process_ha_core_config(hass, config): # If we miss some of the needed values, auto detect them if None in (hac.latitude, hac.longitude, hac.units, hac.time_zone): - info = yield from hass.async_add_job( + info = await hass.async_add_job( loc_util.detect_location_info) if info is None: @@ -487,7 +485,7 @@ def async_process_ha_core_config(hass, config): if hac.elevation is None and hac.latitude is not None and \ hac.longitude is not None: - elevation = yield from hass.async_add_job( + elevation = await hass.async_add_job( loc_util.elevation, hac.latitude, hac.longitude) hac.elevation = elevation discovered.append(('elevation', elevation)) @@ -648,21 +646,20 @@ def async_process_component_config(hass, config, domain): return config -@asyncio.coroutine -def async_check_ha_config_file(hass): +async def async_check_ha_config_file(hass): """Check if Home Assistant configuration file is valid. This method is a coroutine. """ - proc = yield from asyncio.create_subprocess_exec( + proc = await asyncio.create_subprocess_exec( sys.executable, '-m', 'homeassistant', '--script', 'check_config', '--config', hass.config.config_dir, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, loop=hass.loop) # Wait for the subprocess exit - log, _ = yield from proc.communicate() - exit_code = yield from proc.wait() + log, _ = await proc.communicate() + exit_code = await proc.wait() # Convert to ASCII log = RE_ASCII.sub('', log.decode()) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7b5d23d284f..63eff4e1f77 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -76,7 +76,7 @@ If the user input passes validation, you can again return one of the three return values. If you want to navigate the user to the next step, return the return value of that step: - return (await self.async_step_account()) + return await self.async_step_account() ### Abort @@ -110,7 +110,7 @@ should follow the same return values as a normal step. If the result of the step is to show a form, the user will be able to continue the flow from the config panel. """ -import asyncio + import logging import os import uuid @@ -176,14 +176,13 @@ class ConfigEntry: # State of the entry (LOADED, NOT_LOADED) self.state = state - @asyncio.coroutine - def async_setup(self, hass, *, component=None): + async def async_setup(self, hass, *, component=None): """Set up an entry.""" if component is None: component = getattr(hass.components, self.domain) try: - result = yield from component.async_setup_entry(hass, self) + result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): _LOGGER.error('%s.async_config_entry did not return boolean', @@ -199,8 +198,7 @@ class ConfigEntry: else: self.state = ENTRY_STATE_SETUP_ERROR - @asyncio.coroutine - def async_unload(self, hass): + async def async_unload(self, hass): """Unload an entry. Returns if unload is possible and was successful. @@ -213,7 +211,7 @@ class ConfigEntry: return False try: - result = yield from component.async_unload_entry(hass, self) + result = await component.async_unload_entry(hass, self) if not isinstance(result, bool): _LOGGER.error('%s.async_unload_entry did not return boolean', @@ -293,8 +291,7 @@ class ConfigEntries: return list(self._entries) return [entry for entry in self._entries if entry.domain == domain] - @asyncio.coroutine - def async_remove(self, entry_id): + async def async_remove(self, entry_id): """Remove an entry.""" found = None for index, entry in enumerate(self._entries): @@ -308,25 +305,23 @@ class ConfigEntries: entry = self._entries.pop(found) self._async_schedule_save() - unloaded = yield from entry.async_unload(self.hass) + unloaded = await entry.async_unload(self.hass) return { 'require_restart': not unloaded } - @asyncio.coroutine - def async_load(self): + async def async_load(self): """Load the config.""" path = self.hass.config.path(PATH_CONFIG) if not os.path.isfile(path): self._entries = [] return - entries = yield from self.hass.async_add_job(load_json, path) + entries = await self.hass.async_add_job(load_json, path) self._entries = [ConfigEntry(**entry) for entry in entries] - @asyncio.coroutine - def _async_add_entry(self, entry): + async def _async_add_entry(self, entry): """Add an entry.""" self._entries.append(entry) self._async_schedule_save() @@ -334,10 +329,10 @@ class ConfigEntries: # Setup entry if entry.domain in self.hass.config.components: # Component already set up, just need to call setup_entry - yield from entry.async_setup(self.hass) + await entry.async_setup(self.hass) else: # Setting up component will also load the entries - yield from async_setup_component( + await async_setup_component( self.hass, entry.domain, self._hass_config) @callback @@ -350,13 +345,12 @@ class ConfigEntries: SAVE_DELAY, self.hass.async_add_job, self._async_save ) - @asyncio.coroutine - def _async_save(self): + async def _async_save(self): """Save the entity registry to a file.""" self._sched_save = None data = [entry.as_dict() for entry in self._entries] - yield from self.hass.async_add_job( + await self.hass.async_add_job( save_json, self.hass.config.path(PATH_CONFIG), data) @@ -379,8 +373,7 @@ class FlowManager: 'source': flow.source, } for flow in self._progress.values()] - @asyncio.coroutine - def async_init(self, domain, *, source=SOURCE_USER, data=None): + async def async_init(self, domain, *, source=SOURCE_USER, data=None): """Start a configuration flow.""" handler = HANDLERS.get(domain) @@ -393,7 +386,7 @@ class FlowManager: raise self.hass.helpers.UnknownHandler # Make sure requirements and dependencies of component are resolved - yield from async_process_deps_reqs( + await async_process_deps_reqs( self.hass, self._hass_config, domain, component) flow_id = uuid.uuid4().hex @@ -408,10 +401,9 @@ class FlowManager: else: step = source - return (yield from self._async_handle_step(flow, step, data)) + return await self._async_handle_step(flow, step, data) - @asyncio.coroutine - def async_configure(self, flow_id, user_input=None): + async def async_configure(self, flow_id, user_input=None): """Start or continue a configuration flow.""" flow = self._progress.get(flow_id) @@ -423,8 +415,8 @@ class FlowManager: if data_schema is not None and user_input is not None: user_input = data_schema(user_input) - return (yield from self._async_handle_step( - flow, step_id, user_input)) + return await self._async_handle_step( + flow, step_id, user_input) @callback def async_abort(self, flow_id): @@ -432,8 +424,7 @@ class FlowManager: if self._progress.pop(flow_id, None) is None: raise UnknownFlow - @asyncio.coroutine - def _async_handle_step(self, flow, step_id, user_input): + async def _async_handle_step(self, flow, step_id, user_input): """Handle a step of a flow.""" method = "async_step_{}".format(step_id) @@ -442,7 +433,7 @@ class FlowManager: raise UnknownStep("Handler {} doesn't support step {}".format( flow.__class__.__name__, step_id)) - result = yield from getattr(flow, method)(user_input) + result = await getattr(flow, method)(user_input) if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_ABORT): @@ -466,7 +457,7 @@ class FlowManager: data=result.pop('data'), source=flow.source ) - yield from self._async_add_entry(entry) + await self._async_add_entry(entry) return result diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 239aaea64a0..5a6e0eae1e3 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -106,29 +106,28 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, req.close() -@asyncio.coroutine @bind_hass -def async_aiohttp_proxy_stream(hass, request, stream, content_type, - buffer_size=102400, timeout=10): +async def async_aiohttp_proxy_stream(hass, request, stream, content_type, + buffer_size=102400, timeout=10): """Stream a stream to aiohttp web response.""" response = web.StreamResponse() response.content_type = content_type - yield from response.prepare(request) + await response.prepare(request) try: while True: with async_timeout.timeout(timeout, loop=hass.loop): - data = yield from stream.read(buffer_size) + data = await stream.read(buffer_size) if not data: - yield from response.write_eof() + await response.write_eof() break response.write(data) except (asyncio.TimeoutError, aiohttp.ClientError): # Something went wrong fetching data, close connection gracefully - yield from response.write_eof() + await response.write_eof() except asyncio.CancelledError: # The user closed the connection diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9168c459f74..a3c6e7a944b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -332,11 +332,10 @@ class Entity(object): if self.parallel_updates: self.parallel_updates.release() - @asyncio.coroutine - def async_remove(self): + async def async_remove(self): """Remove entity from Home Assistant.""" if self.platform is not None: - yield from self.platform.async_remove_entity(self.entity_id) + await self.platform.async_remove_entity(self.entity_id) else: self.hass.states.async_remove(self.entity_id) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2dcde6fdeda..f086437c10d 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -75,8 +75,7 @@ class EntityComponent(object): """ self.hass.add_job(self.async_setup(config)) - @asyncio.coroutine - def async_setup(self, config): + async def async_setup(self, config): """Set up a full entity component. Loads the platforms from the config and will listen for supported @@ -92,14 +91,13 @@ class EntityComponent(object): tasks.append(self._async_setup_platform(p_type, p_config)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.components.discovery.load_platform() - @asyncio.coroutine - def component_platform_discovered(platform, info): + async def component_platform_discovered(platform, info): """Handle the loading of a platform.""" - yield from self._async_setup_platform(platform, {}, info) + await self._async_setup_platform(platform, {}, info) discovery.async_listen_platform( self.hass, self.domain, component_platform_discovered) @@ -120,11 +118,10 @@ class EntityComponent(object): return [entity for entity in self.entities if entity.available and entity.entity_id in entity_ids] - @asyncio.coroutine - def _async_setup_platform(self, platform_type, platform_config, - discovery_info=None): + async def _async_setup_platform(self, platform_type, platform_config, + discovery_info=None): """Set up a platform for this component.""" - platform = yield from async_prepare_setup_platform( + platform = await async_prepare_setup_platform( self.hass, self.config, self.domain, platform_type) if platform is None: @@ -156,7 +153,7 @@ class EntityComponent(object): else: entity_platform = self._platforms[key] - yield from entity_platform.async_setup( + await entity_platform.async_setup( platform, platform_config, discovery_info) @callback @@ -177,8 +174,7 @@ class EntityComponent(object): visible=False, entity_ids=ids ) - @asyncio.coroutine - def _async_reset(self): + async def _async_reset(self): """Remove entities and reset the entity component to initial values. This method must be run in the event loop. @@ -187,7 +183,7 @@ class EntityComponent(object): in self._platforms.values()] if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self._platforms = { self.domain: self._platforms[self.domain] @@ -197,21 +193,19 @@ class EntityComponent(object): if self.group_name is not None: self.hass.components.group.async_remove(slugify(self.group_name)) - @asyncio.coroutine - def async_remove_entity(self, entity_id): + async def async_remove_entity(self, entity_id): """Remove an entity managed by one of the platforms.""" for platform in self._platforms.values(): if entity_id in platform.entities: - yield from platform.async_remove_entity(entity_id) + await platform.async_remove_entity(entity_id) - @asyncio.coroutine - def async_prepare_reload(self): + async def async_prepare_reload(self): """Prepare reloading this entity component. This method must be run in the event loop. """ try: - conf = yield from \ + conf = await \ conf_util.async_hass_config_yaml(self.hass) except HomeAssistantError as err: self.logger.error(err) @@ -223,5 +217,5 @@ class EntityComponent(object): if conf is None: return None - yield from self._async_reset() + await self._async_reset() return conf diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index f627ccd24b1..d28212a34d1 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -51,9 +51,8 @@ class EntityPlatform(object): self.parallel_updates = asyncio.Semaphore( parallel_updates, loop=hass.loop) - @asyncio.coroutine - def async_setup(self, platform, platform_config, discovery_info=None, - tries=0): + async def async_setup(self, platform, platform_config, discovery_info=None, + tries=0): """Setup the platform.""" logger = self.logger hass = self.hass @@ -78,7 +77,7 @@ class EntityPlatform(object): None, platform.setup_platform, hass, platform_config, self._schedule_add_entities, discovery_info ) - yield from asyncio.wait_for( + await asyncio.wait_for( asyncio.shield(task, loop=hass.loop), SLOW_SETUP_MAX_WAIT, loop=hass.loop) @@ -88,7 +87,7 @@ class EntityPlatform(object): self._tasks.clear() if pending: - yield from asyncio.wait( + await asyncio.wait( pending, loop=self.hass.loop) hass.config.components.add(full_name) @@ -142,8 +141,7 @@ class EntityPlatform(object): self.async_add_entities(list(new_entities), update_before_add), self.hass.loop).result() - @asyncio.coroutine - def async_add_entities(self, new_entities, update_before_add=False): + async def async_add_entities(self, new_entities, update_before_add=False): """Add entities for a single platform async. This method must be run in the event loop. @@ -155,14 +153,14 @@ class EntityPlatform(object): hass = self.hass component_entities = set(hass.states.async_entity_ids(self.domain)) - registry = yield from async_get_registry(hass) + registry = await async_get_registry(hass) tasks = [ self._async_add_entity(entity, update_before_add, component_entities, registry) for entity in new_entities] - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self.async_entities_added_callback() if self._async_unsub_polling is not None or \ @@ -174,9 +172,8 @@ class EntityPlatform(object): self.hass, self._update_entity_states, self.scan_interval ) - @asyncio.coroutine - def _async_add_entity(self, entity, update_before_add, component_entities, - registry): + async def _async_add_entity(self, entity, update_before_add, + component_entities, registry): """Helper method to add an entity to the platform.""" if entity is None: raise ValueError('Entity cannot be None') @@ -188,7 +185,7 @@ class EntityPlatform(object): # Update properties before we generate the entity_id if update_before_add: try: - yield from entity.async_device_update(warning=False) + await entity.async_device_update(warning=False) except Exception: # pylint: disable=broad-except self.logger.exception( "%s: Error on device update!", self.platform_name) @@ -258,12 +255,11 @@ class EntityPlatform(object): component_entities.add(entity.entity_id) if hasattr(entity, 'async_added_to_hass'): - yield from entity.async_added_to_hass() + await entity.async_added_to_hass() - yield from entity.async_update_ha_state() + await entity.async_update_ha_state() - @asyncio.coroutine - def async_reset(self): + async def async_reset(self): """Remove all entities and reset data. This method must be run in the event loop. @@ -274,16 +270,15 @@ class EntityPlatform(object): tasks = [self._async_remove_entity(entity_id) for entity_id in self.entities] - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) if self._async_unsub_polling is not None: self._async_unsub_polling() self._async_unsub_polling = None - @asyncio.coroutine - def async_remove_entity(self, entity_id): + async def async_remove_entity(self, entity_id): """Remove entity id from platform.""" - yield from self._async_remove_entity(entity_id) + await self._async_remove_entity(entity_id) # Clean up polling job if no longer needed if (self._async_unsub_polling is not None and @@ -292,18 +287,16 @@ class EntityPlatform(object): self._async_unsub_polling() self._async_unsub_polling = None - @asyncio.coroutine - def _async_remove_entity(self, entity_id): + async def _async_remove_entity(self, entity_id): """Remove entity id from platform.""" entity = self.entities.pop(entity_id) if hasattr(entity, 'async_will_remove_from_hass'): - yield from entity.async_will_remove_from_hass() + await entity.async_will_remove_from_hass() self.hass.states.async_remove(entity_id) - @asyncio.coroutine - def _update_entity_states(self, now): + async def _update_entity_states(self, now): """Update the states of all the polling entities. To protect from flooding the executor, we will update async entities @@ -318,7 +311,7 @@ class EntityPlatform(object): self.scan_interval) return - with (yield from self._process_updates): + with (await self._process_updates): tasks = [] for entity in self.entities.values(): if not entity.should_poll: @@ -326,4 +319,4 @@ class EntityPlatform(object): tasks.append(entity.async_update_ha_state(True)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index c6eafa91335..b5a9c309119 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,7 +10,7 @@ timer. After initializing, call EntityRegistry.async_ensure_loaded to load the data from disk. """ -import asyncio + from collections import OrderedDict from itertools import chain import logging @@ -150,8 +150,7 @@ class EntityRegistry: return new - @asyncio.coroutine - def async_ensure_loaded(self): + async def async_ensure_loaded(self): """Load the registry from disk.""" if self.entities is not None: return @@ -159,16 +158,15 @@ class EntityRegistry: if self._load_task is None: self._load_task = self.hass.async_add_job(self._async_load) - yield from self._load_task + await self._load_task - @asyncio.coroutine - def _async_load(self): + async def _async_load(self): """Load the entity registry.""" path = self.hass.config.path(PATH_REGISTRY) entities = OrderedDict() if os.path.isfile(path): - data = yield from self.hass.async_add_job(load_yaml, path) + data = await self.hass.async_add_job(load_yaml, path) for entity_id, info in data.items(): entities[entity_id] = RegistryEntry( @@ -192,8 +190,7 @@ class EntityRegistry: SAVE_DELAY, self.hass.async_add_job, self._async_save ) - @asyncio.coroutine - def _async_save(self): + async def _async_save(self): """Save the entity registry to a file.""" self._sched_save = None data = OrderedDict() @@ -205,7 +202,7 @@ class EntityRegistry: 'name': entry.name, } - yield from self.hass.async_add_job( + await self.hass.async_add_job( save_yaml, self.hass.config.path(PATH_REGISTRY), data) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index bf2773d32b8..dfbce4e82a5 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,5 +1,4 @@ """Module to coordinate user intentions.""" -import asyncio import logging import re @@ -41,9 +40,9 @@ def async_register(hass, handler): intents[handler.intent_type] = handler -@asyncio.coroutine @bind_hass -def async_handle(hass, platform, intent_type, slots=None, text_input=None): +async def async_handle(hass, platform, intent_type, slots=None, + text_input=None): """Handle an intent.""" handler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -54,7 +53,7 @@ def async_handle(hass, platform, intent_type, slots=None, text_input=None): try: _LOGGER.info("Triggering intent handler %s", handler) - result = yield from handler.async_handle(intent) + result = await handler.async_handle(intent) return result except vol.Invalid as err: raise InvalidSlotInfo( @@ -114,8 +113,7 @@ class IntentHandler: return self._slot_schema(slots) - @asyncio.coroutine - def async_handle(self, intent_obj): + async def async_handle(self, intent_obj): """Handle the intent.""" raise NotImplementedError() @@ -153,8 +151,7 @@ class ServiceIntentHandler(IntentHandler): self.service = service self.speech = speech - @asyncio.coroutine - def async_handle(self, intent_obj): + async def async_handle(self, intent_obj): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) @@ -175,7 +172,7 @@ class ServiceIntentHandler(IntentHandler): _LOGGER.error("Could not find entity id matching %s", name) return response - yield from hass.services.async_call( + await hass.services.async_call( self.domain, self.service, { ATTR_ENTITY_ID: entity_id }) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index a2940f06022..aac00b07d7a 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -49,9 +49,8 @@ def _load_restore_cache(hass: HomeAssistant): _LOGGER.debug('Created cache with %s', list(hass.data[DATA_RESTORE_CACHE])) -@asyncio.coroutine @bind_hass -def async_get_last_state(hass, entity_id: str): +async def async_get_last_state(hass, entity_id: str): """Restore state.""" if DATA_RESTORE_CACHE in hass.data: return hass.data[DATA_RESTORE_CACHE].get(entity_id) @@ -66,7 +65,7 @@ def async_get_last_state(hass, entity_id: str): try: with async_timeout.timeout(RECORDER_TIMEOUT, loop=hass.loop): - connected = yield from wait_connection_ready(hass) + connected = await wait_connection_ready(hass) except asyncio.TimeoutError: return None @@ -76,25 +75,24 @@ def async_get_last_state(hass, entity_id: str): if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) - with (yield from hass.data[_LOCK]): + with (await hass.data[_LOCK]): if DATA_RESTORE_CACHE not in hass.data: - yield from hass.async_add_job( + await hass.async_add_job( _load_restore_cache, hass) return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) -@asyncio.coroutine -def async_restore_state(entity, extract_info): +async def async_restore_state(entity, extract_info): """Call entity.async_restore_state with cached info.""" if entity.hass.state not in (CoreState.starting, CoreState.not_running): _LOGGER.debug("Not restoring state for %s: Hass is not starting: %s", entity.entity_id, entity.hass.state) return - state = yield from async_get_last_state(entity.hass, entity.entity_id) + state = await async_get_last_state(entity.hass, entity.entity_id) if not state: return - yield from entity.async_restore_state(**extract_info(state)) + await entity.async_restore_state(**extract_info(state)) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 7a989267572..6530cb62485 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,5 +1,5 @@ """Helpers to execute scripts.""" -import asyncio + import logging from itertools import islice from typing import Optional, Sequence @@ -68,8 +68,7 @@ class Script(): run_coroutine_threadsafe( self.async_run(variables), self.hass.loop).result() - @asyncio.coroutine - def async_run(self, variables: Optional[Sequence] = None) -> None: + async def async_run(self, variables: Optional[Sequence] = None) -> None: """Run script. This method is a coroutine. @@ -151,7 +150,7 @@ class Script(): self._async_fire_event(action, variables) else: - yield from self._async_call_service(action, variables) + await self._async_call_service(action, variables) self._cur = -1 self.last_action = None @@ -172,15 +171,14 @@ class Script(): if self._change_listener: self.hass.async_add_job(self._change_listener) - @asyncio.coroutine - def _async_call_service(self, action, variables): + async def _async_call_service(self, action, variables): """Call the service specified in the action. This method is a coroutine. """ self.last_action = action.get(CONF_ALIAS, 'call service') self._log("Executing step %s" % self.last_action) - yield from service.async_call_from_config( + await service.async_call_from_config( self.hass, action, True, variables, validate_config=False) def _async_fire_event(self, action, variables): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index b89b1689c9e..7118cab211a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,5 +1,4 @@ """Service calling related helpers.""" -import asyncio import logging # pylint: disable=unused-import from typing import Optional # NOQA @@ -36,10 +35,9 @@ def call_from_config(hass, config, blocking=False, variables=None, validate_config), hass.loop).result() -@asyncio.coroutine @bind_hass -def async_call_from_config(hass, config, blocking=False, variables=None, - validate_config=True): +async def async_call_from_config(hass, config, blocking=False, variables=None, + validate_config=True): """Call a service based on a config hash.""" if validate_config: try: @@ -79,7 +77,7 @@ def async_call_from_config(hass, config, blocking=False, variables=None, if CONF_SERVICE_ENTITY_ID in config: service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] - yield from hass.services.async_call( + await hass.services.async_call( domain, service_name, service_data, blocking) @@ -115,9 +113,8 @@ def extract_entity_ids(hass, service_call, expand_group=True): return service_ent_id -@asyncio.coroutine @bind_hass -def async_get_all_descriptions(hass): +async def async_get_all_descriptions(hass): """Return descriptions (i.e. user documentation) for all service calls.""" if SERVICE_DESCRIPTION_CACHE not in hass.data: hass.data[SERVICE_DESCRIPTION_CACHE] = {} @@ -156,7 +153,7 @@ def async_get_all_descriptions(hass): break if missing: - loaded = yield from hass.async_add_job(load_services_files, missing) + loaded = await hass.async_add_job(load_services_files, missing) # Build response catch_all_yaml_file = domain_yaml_file(ha.DOMAIN) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 255f760ebff..6be0dbae914 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -130,9 +130,8 @@ def reproduce_state(hass, states, blocking=False): async_reproduce_state(hass, states, blocking), hass.loop).result() -@asyncio.coroutine @bind_hass -def async_reproduce_state(hass, states, blocking=False): +async def async_reproduce_state(hass, states, blocking=False): """Reproduce given state.""" if isinstance(states, State): states = [states] @@ -193,16 +192,15 @@ def async_reproduce_state(hass, states, blocking=False): hass.services.async_call(service_domain, service, data, blocking) ) - @asyncio.coroutine - def async_handle_service_calls(coro_list): + async def async_handle_service_calls(coro_list): """Handle service calls by domain sequence.""" for coro in coro_list: - yield from coro + await coro execute_tasks = [async_handle_service_calls(coro_list) for coro_list in domain_tasks.values()] if execute_tasks: - yield from asyncio.wait(execute_tasks, loop=hass.loop) + await asyncio.wait(execute_tasks, loop=hass.loop) def state_as_number(state): diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index aaf83870147..df5a098f901 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -11,8 +11,7 @@ CONSTRAINT_FILE = 'package_constraints.txt' _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_process_requirements(hass, name, requirements): +async def async_process_requirements(hass, name, requirements): """Install the requirements for a component or platform. This method is a coroutine. @@ -24,9 +23,9 @@ def async_process_requirements(hass, name, requirements): pip_install = partial(pkg_util.install_package, **pip_kwargs(hass.config.config_dir)) - with (yield from pip_lock): + async with pip_lock: for req in requirements: - ret = yield from hass.async_add_job(pip_install, req) + ret = await hass.async_add_job(pip_install, req) if not ret: _LOGGER.error("Not initializing %s because could not install " "requirement %s", name, req) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 834334b8a90..331b9992627 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -50,8 +50,7 @@ def benchmark(func): @benchmark -@asyncio.coroutine -def async_million_events(hass): +async def async_million_events(hass): """Run a million events.""" count = 0 event_name = 'benchmark_event' @@ -73,15 +72,14 @@ def async_million_events(hass): start = timer() - yield from event.wait() + await event.wait() return timer() - start @benchmark -@asyncio.coroutine # pylint: disable=invalid-name -def async_million_time_changed_helper(hass): +async def async_million_time_changed_helper(hass): """Run a million events through time changed helper.""" count = 0 event = asyncio.Event(loop=hass.loop) @@ -105,15 +103,14 @@ def async_million_time_changed_helper(hass): start = timer() - yield from event.wait() + await event.wait() return timer() - start @benchmark -@asyncio.coroutine # pylint: disable=invalid-name -def async_million_state_changed_helper(hass): +async def async_million_state_changed_helper(hass): """Run a million events through state changed helper.""" count = 0 entity_id = 'light.kitchen' @@ -141,7 +138,7 @@ def async_million_state_changed_helper(hass): start = timer() - yield from event.wait() + await event.wait() return timer() - start diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ec55b1d70c5..a062c1724ae 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -1,5 +1,5 @@ """Script to ensure a configuration file exists.""" -import asyncio + import argparse import logging import os @@ -46,8 +46,7 @@ C_HEAD = 'bold' ERROR_STR = 'General Errors' -@asyncio.coroutine -def mock_coro(*args): +async def mock_coro(*args): """Coroutine that returns None.""" return None @@ -181,8 +180,7 @@ def check(config_path): # pylint: disable=unused-variable def mock_get(comp_name): """Mock hass.loader.get_component to replace setup & setup_platform.""" - @asyncio.coroutine - def mock_async_setup(*args): + async def mock_async_setup(*args): """Mock setup, only record the component name & config.""" assert comp_name not in res['components'], \ "Components should contain a list of platforms" diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 5a8681e82fd..5be1547242e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -30,9 +30,8 @@ def setup_component(hass: core.HomeAssistant, domain: str, async_setup_component(hass, domain, config), loop=hass.loop).result() -@asyncio.coroutine -def async_setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict] = None) -> bool: +async def async_setup_component(hass: core.HomeAssistant, domain: str, + config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies. This method is a coroutine. @@ -43,7 +42,7 @@ def async_setup_component(hass: core.HomeAssistant, domain: str, setup_tasks = hass.data.get(DATA_SETUP) if setup_tasks is not None and domain in setup_tasks: - return (yield from setup_tasks[domain]) + return await setup_tasks[domain] if config is None: config = {} @@ -54,11 +53,10 @@ def async_setup_component(hass: core.HomeAssistant, domain: str, task = setup_tasks[domain] = hass.async_add_job( _async_setup_component(hass, domain, config)) - return (yield from task) + return await task -@asyncio.coroutine -def _async_process_dependencies(hass, config, name, dependencies): +async def _async_process_dependencies(hass, config, name, dependencies): """Ensure all dependencies are set up.""" blacklisted = [dep for dep in dependencies if dep in loader.DEPENDENCY_BLACKLIST] @@ -75,7 +73,7 @@ def _async_process_dependencies(hass, config, name, dependencies): if not tasks: return True - results = yield from asyncio.gather(*tasks, loop=hass.loop) + results = await asyncio.gather(*tasks, loop=hass.loop) failed = [dependencies[idx] for idx, res in enumerate(results) if not res] @@ -89,9 +87,8 @@ def _async_process_dependencies(hass, config, name, dependencies): return True -@asyncio.coroutine -def _async_setup_component(hass: core.HomeAssistant, - domain: str, config) -> bool: +async def _async_setup_component(hass: core.HomeAssistant, + domain: str, config) -> bool: """Set up a component for Home Assistant. This method is a coroutine. @@ -123,7 +120,7 @@ def _async_setup_component(hass: core.HomeAssistant, return False try: - yield from async_process_deps_reqs(hass, config, domain, component) + await async_process_deps_reqs(hass, config, domain, component) except HomeAssistantError as err: log_error(str(err)) return False @@ -142,9 +139,9 @@ def _async_setup_component(hass: core.HomeAssistant, try: if hasattr(component, 'async_setup'): - result = yield from component.async_setup(hass, processed_config) + result = await component.async_setup(hass, processed_config) else: - result = yield from hass.async_add_job( + result = await hass.async_add_job( component.setup, hass, processed_config) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) @@ -166,7 +163,7 @@ def _async_setup_component(hass: core.HomeAssistant, return False for entry in hass.config_entries.async_entries(domain): - yield from entry.async_setup(hass, component=component) + await entry.async_setup(hass, component=component) hass.config.components.add(component.DOMAIN) @@ -181,9 +178,8 @@ def _async_setup_component(hass: core.HomeAssistant, return True -@asyncio.coroutine -def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, - platform_name: str) \ +async def async_prepare_setup_platform(hass: core.HomeAssistant, config, + domain: str, platform_name: str) \ -> Optional[ModuleType]: """Load a platform and makes sure dependencies are setup. @@ -209,7 +205,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform try: - yield from async_process_deps_reqs( + await async_process_deps_reqs( hass, config, platform_path, platform) except HomeAssistantError as err: log_error(str(err)) @@ -218,8 +214,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform -@asyncio.coroutine -def async_process_deps_reqs(hass, config, name, module): +async def async_process_deps_reqs(hass, config, name, module): """Process all dependencies and requirements for a module. Module is a Python module of either a component or platform. @@ -232,14 +227,14 @@ def async_process_deps_reqs(hass, config, name, module): return if hasattr(module, 'DEPENDENCIES'): - dep_success = yield from _async_process_dependencies( + dep_success = await _async_process_dependencies( hass, config, name, module.DEPENDENCIES) if not dep_success: raise HomeAssistantError("Could not setup all dependencies.") if not hass.config.skip_pip and hasattr(module, 'REQUIREMENTS'): - req_success = yield from requirements.async_process_requirements( + req_success = await requirements.async_process_requirements( hass, name, module.REQUIREMENTS) if not req_success: diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index e8149a85262..75e12f41a90 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -88,17 +88,17 @@ def get_user_site(deps_dir: str) -> str: return lib_dir -@asyncio.coroutine -def async_get_user_site(deps_dir: str, loop: asyncio.AbstractEventLoop) -> str: +async def async_get_user_site(deps_dir: str, + loop: asyncio.AbstractEventLoop) -> str: """Return user local library path. This function is a coroutine. """ args, env = _get_user_site(deps_dir) - process = yield from asyncio.create_subprocess_exec( + process = await asyncio.create_subprocess_exec( *args, loop=loop, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, env=env) - stdout, _ = yield from process.communicate() + stdout, _ = await process.communicate() lib_dir = stdout.decode().strip() return lib_dir diff --git a/script/test b/script/test index 2f3f3557094..14fc357eb12 100755 --- a/script/test +++ b/script/test @@ -3,4 +3,4 @@ cd "$(dirname "$0")/.." -tox -e py34 +tox -e py35 diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 94c8568dc47..c67b2aea448 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -6,7 +6,6 @@ import threading import homeassistant.util.logging as logging_util -@asyncio.coroutine def test_sensitive_data_filter(): """Test the logging sensitive data filter.""" log_filter = logging_util.HideSensitiveDataFilter('mock_sensitive') From 63552abce501ab0fe5a6f01f5a8a765b1fd97fb2 Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Sun, 25 Feb 2018 05:47:46 -0800 Subject: [PATCH 022/191] Synology Chat as a notification platform (#12596) * first attempt at synology chat as a notification platform * quick fix * houndci and coverage * Cleanup Some cleanup of the file * Ugh underscore * Use string formatting * Remove `CONF_NAME` --- .coveragerc | 1 + .../components/notify/synology_chat.py | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 homeassistant/components/notify/synology_chat.py diff --git a/.coveragerc b/.coveragerc index a1022dcb42e..4cee2925383 100644 --- a/.coveragerc +++ b/.coveragerc @@ -509,6 +509,7 @@ omit = homeassistant/components/notify/simplepush.py homeassistant/components/notify/slack.py homeassistant/components/notify/smtp.py + homeassistant/components/notify/synology_chat.py homeassistant/components/notify/syslog.py homeassistant/components/notify/telegram.py homeassistant/components/notify/telstra.py diff --git a/homeassistant/components/notify/synology_chat.py b/homeassistant/components/notify/synology_chat.py new file mode 100644 index 00000000000..8b968729074 --- /dev/null +++ b/homeassistant/components/notify/synology_chat.py @@ -0,0 +1,53 @@ +""" +SynologyChat platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.synology_chat/ +""" +import logging +import json + +import requests +import voluptuous as vol + +from homeassistant.components.notify import ( + BaseNotificationService, PLATFORM_SCHEMA) +from homeassistant.const import CONF_RESOURCE +import homeassistant.helpers.config_validation as cv + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, +}) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Synology Chat notification service.""" + resource = config.get(CONF_RESOURCE) + + return SynologyChatNotificationService(resource) + + +class SynologyChatNotificationService(BaseNotificationService): + """Implementation of a notification service for Synology Chat.""" + + def __init__(self, resource): + """Initialize the service.""" + self._resource = resource + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + data = { + 'text': message + } + + to_send = 'payload={}'.format(json.dumps(data)) + + response = requests.post(self._resource, data=to_send, timeout=10) + + if response.status_code not in (200, 201): + _LOGGER.exception( + "Error sending message. Response %d: %s:", + response.status_code, response.reason) From e8173fbc16fdfd4a697349c3d538241bd57ce3ee Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Sun, 25 Feb 2018 18:20:43 +0000 Subject: [PATCH 023/191] Enable pytradfri during build, and include in Docker (#12662) --- requirements_all.txt | 2 +- script/gen_requirements_all.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index 7de7131b562..ce639243a98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ pytouchline==0.7 pytrackr==0.0.5 # homeassistant.components.tradfri -# pytradfri[async]==4.1.0 +pytradfri[async]==4.1.0 # homeassistant.components.device_tracker.unifi pyunifi==2.13 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 460c998f556..806700cfc97 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -31,7 +31,6 @@ COMMENT_REQUIREMENTS = ( 'envirophat', 'i2csense', 'credstash', - 'pytradfri', 'bme680', ) From 32166fd56ae076634316dc7a6c60c067f86f7604 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sun, 25 Feb 2018 14:13:39 -0500 Subject: [PATCH 024/191] Upgrade insteonplm to 0.8.2 (required refactoring) (#12534) * Merge from current dev * Update for Sensor approach * Update reference to state classes * Reference stateKey correctly * Reference stateKey * Change deviceInfo to a dict * Pass state to properties method * Add state info to device_state_attributes * Update entity name to include state name * Update for on() off() vs light_on/off * Flag newnames option * Update configuration schema * Update configuration schema * Spell False correctly * Rename state to statekey * Rename statekey to stateKey * Call new device with stateKey and newname * Simplify use of newnames * Add workdir to save devices * Fix newnames config setup * Propogate OnOffSensor to VariableSensor change * Upgrade insteonplm version to 0.8.0 * Pass address rather than device object to platform * Set inteon_plm data variable to PLM * Consistant use of conn and plm * Consistant use of conn and plm * Proper reference to device and address * Fixed platform setup issues * Correct issue with missing _supported_features attr * Properly reference self._state vs self.state * Bump insteonplm version to 0.8.1 * Remove subplatform and map based on state name * Correct refrence to State * Correct reference to device.states * Bump insteonplm to 0.8.2 * Fix format errors * Fix format issues * Fix trailing whitespace * Correct format issues * Fix format issues * Fix format issues * Fixed format issues * Fixed format issues * Move imports inside classes * Simplify import of modules * Correct reference to OnOffSwitch_OutletTop and bottom * Remove unnessary references * Fix format issues * Code review adjustments * Fix format issue * Use new nameing format for groups that are not group 0x01 * Remove newname feature * Fix binary_sensor type to default to None * Fix device_class property to return the sensor type correctly. * rename _device and _state to avoid conflicts with Entity * Format long lines * Pylint clean up * Insteon_PLM * lint cleanup * Check device_override has address * 3.4 lint clean up * Changes per code review * Change discovered from a list of dict to a dict * Correct common_attributes usage * Change discovered from a list of dict to a dict * Add debugging message to confirm platform setup * Debug messages * Debug messages * Debug async_added_to_hass * Debug async_added_to_hass async_add_job * Debug async_added_to_hass * Debug devices not loading in hass * Debug new entities not being added to hass * Debug adding devices to hass * Revert "3.4 lint clean up" This reverts commit 0d8fb992b12e9eea86716753fd2f302b1addb458. * 3.4 lint clean up * Revert "Debug adding devices to hass" This reverts commit ec306773d47401b100bcdaaf0af47c93699d78b4. * Revert "Debug new entities not being added to hass" This reverts commit 55fb724d06e7d1e249de46acb2de7eac2eb7d14d. * Revert "Debug devices not loading in hass" This reverts commit 07814b4f14cab85e67197812b055a2d71a954b1f. * Revert "Debug async_added_to_hass" This reverts commit 4963a255d86c1bf63ec6064b0d911893d310f13d. * Revert "Debug async_added_to_hass async_add_job" This reverts commit 22cadff91f524edf91605c4c1f9df0a3d125d1d9. * Revert "Debug async_added_to_hass" This reverts commit 12c5651fe497b439ba962473973232ae9745314d. * Pylint clean up * pylint cleanup * Clean up naming * Enhance config schema. Fix logging issue * Reapply changes after squash --- .../components/binary_sensor/insteon_plm.py | 80 ++----- homeassistant/components/fan/insteon_plm.py | 96 ++++++++ homeassistant/components/insteon_plm.py | 226 +++++++++++++----- homeassistant/components/light/insteon_plm.py | 84 ++----- .../components/sensor/insteon_plm.py | 36 +++ .../components/switch/insteon_plm.py | 96 +++----- requirements_all.txt | 2 +- 7 files changed, 371 insertions(+), 249 deletions(-) create mode 100644 homeassistant/components/fan/insteon_plm.py create mode 100644 homeassistant/components/sensor/insteon_plm.py diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 1874be6ec41..09c4b5c8ea7 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -2,86 +2,56 @@ Support for INSTEON dimmers via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/binary_sensor.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.loader import get_component +from homeassistant.components.insteon_plm import InsteonPLMEntity DEPENDENCIES = ['insteon_plm'] _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = {'openClosedSensor': 'opening', + 'motionSensor': 'motion', + 'doorSensor': 'door', + 'leakSensor': 'moisture'} + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the INSTEON PLM device class for the hass platform.""" plm = hass.data['insteon_plm'] - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info('Registered %s with binary_sensor platform.', name) + _LOGGER.debug('Adding device %s entity %s to Binary Sensor platform', + device.address.hex, device.states[state_key].name) - device_list.append( - InsteonPLMBinarySensorDevice(hass, plm, address, name) - ) + new_entity = InsteonPLMBinarySensor(device, state_key) - async_add_devices(device_list) + async_add_devices([new_entity]) -class InsteonPLMBinarySensorDevice(BinarySensorDevice): - """A Class for an Insteon device.""" +class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice): + """A Class for an Insteon device entity.""" - def __init__(self, hass, plm, address, name): - """Initialize the binarysensor.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - - self._plm.add_update_callback( - self.async_binarysensor_update, {'address': self._address}) + def __init__(self, device, state_key): + """Initialize the INSTEON PLM binary sensor.""" + super().__init__(device, state_key) + self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name) @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._address - - @property - def name(self): - """Return the name of the node.""" - return self._name + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type @property def is_on(self): """Return the boolean response if the node is on.""" - sensorstate = self._plm.get_device_attr(self._address, 'sensorstate') - _LOGGER.info("Sensor state for %s is %s", self._address, sensorstate) + sensorstate = self._insteon_device_state.value return bool(sensorstate) - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_binarysensor_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update callback from PLM for %s", self._address) - self._hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/fan/insteon_plm.py b/homeassistant/components/fan/insteon_plm.py new file mode 100644 index 00000000000..f30abdbaa30 --- /dev/null +++ b/homeassistant/components/fan/insteon_plm.py @@ -0,0 +1,96 @@ +""" +Support for INSTEON fans via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/fan.insteon_plm/ +""" +import asyncio +import logging + +from homeassistant.components.fan import (SPEED_OFF, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_HIGH, + FanEntity, + SUPPORT_SET_SPEED) +from homeassistant.const import STATE_OFF +from homeassistant.components.insteon_plm import InsteonPLMEntity + +DEPENDENCIES = ['insteon_plm'] + +SPEED_TO_HEX = {SPEED_OFF: 0x00, + SPEED_LOW: 0x3f, + SPEED_MEDIUM: 0xbe, + SPEED_HIGH: 0xff} + +FAN_SPEEDS = [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] + + _LOGGER.debug('Adding device %s entity %s to Fan platform', + device.address.hex, device.states[state_key].name) + + new_entity = InsteonPLMFan(device, state_key) + + async_add_devices([new_entity]) + + +class InsteonPLMFan(InsteonPLMEntity, FanEntity): + """An INSTEON fan component.""" + + @property + def speed(self) -> str: + """Return the current speed.""" + return self._hex_to_speed(self._insteon_device_state.value) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return FAN_SPEEDS + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @asyncio.coroutine + def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the entity.""" + if speed is None: + speed = SPEED_MEDIUM + yield from self.async_set_speed(speed) + + @asyncio.coroutine + def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + yield from self.async_set_speed(SPEED_OFF) + + @asyncio.coroutine + def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + fan_speed = SPEED_TO_HEX[speed] + if fan_speed == 0x00: + self._insteon_device_state.off() + else: + self._insteon_device_state.set_level(fan_speed) + + @staticmethod + def _hex_to_speed(speed: int): + hex_speed = SPEED_OFF + if speed > 0xfe: + hex_speed = SPEED_HIGH + elif speed > 0x7f: + hex_speed = SPEED_MEDIUM + elif speed > 0: + hex_speed = SPEED_LOW + return hex_speed diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 4e2e8e02c7a..2381e3db69e 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -4,117 +4,211 @@ Support for INSTEON PowerLinc Modem. For more details about this component, please refer to the documentation at https://home-assistant.io/components/insteon_plm/ """ -import logging import asyncio - +import collections +import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import ( - CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import (CONF_PORT, EVENT_HOMEASSISTANT_STOP, + CONF_PLATFORM) import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.7.5'] +REQUIREMENTS = ['insteonplm==0.8.2'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'insteon_plm' CONF_OVERRIDE = 'device_override' +CONF_ADDRESS = 'address' +CONF_CAT = 'cat' +CONF_SUBCAT = 'subcat' +CONF_FIRMWARE = 'firmware' +CONF_PRODUCT_KEY = 'product_key' + +CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( + cv.deprecated(CONF_PLATFORM), vol.Schema({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_CAT): cv.byte, + vol.Optional(CONF_SUBCAT): cv.byte, + vol.Optional(CONF_FIRMWARE): cv.byte, + vol.Optional(CONF_PRODUCT_KEY): cv.byte, + vol.Optional(CONF_PLATFORM): cv.string, + })) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_OVERRIDE, default=[]): cv.ensure_list_csv, - }) + vol.Optional(CONF_OVERRIDE): vol.All( + cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA]) + }) }, extra=vol.ALLOW_EXTRA) -PLM_PLATFORMS = { - 'binary_sensor': ['binary_sensor'], - 'light': ['light'], - 'switch': ['switch'], -} - @asyncio.coroutine def async_setup(hass, config): """Set up the connection to the PLM.""" import insteonplm + ipdb = IPDB() + conf = config[DOMAIN] port = conf.get(CONF_PORT) - overrides = conf.get(CONF_OVERRIDE) + overrides = conf.get(CONF_OVERRIDE, []) @callback def async_plm_new_device(device): """Detect device from transport to be delegated to platform.""" - name = device.get('address') - address = device.get('address_hex') - capabilities = device.get('capabilities', []) + for state_key in device.states: + platform_info = ipdb[device.states[state_key]] + platform = platform_info.platform + if platform is not None: + _LOGGER.info("New INSTEON PLM device: %s (%s) %s", + device.address, + device.states[state_key].name, + platform) - _LOGGER.info("New INSTEON PLM device: %s (%s) %r", - name, address, capabilities) - - loadlist = [] - for platform in PLM_PLATFORMS: - caplist = PLM_PLATFORMS.get(platform) - for key in capabilities: - if key in caplist: - loadlist.append(platform) - - loadlist = sorted(set(loadlist)) - - for loadplatform in loadlist: - hass.async_add_job( - discovery.async_load_platform( - hass, loadplatform, DOMAIN, discovered=[device], - hass_config=config)) + hass.async_add_job( + discovery.async_load_platform( + hass, platform, DOMAIN, + discovered={'address': device.address.hex, + 'state_key': state_key}, + hass_config=config)) _LOGGER.info("Looking for PLM on %s", port) - plm = yield from insteonplm.Connection.create(device=port, loop=hass.loop) + conn = yield from insteonplm.Connection.create( + device=port, + loop=hass.loop, + workdir=hass.config.config_dir) - for device in overrides: + plm = conn.protocol + + for device_override in overrides: # # Override the device default capabilities for a specific address # - if isinstance(device['platform'], list): - plm.protocol.devices.add_override( - device['address'], 'capabilities', device['platform']) - else: - plm.protocol.devices.add_override( - device['address'], 'capabilities', [device['platform']]) + address = device_override.get('address') + for prop in device_override: + if prop in [CONF_CAT, CONF_SUBCAT]: + plm.devices.add_override(address, prop, + device_override[prop]) + elif prop in [CONF_FIRMWARE, CONF_PRODUCT_KEY]: + plm.devices.add_override(address, CONF_PRODUCT_KEY, + device_override[prop]) hass.data['insteon_plm'] = plm - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, plm.close) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close) - plm.protocol.devices.add_device_callback(async_plm_new_device, {}) + plm.devices.add_device_callback(async_plm_new_device) return True -def common_attributes(entity): - """Return the device state attributes.""" - attributes = {} - attributekeys = { - 'address': 'INSTEON Address', - 'description': 'Description', - 'model': 'Model', - 'cat': 'Category', - 'subcat': 'Subcategory', - 'firmware': 'Firmware', - 'product_key': 'Product Key' - } +State = collections.namedtuple('Product', 'stateType platform') - hexkeys = ['cat', 'subcat', 'firmware'] - for key in attributekeys: - name = attributekeys[key] - val = entity.get_attr(key) - if val is not None: - if key in hexkeys: - attributes[name] = hex(int(val)) - else: - attributes[name] = val - return attributes +class IPDB(object): + """Embodies the INSTEON Product Database static data and access methods.""" + + def __init__(self): + """Create the INSTEON Product Database (IPDB).""" + from insteonplm.states.onOff import (OnOffSwitch, + OnOffSwitch_OutletTop, + OnOffSwitch_OutletBottom, + OpenClosedRelay) + + from insteonplm.states.dimmable import (DimmableSwitch, + DimmableSwitch_Fan) + + from insteonplm.states.sensor import (VariableSensor, + OnOffSensor, + SmokeCO2Sensor, + IoLincSensor) + + self.states = [State(OnOffSwitch_OutletTop, 'switch'), + State(OnOffSwitch_OutletBottom, 'switch'), + State(OpenClosedRelay, 'switch'), + State(OnOffSwitch, 'switch'), + + State(IoLincSensor, 'binary_sensor'), + State(SmokeCO2Sensor, 'sensor'), + State(OnOffSensor, 'binary_sensor'), + State(VariableSensor, 'sensor'), + + State(DimmableSwitch_Fan, 'fan'), + State(DimmableSwitch, 'light')] + + def __len__(self): + """Return the number of INSTEON state types mapped to HA platforms.""" + return len(self.states) + + def __iter__(self): + """Itterate through the INSTEON state types to HA platforms.""" + for product in self.states: + yield product + + def __getitem__(self, key): + """Return a Home Assistant platform from an INSTEON state type.""" + for state in self.states: + if isinstance(key, state.stateType): + return state + return None + + +class InsteonPLMEntity(Entity): + """INSTEON abstract base entity.""" + + def __init__(self, device, state_key): + """Initialize the INSTEON PLM binary sensor.""" + self._insteon_device_state = device.states[state_key] + self._insteon_device = device + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def address(self): + """Return the address of the node.""" + return self._insteon_device.address.human + + @property + def group(self): + """Return the INSTEON group that the entity responds to.""" + return self._insteon_device_state.group + + @property + def name(self): + """Return the name of the node (used for Entity_ID).""" + name = '' + if self._insteon_device_state.group == 0x01: + name = self._insteon_device.id + else: + name = '{:s}_{:d}'.format(self._insteon_device.id, + self._insteon_device_state.group) + return name + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + attributes = { + 'INSTEON Address': self.address, + 'INSTEON Group': self.group + } + return attributes + + @callback + def async_entity_update(self, deviceid, statename, val): + """Receive notification from transport that new data exists.""" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """Register INSTEON update events.""" + self._insteon_device_state.register_updates( + self.async_entity_update) diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index f0ef0ce1b7e..40453da38e5 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -2,15 +2,14 @@ Support for Insteon lights via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/light.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback +from homeassistant.components.insteon_plm import InsteonPLMEntity from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.loader import get_component _LOGGER = logging.getLogger(__name__) @@ -24,96 +23,47 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Insteon PLM device.""" plm = hass.data['insteon_plm'] - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') - dimmable = bool('dimmable' in device.get('capabilities')) + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info("Registered %s with light platform", name) + _LOGGER.debug('Adding device %s entity %s to Light platform', + device.address.hex, device.states[state_key].name) - device_list.append( - InsteonPLMDimmerDevice(hass, plm, address, name, dimmable) - ) + new_entity = InsteonPLMDimmerDevice(device, state_key) - async_add_devices(device_list) + async_add_devices([new_entity]) -class InsteonPLMDimmerDevice(Light): +class InsteonPLMDimmerDevice(InsteonPLMEntity, Light): """A Class for an Insteon device.""" - def __init__(self, hass, plm, address, name, dimmable): - """Initialize the light.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - self._dimmable = dimmable - - self._plm.add_update_callback( - self.async_light_update, {'address': self._address}) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._address - - @property - def name(self): - """Return the name of the node.""" - return self._name - @property def brightness(self): """Return the brightness of this light between 0..255.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug("on level for %s is %s", self._address, onlevel) + onlevel = self._insteon_device_state.value return int(onlevel) @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug("on level for %s is %s", self._address, onlevel) - return bool(onlevel) + return bool(self.brightness) @property def supported_features(self): """Flag supported features.""" - if self._dimmable: - return SUPPORT_BRIGHTNESS - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_light_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update callback from PLM for %s", self._address) - self._hass.async_add_job(self.async_update_ha_state()) + return SUPPORT_BRIGHTNESS @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" if ATTR_BRIGHTNESS in kwargs: brightness = int(kwargs[ATTR_BRIGHTNESS]) + self._insteon_device_state.set_level(brightness) else: - brightness = MAX_BRIGHTNESS - self._plm.turn_on(self._address, brightness=brightness) + self._insteon_device_state.on() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn device off.""" - self._plm.turn_off(self._address) + self._insteon_device_state.off() diff --git a/homeassistant/components/sensor/insteon_plm.py b/homeassistant/components/sensor/insteon_plm.py new file mode 100644 index 00000000000..a72b8efbc05 --- /dev/null +++ b/homeassistant/components/sensor/insteon_plm.py @@ -0,0 +1,36 @@ +""" +Support for INSTEON dimmers via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.insteon_plm/ +""" +import asyncio +import logging + +from homeassistant.components.insteon_plm import InsteonPLMEntity +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['insteon_plm'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] + + _LOGGER.debug('Adding device %s entity %s to Sensor platform', + device.address.hex, device.states[state_key].name) + + new_entity = InsteonPLMSensorDevice(device, state_key) + + async_add_devices([new_entity]) + + +class InsteonPLMSensorDevice(InsteonPLMEntity, Entity): + """A Class for an Insteon device.""" diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index 0b584e14b8d..5f9482ce955 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -2,14 +2,13 @@ Support for INSTEON dimmers via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/switch.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback -from homeassistant.components.switch import (SwitchDevice) -from homeassistant.loader import get_component +from homeassistant.components.insteon_plm import InsteonPLMEntity +from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['insteon_plm'] @@ -21,77 +20,54 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the INSTEON PLM device class for the hass platform.""" plm = hass.data['insteon_plm'] - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info('Registered %s with switch platform.', name) + state_name = device.states[state_key].name - device_list.append( - InsteonPLMSwitchDevice(hass, plm, address, name) - ) + _LOGGER.debug('Adding device %s entity %s to Switch platform', + device.address.hex, device.states[state_key].name) - async_add_devices(device_list) + new_entity = None + if state_name in ['lightOnOff', 'outletTopOnOff', 'outletBottomOnOff']: + new_entity = InsteonPLMSwitchDevice(device, state_key) + elif state_name == 'openClosedRelay': + new_entity = InsteonPLMOpenClosedDevice(device, state_key) + + if new_entity is not None: + async_add_devices([new_entity]) -class InsteonPLMSwitchDevice(SwitchDevice): +class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): """A Class for an Insteon device.""" - def __init__(self, hass, plm, address, name): - """Initialize the switch.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - - self._plm.add_update_callback( - self.async_switch_update, {'address': self._address}) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._address - - @property - def name(self): - """Return the name of the node.""" - return self._name - @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug('on level for %s is %s', self._address, onlevel) + onlevel = self._insteon_device_state.value return bool(onlevel) - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_switch_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info('Received update callback from PLM for %s', self._address) - self._hass.async_add_job(self.async_update_ha_state()) - @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" - self._plm.turn_on(self._address) + self._insteon_device_state.on() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn device off.""" - self._plm.turn_off(self._address) + self._insteon_device_state.off() + + +class InsteonPLMOpenClosedDevice(InsteonPLMEntity, SwitchDevice): + """A Class for an Insteon device.""" + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn device on.""" + self._insteon_device_state.open() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn device off.""" + self._insteon_device_state.close() diff --git a/requirements_all.txt b/requirements_all.txt index ce639243a98..fa55aa20233 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -413,7 +413,7 @@ influxdb==5.0.0 insteonlocal==0.53 # homeassistant.components.insteon_plm -insteonplm==0.7.5 +insteonplm==0.8.2 # homeassistant.components.verisure jsonpath==0.75 From 347ba1a2d84aa13a062b43b6f704f674e663788f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 25 Feb 2018 11:50:06 -0800 Subject: [PATCH 025/191] Remove braviatv_psk (#12669) --- .coveragerc | 1 - CODEOWNERS | 1 - .../components/media_player/braviatv_psk.py | 363 ------------------ requirements_all.txt | 3 - 4 files changed, 368 deletions(-) delete mode 100755 homeassistant/components/media_player/braviatv_psk.py diff --git a/.coveragerc b/.coveragerc index 4cee2925383..f073f72cf24 100644 --- a/.coveragerc +++ b/.coveragerc @@ -432,7 +432,6 @@ omit = homeassistant/components/media_player/aquostv.py homeassistant/components/media_player/bluesound.py homeassistant/components/media_player/braviatv.py - homeassistant/components/media_player/braviatv_psk.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/clementine.py homeassistant/components/media_player/cmus.py diff --git a/CODEOWNERS b/CODEOWNERS index f3ddfc3b3e6..a5b5cfcb32c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,7 +54,6 @@ homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti -homeassistant/components/media_player/braviatv_psk.py @gerard33 homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko diff --git a/homeassistant/components/media_player/braviatv_psk.py b/homeassistant/components/media_player/braviatv_psk.py deleted file mode 100755 index 122eb3b9739..00000000000 --- a/homeassistant/components/media_player/braviatv_psk.py +++ /dev/null @@ -1,363 +0,0 @@ -""" -Support for interface with a Sony Bravia TV. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.braviatv_psk/ -""" -import logging -import voluptuous as vol - -from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, - SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, - PLATFORM_SCHEMA, MEDIA_TYPE_TVSHOW, SUPPORT_STOP) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_MAC, STATE_OFF, STATE_ON) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pySonyBraviaPSK==0.1.5'] - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ - SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP - -DEFAULT_NAME = 'Sony Bravia TV' - -# Config file -CONF_PSK = 'psk' -CONF_AMP = 'amp' -CONF_ANDROID = 'android' -CONF_SOURCE_FILTER = 'sourcefilter' - -# Some additional info to show specific for Sony Bravia TV -TV_WAIT = 'TV started, waiting for program info' -TV_APP_OPENED = 'App opened' -TV_NO_INFO = 'No info: TV resumed after pause' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PSK): cv.string, - vol.Optional(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_AMP, default=False): cv.boolean, - vol.Optional(CONF_ANDROID, default=True): cv.boolean, - vol.Optional(CONF_SOURCE_FILTER, default=[]): vol.All( - cv.ensure_list, [cv.string])}) - -# pylint: disable=unused-argument - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Sony Bravia TV platform.""" - host = config.get(CONF_HOST) - psk = config.get(CONF_PSK) - mac = config.get(CONF_MAC) - name = config.get(CONF_NAME) - amp = config.get(CONF_AMP) - android = config.get(CONF_ANDROID) - source_filter = config.get(CONF_SOURCE_FILTER) - - if host is None or psk is None: - _LOGGER.error( - "No TV IP address or Pre-Shared Key found in configuration file") - return - - add_devices( - [BraviaTVDevice(host, psk, mac, name, amp, android, source_filter)]) - - -class BraviaTVDevice(MediaPlayerDevice): - """Representation of a Sony Bravia TV.""" - - def __init__(self, host, psk, mac, name, amp, android, source_filter): - """Initialize the Sony Bravia device.""" - _LOGGER.info("Setting up Sony Bravia TV") - from braviapsk import sony_bravia_psk - - self._braviarc = sony_bravia_psk.BraviaRC(host, psk, mac) - self._name = name - self._amp = amp - self._android = android - self._source_filter = source_filter - self._state = STATE_OFF - self._muted = False - self._program_name = None - self._channel_name = None - self._channel_number = None - self._source = None - self._source_list = [] - self._original_content_list = [] - self._content_mapping = {} - self._duration = None - self._content_uri = None - self._id = None - self._playing = False - self._start_date_time = None - self._program_media_type = None - self._min_volume = None - self._max_volume = None - self._volume = None - self._start_time = None - self._end_time = None - - _LOGGER.debug( - "Set up Sony Bravia TV with IP: %s, PSK: %s, MAC: %s", host, psk, - mac) - - self.update() - - def update(self): - """Update TV info.""" - try: - power_status = self._braviarc.get_power_status() - if power_status == 'active': - self._state = STATE_ON - self._refresh_volume() - self._refresh_channels() - playing_info = self._braviarc.get_playing_info() - self._reset_playing_info() - if playing_info is None or not playing_info: - self._program_name = TV_NO_INFO - else: - self._program_name = playing_info.get('programTitle') - self._channel_name = playing_info.get('title') - self._program_media_type = playing_info.get( - 'programMediaType') - self._channel_number = playing_info.get('dispNum') - self._source = playing_info.get('source') - self._content_uri = playing_info.get('uri') - self._duration = playing_info.get('durationSec') - self._start_date_time = playing_info.get('startDateTime') - # Get time info from TV program - if self._start_date_time is not None and \ - self._duration is not None: - time_info = self._braviarc.playing_time( - self._start_date_time, self._duration) - self._start_time = time_info.get('start_time') - self._end_time = time_info.get('end_time') - else: - if self._program_name == TV_WAIT: - # TV is starting up, takes some time before it responds - _LOGGER.info("TV is starting, no info available yet") - else: - self._state = STATE_OFF - - except Exception as exception_instance: # pylint: disable=broad-except - _LOGGER.error( - "No data received from TV. Error message: %s", - exception_instance) - self._state = STATE_OFF - - def _reset_playing_info(self): - self._program_name = None - self._channel_name = None - self._program_media_type = None - self._channel_number = None - self._source = None - self._content_uri = None - self._duration = None - self._start_date_time = None - self._start_time = None - self._end_time = None - - def _refresh_volume(self): - """Refresh volume information.""" - volume_info = self._braviarc.get_volume_info() - if volume_info is not None: - self._volume = volume_info.get('volume') - self._min_volume = volume_info.get('minVolume') - self._max_volume = volume_info.get('maxVolume') - self._muted = volume_info.get('mute') - - def _refresh_channels(self): - if not self._source_list: - self._content_mapping = self._braviarc.load_source_list() - self._source_list = [] - if not self._source_filter: # list is empty - for key in self._content_mapping: - self._source_list.append(key) - else: - filtered_dict = {title: uri for (title, uri) in - self._content_mapping.items() - if any(filter_title in title for filter_title - in self._source_filter)} - for key in filtered_dict: - self._source_list.append(key) - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def source(self): - """Return the current input source.""" - return self._source - - @property - def source_list(self): - """List of available input sources.""" - return self._source_list - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - if self._volume is not None: - return self._volume / 100 - return None - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - supported = SUPPORT_BRAVIA - # Remove volume slider if amplifier is attached to TV - if self._amp: - supported = supported ^ SUPPORT_VOLUME_SET - return supported - - @property - def media_content_type(self): - """Content type of current playing media. - - Used for program information below the channel in the state card. - """ - return MEDIA_TYPE_TVSHOW - - @property - def media_title(self): - """Title of current playing media. - - Used to show TV channel info. - """ - return_value = None - if self._channel_name is not None: - if self._channel_number is not None: - return_value = '{0!s}: {1}'.format( - self._channel_number.lstrip('0'), self._channel_name) - else: - return_value = self._channel_name - return return_value - - @property - def media_series_title(self): - """Title of series of current playing media, TV show only. - - Used to show TV program info. - """ - return_value = None - if self._program_name is not None: - if self._start_time is not None and self._end_time is not None: - return_value = '{0} [{1} - {2}]'.format( - self._program_name, self._start_time, self._end_time) - else: - return_value = self._program_name - else: - if not self._channel_name: # This is empty when app is opened - return_value = TV_APP_OPENED - return return_value - - @property - def media_content_id(self): - """Content ID of current playing media.""" - return self._channel_name - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self._braviarc.set_volume_level(volume) - - def turn_on(self): - """Turn the media player on. - - Use a different command for Android as WOL is not working. - """ - if self._android: - self._braviarc.turn_on_command() - else: - self._braviarc.turn_on() - - # Show that TV is starting while it takes time - # before program info is available - self._reset_playing_info() - self._state = STATE_ON - self._program_name = TV_WAIT - - def turn_off(self): - """Turn off media player.""" - self._state = STATE_OFF - self._braviarc.turn_off() - - def volume_up(self): - """Volume up the media player.""" - self._braviarc.volume_up() - - def volume_down(self): - """Volume down media player.""" - self._braviarc.volume_down() - - def mute_volume(self, mute): - """Send mute command.""" - self._braviarc.mute_volume() - - def select_source(self, source): - """Set the input source.""" - if source in self._content_mapping: - uri = self._content_mapping[source] - self._braviarc.play_content(uri) - - def media_play_pause(self): - """Simulate play pause media player.""" - if self._playing: - self.media_pause() - else: - self.media_play() - - def media_play(self): - """Send play command.""" - self._playing = True - self._braviarc.media_play() - - def media_pause(self): - """Send media pause command to media player. - - Will pause TV when TV tuner is on. - """ - self._playing = False - if self._program_media_type == 'tv' or self._program_name is not None: - self._braviarc.media_tvpause() - else: - self._braviarc.media_pause() - - def media_next_track(self): - """Send next track command. - - Will switch to next channel when TV tuner is on. - """ - if self._program_media_type == 'tv' or self._program_name is not None: - self._braviarc.send_command('ChannelUp') - else: - self._braviarc.media_next_track() - - def media_previous_track(self): - """Send the previous track command. - - Will switch to previous channel when TV tuner is on. - """ - if self._program_media_type == 'tv' or self._program_name is not None: - self._braviarc.send_command('ChannelDown') - else: - self._braviarc.media_previous_track() diff --git a/requirements_all.txt b/requirements_all.txt index fa55aa20233..920d16980fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,9 +643,6 @@ pyHS100==0.3.0 # homeassistant.components.rfxtrx pyRFXtrx==0.21.1 -# homeassistant.components.media_player.braviatv_psk -pySonyBraviaPSK==0.1.5 - # homeassistant.components.sensor.tibber pyTibber==0.2.1 From 27b1d448a31da93ccb1f8f0968f083a149e1c4c6 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 26 Feb 2018 04:27:40 +0100 Subject: [PATCH 026/191] =?UTF-8?q?Homekit=20Update,=20Support=20for=20Tem?= =?UTF-8?q?pSensor=20(=C2=B0F)=20(#12676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changed version of "HAP-python" to "v1.1.7" * Updated acc file to simplify init calls * Code refactored and '°F' temp Sensors added * Changed call to 'HomeAccessory' and 'HomeBridge' * Extended function of 'add_preload_service' to add additional characteristics * Added function to override characteristic property values * TemperatureSensor * Added unit * Added calc_temperature * Updated tests --- homeassistant/components/homekit/__init__.py | 11 +-- .../components/homekit/accessories.py | 79 ++++++++++--------- homeassistant/components/homekit/const.py | 9 ++- homeassistant/components/homekit/covers.py | 19 ++--- homeassistant/components/homekit/sensors.py | 46 ++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_sensors.py | 26 +++++- 8 files changed, 120 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 1af56a81137..52b90af7b9b 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, - TEMP_CELSIUS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + TEMP_CELSIUS, TEMP_FAHRENHEIT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry @@ -21,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) _RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$") DOMAIN = 'homekit' -REQUIREMENTS = ['HAP-python==1.1.5'] +REQUIREMENTS = ['HAP-python==1.1.7'] BRIDGE_NAME = 'Home Assistant' CONF_PIN_CODE = 'pincode' @@ -74,7 +75,8 @@ def import_types(): def get_accessory(hass, state): """Take state and return an accessory object if supported.""" if state.domain == 'sensor': - if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT: _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'TemperatureSensor') return TYPES['TemperatureSensor'](hass, state.entity_id, @@ -103,8 +105,7 @@ class HomeKit(): def setup_bridge(self, pin): """Setup the bridge component to track all accessories.""" from .accessories import HomeBridge - self.bridge = HomeBridge(BRIDGE_NAME, pincode=pin) - self.bridge.set_accessory_info('homekit.bridge') + self.bridge = HomeBridge(BRIDGE_NAME, 'homekit.bridge', pin) def start_driver(self, event): """Start the accessory driver.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index e1a25a2c976..f2ad6067258 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,55 +1,62 @@ """Extend the basic Accessory and Bridge functions.""" +import logging + from pyhap.accessory import Accessory, Bridge, Category from .const import ( - SERVICES_ACCESSORY_INFO, MANUFACTURER, + SERV_ACCESSORY_INFO, MANUFACTURER, CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) +_LOGGER = logging.getLogger(__name__) + + +def set_accessory_info(acc, model, manufacturer=MANUFACTURER, + serial_number='0000'): + """Set the default accessory information.""" + service = acc.get_service(SERV_ACCESSORY_INFO) + service.get_characteristic(CHAR_MODEL).set_value(model) + service.get_characteristic(CHAR_MANUFACTURER).set_value(manufacturer) + service.get_characteristic(CHAR_SERIAL_NUMBER).set_value(serial_number) + + +def add_preload_service(acc, service, chars=None, opt_chars=None): + """Define and return a service to be available for the accessory.""" + from pyhap.loader import get_serv_loader, get_char_loader + service = get_serv_loader().get(service) + if chars: + chars = chars if isinstance(chars, list) else [chars] + for char_name in chars: + char = get_char_loader().get(char_name) + service.add_characteristic(char) + if opt_chars: + opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars] + for opt_char_name in opt_chars: + opt_char = get_char_loader().get(opt_char_name) + service.add_opt_characteristic(opt_char) + acc.add_service(service) + return service + + +def override_properties(char, new_properties): + """Override characteristic property values.""" + char.properties.update(new_properties) + + class HomeAccessory(Accessory): """Class to extend the Accessory class.""" - ALL_CATEGORIES = Category - - def __init__(self, display_name): + def __init__(self, display_name, model, category='OTHER'): """Initialize a Accessory object.""" super().__init__(display_name) - - def set_category(self, category): - """Set the category of the accessory.""" - self.category = category - - def add_preload_service(self, service): - """Define the services to be available for the accessory.""" - from pyhap.loader import get_serv_loader - self.add_service(get_serv_loader().get(service)) - - def set_accessory_info(self, model, manufacturer=MANUFACTURER, - serial_number='0000'): - """Set the default accessory information.""" - service_info = self.get_service(SERVICES_ACCESSORY_INFO) - service_info.get_characteristic(CHAR_MODEL) \ - .set_value(model) - service_info.get_characteristic(CHAR_MANUFACTURER) \ - .set_value(manufacturer) - service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ - .set_value(serial_number) + set_accessory_info(self, model) + self.category = getattr(Category, category, Category.OTHER) class HomeBridge(Bridge): """Class to extend the Bridge class.""" - def __init__(self, display_name, pincode): + def __init__(self, display_name, model, pincode): """Initialize a Bridge object.""" super().__init__(display_name, pincode=pincode) - - def set_accessory_info(self, model, manufacturer=MANUFACTURER, - serial_number='0000'): - """Set the default accessory information.""" - service_info = self.get_service(SERVICES_ACCESSORY_INFO) - service_info.get_characteristic(CHAR_MODEL) \ - .set_value(model) - service_info.get_characteristic(CHAR_MANUFACTURER) \ - .set_value(manufacturer) - service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ - .set_value(serial_number) + set_accessory_info(self, model) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 02b2e6b8f67..f514308c823 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -2,17 +2,20 @@ MANUFACTURER = 'HomeAssistant' # Service: AccessoryInfomation -SERVICES_ACCESSORY_INFO = 'AccessoryInformation' +SERV_ACCESSORY_INFO = 'AccessoryInformation' CHAR_MODEL = 'Model' CHAR_MANUFACTURER = 'Manufacturer' CHAR_SERIAL_NUMBER = 'SerialNumber' # Service: TemperatureSensor -SERVICES_TEMPERATURE_SENSOR = 'TemperatureSensor' +SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' # Service: WindowCovering -SERVICES_WINDOW_COVERING = 'WindowCovering' +SERV_WINDOW_COVERING = 'WindowCovering' CHAR_CURRENT_POSITION = 'CurrentPosition' CHAR_TARGET_POSITION = 'TargetPosition' CHAR_POSITION_STATE = 'PositionState' + +# Properties +PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} diff --git a/homeassistant/components/homekit/covers.py b/homeassistant/components/homekit/covers.py index 1068b1e0e3f..e9fc3b08d76 100644 --- a/homeassistant/components/homekit/covers.py +++ b/homeassistant/components/homekit/covers.py @@ -5,9 +5,9 @@ from homeassistant.components.cover import ATTR_CURRENT_POSITION from homeassistant.helpers.event import async_track_state_change from . import TYPES -from .accessories import HomeAccessory +from .accessories import HomeAccessory, add_preload_service from .const import ( - SERVICES_WINDOW_COVERING, CHAR_CURRENT_POSITION, + SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE) @@ -23,10 +23,7 @@ class Window(HomeAccessory): def __init__(self, hass, entity_id, display_name): """Initialize a Window accessory object.""" - super().__init__(display_name) - self.set_category(self.ALL_CATEGORIES.WINDOW) - self.set_accessory_info(entity_id) - self.add_preload_service(SERVICES_WINDOW_COVERING) + super().__init__(display_name, entity_id, 'WINDOW') self._hass = hass self._entity_id = entity_id @@ -34,12 +31,12 @@ class Window(HomeAccessory): self.current_position = None self.homekit_target = None - self.service_cover = self.get_service(SERVICES_WINDOW_COVERING) - self.char_current_position = self.service_cover. \ + self.serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) + self.char_current_position = self.serv_cover. \ get_characteristic(CHAR_CURRENT_POSITION) - self.char_target_position = self.service_cover. \ + self.char_target_position = self.serv_cover. \ get_characteristic(CHAR_TARGET_POSITION) - self.char_position_state = self.service_cover. \ + self.char_position_state = self.serv_cover. \ get_characteristic(CHAR_POSITION_STATE) self.char_target_position.setter_callback = self.move_cover @@ -53,7 +50,7 @@ class Window(HomeAccessory): self._hass, self._entity_id, self.update_cover_position) def move_cover(self, value): - """Move cover to value if call came from homekit.""" + """Move cover to value if call came from HomeKit.""" if value != self.current_position: _LOGGER.debug("%s: Set position to %d", self._entity_id, value) self.homekit_target = value diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py index db9ba2d628a..05465cd1397 100644 --- a/homeassistant/components/homekit/sensors.py +++ b/homeassistant/components/homekit/sensors.py @@ -1,38 +1,55 @@ """Class to hold all sensor accessories.""" import logging -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import ( + STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT, TEMP_FAHRENHEIT, TEMP_CELSIUS) from homeassistant.helpers.event import async_track_state_change from . import TYPES -from .accessories import HomeAccessory +from .accessories import ( + HomeAccessory, add_preload_service, override_properties) from .const import ( - SERVICES_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE) + SERV_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS) _LOGGER = logging.getLogger(__name__) +def calc_temperature(state, unit=TEMP_CELSIUS): + """Calculate temperature from state and unit. + + Always return temperature as Celsius value. + Conversion is handled on the device. + """ + if state == STATE_UNKNOWN: + return None + + if unit == TEMP_FAHRENHEIT: + value = round((float(state) - 32) / 1.8, 2) + else: + value = float(state) + return value + + @TYPES.register('TemperatureSensor') class TemperatureSensor(HomeAccessory): """Generate a TemperatureSensor accessory for a temperature sensor. - Sensor entity must return either temperature in °C or STATE_UNKNOWN. + Sensor entity must return temperature in °C, °F or STATE_UNKNOWN. """ def __init__(self, hass, entity_id, display_name): """Initialize a TemperatureSensor accessory object.""" - super().__init__(display_name) - self.set_category(self.ALL_CATEGORIES.SENSOR) - self.set_accessory_info(entity_id) - self.add_preload_service(SERVICES_TEMPERATURE_SENSOR) + super().__init__(display_name, entity_id, 'SENSOR') self._hass = hass self._entity_id = entity_id - self.service_temp = self.get_service(SERVICES_TEMPERATURE_SENSOR) - self.char_temp = self.service_temp. \ + self.serv_temp = add_preload_service(self, SERV_TEMPERATURE_SENSOR) + self.char_temp = self.serv_temp. \ get_characteristic(CHAR_CURRENT_TEMPERATURE) + override_properties(self.char_temp, PROP_CELSIUS) + self.unit = None def run(self): """Method called be object after driver is started.""" @@ -48,6 +65,9 @@ class TemperatureSensor(HomeAccessory): if new_state is None: return - temperature = new_state.state - if temperature != STATE_UNKNOWN: - self.char_temp.set_value(float(temperature)) + unit = new_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + temperature = calc_temperature(new_state.state, unit) + if temperature is not None: + self.char_temp.set_value(temperature) + _LOGGER.debug("%s: Current temperature set to %d°C", + self._entity_id, temperature) diff --git a/requirements_all.txt b/requirements_all.txt index 920d16980fa..93710e6506e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -24,7 +24,7 @@ attrs==17.4.0 DoorBirdPy==0.1.2 # homeassistant.components.homekit -HAP-python==1.1.5 +HAP-python==1.1.7 # homeassistant.components.isy994 PyISY==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e443e3ad00..6c0a4d1a586 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ asynctest>=0.11.1 # homeassistant.components.homekit -HAP-python==1.1.5 +HAP-python==1.1.7 # homeassistant.components.notify.html5 PyJWT==1.5.3 diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py index b7d3de4e90b..8e453cea0f2 100644 --- a/tests/components/homekit/test_sensors.py +++ b/tests/components/homekit/test_sensors.py @@ -1,13 +1,25 @@ """Test different accessory types: Sensors.""" import unittest -from homeassistant.components.homekit.sensors import TemperatureSensor +from homeassistant.components.homekit.sensors import ( + TemperatureSensor, calc_temperature) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) from tests.common import get_test_home_assistant +def test_calc_temperature(): + """Test if temperature in Celsius is calculated correctly.""" + assert calc_temperature(STATE_UNKNOWN) is None + + assert calc_temperature('20') == 20 + assert calc_temperature('20.12', TEMP_CELSIUS) == 20.12 + + assert calc_temperature('75.2', TEMP_FAHRENHEIT) == 24 + assert calc_temperature('-20.6', TEMP_FAHRENHEIT) == -29.22 + + class TestHomekitSensors(unittest.TestCase): """Test class for all accessory types regarding sensors.""" @@ -16,7 +28,7 @@ class TestHomekitSensors(unittest.TestCase): self.hass = get_test_home_assistant() def tearDown(self): - """Stop down everthing that was started.""" + """Stop down everything that was started.""" self.hass.stop() def test_temperature_celsius(self): @@ -32,6 +44,12 @@ class TestHomekitSensors(unittest.TestCase): {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.block_till_done() - self.hass.states.set(temperature_sensor, '20') + self.hass.states.set(temperature_sensor, '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.block_till_done() self.assertEqual(acc.char_temp.value, 20) + + self.hass.states.set(temperature_sensor, '75.2', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + self.hass.block_till_done() + self.assertEqual(acc.char_temp.value, 24) From e96ac74b1170484e0dd4777972c99856d4425e39 Mon Sep 17 00:00:00 2001 From: awkwardDuck <34869622+awkwardDuck@users.noreply.github.com> Date: Sun, 25 Feb 2018 22:28:41 -0500 Subject: [PATCH 027/191] Fix formatting of minutes for sleep start in the fitbit sensor (#12664) * fix formatting of minutes for sleep start https://github.com/home-assistant/home-assistant/issues/12594 * Update fitbit.py --- homeassistant/components/sensor/fitbit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 35748b30ecf..6ea18d318b8 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -463,7 +463,8 @@ class FitbitSensor(Entity): hours -= 12 elif hours == 0: hours = 12 - self._state = '{}:{} {}'.format(hours, minutes, setting) + self._state = '{}:{:02d} {}'.format(hours, minutes, + setting) else: self._state = raw_state else: From 6d5fb4968753fcb3e7043d7b82167015ff9fbda5 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Mon, 26 Feb 2018 09:43:26 +0200 Subject: [PATCH 028/191] Roomba timeout (#12645) * Roomba timeout * PlatformNotReady --- homeassistant/components/vacuum/roomba.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 6485f0025e2..b983b20bd0c 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -8,12 +8,15 @@ import asyncio import logging import voluptuous as vol +import async_timeout + from homeassistant.components.vacuum import ( VacuumDevice, PLATFORM_SCHEMA, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -90,7 +93,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ) _LOGGER.info("Initializing communication with host %s (username: %s)", host, username) - yield from hass.async_add_job(roomba.connect) + + try: + with async_timeout.timeout(9): + yield from hass.async_add_job(roomba.connect) + except asyncio.TimeoutError: + raise PlatformNotReady + roomba_vac = RoombaVacuum(name, roomba) hass.data[PLATFORM][host] = roomba_vac From 7d5c1581f15f1c0abe4e7b5e600fc9808c0e2305 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Mon, 26 Feb 2018 08:44:09 +0100 Subject: [PATCH 029/191] KNX Component: Scene support and expose sensor values (#11978) * XKNX improvements: Added Scene support, added support for exposing sensors to KNX bus, added reset value option for binary switches * fixed import * Bumped version of KNX library (minor upgrade with two important bugfixes) * bumped version of xknx (now without python requirement *sigh*) * Issue #11978: fixed review comments * Issue #11978: hound suggestion fixed: * review comments * made async functions async * Addressed issues mentined by @MartinHjelmare * removed default=None from validation schema * ATTR_ENTITY_ID->CONF_ENTITY_ID * moved missing function to async syntax * pylint * Trigger notification * Trigger notification * fixed review comment --- homeassistant/components/binary_sensor/knx.py | 5 +- homeassistant/components/knx.py | 97 ++++++++++++++++++- homeassistant/components/scene/knx.py | 79 +++++++++++++++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/scene/knx.py diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 1802ae34454..834186b8b18 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -25,6 +25,7 @@ CONF_DEFAULT_HOOK = 'on' CONF_COUNTER = 'counter' CONF_DEFAULT_COUNTER = 1 CONF_ACTION = 'action' +CONF_RESET_AFTER = 'reset_after' CONF__ACTION = 'turn_off_action' @@ -48,6 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_CLASS): cv.string, vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): cv.positive_int, + vol.Optional(CONF_RESET_AFTER): cv.positive_int, vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA, }) @@ -81,7 +83,8 @@ def async_add_devices_config(hass, config, async_add_devices): name=name, group_address=config.get(CONF_ADDRESS), device_class=config.get(CONF_DEVICE_CLASS), - significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + significant_bit=config.get(CONF_SIGNIFICANT_BIT), + reset_after=config.get(CONF_RESET_AFTER)) hass.data[DATA_KNX].xknx.devices.add(binary_sensor) entity = KNXBinarySensor(hass, binary_sensor) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 63407fd6246..d7630fee7a5 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -9,9 +9,12 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script REQUIREMENTS = ['xknx==0.8.3'] @@ -26,6 +29,9 @@ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_FIRE_EVENT = "fire_event" CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" CONF_KNX_STATE_UPDATER = "state_updater" +CONF_KNX_EXPOSE = "expose" +CONF_KNX_EXPOSE_TYPE = "type" +CONF_KNX_EXPOSE_ADDRESS = "address" SERVICE_KNX_SEND = "send" SERVICE_KNX_ATTR_ADDRESS = "address" @@ -45,6 +51,12 @@ ROUTING_SCHEMA = vol.Schema({ vol.Required(CONF_KNX_LOCAL_IP): cv.string, }) +EXPOSE_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_EXPOSE_TYPE): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_KNX_EXPOSE_ADDRESS): cv.string, +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_KNX_CONFIG): cv.string, @@ -56,6 +68,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, + vol.Optional(CONF_KNX_EXPOSE): + vol.All( + cv.ensure_list, + [EXPOSE_SCHEMA]), }) }, extra=vol.ALLOW_EXTRA) @@ -71,6 +87,7 @@ async def async_setup(hass, config): from xknx.exceptions import XKNXException try: hass.data[DATA_KNX] = KNXModule(hass, config) + hass.data[DATA_KNX].async_create_exposures() await hass.data[DATA_KNX].start() except XKNXException as ex: @@ -87,6 +104,7 @@ async def async_setup(hass, config): ('light', 'Light'), ('sensor', 'Sensor'), ('binary_sensor', 'BinarySensor'), + ('scene', 'Scene'), ('notify', 'Notification')): found_devices = _get_devices(hass, discovery_type) hass.async_add_job( @@ -121,6 +139,7 @@ class KNXModule(object): self.connected = False self.init_xknx() self.register_callbacks() + self.exposures = [] def init_xknx(self): """Initialize of KNX object.""" @@ -199,6 +218,26 @@ class KNXModule(object): self.xknx.telegram_queue.register_telegram_received_cb( self.telegram_received_cb, address_filters) + @callback + def async_create_exposures(self): + """Create exposures.""" + if CONF_KNX_EXPOSE not in self.config[DOMAIN]: + return + for to_expose in self.config[DOMAIN][CONF_KNX_EXPOSE]: + expose_type = to_expose.get(CONF_KNX_EXPOSE_TYPE) + entity_id = to_expose.get(CONF_ENTITY_ID) + address = to_expose.get(CONF_KNX_EXPOSE_ADDRESS) + if expose_type in ['time', 'date', 'datetime']: + exposure = KNXExposeTime( + self.xknx, expose_type, address) + exposure.async_register() + self.exposures.append(exposure) + else: + exposure = KNXExposeSensor( + self.hass, self.xknx, expose_type, entity_id, address) + exposure.async_register() + self.exposures.append(exposure) + async def telegram_received_cb(self, telegram): """Call invoked after a KNX telegram was received.""" self.hass.bus.fire('knx_event', { @@ -243,3 +282,59 @@ class KNXAutomation(): hass.data[DATA_KNX].xknx, self.script.async_run, hook=hook, counter=counter) device.actions.append(self.action) + + +class KNXExposeTime(object): + """Object to Expose Time/Date object to KNX bus.""" + + def __init__(self, xknx, expose_type, address): + """Initialize of Expose class.""" + self.xknx = xknx + self.type = expose_type + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + from xknx.devices import DateTime, DateTimeBroadcastType + broadcast_type_string = self.type.upper() + broadcast_type = DateTimeBroadcastType[broadcast_type_string] + self.device = DateTime( + self.xknx, + 'Time', + broadcast_type=broadcast_type, + group_address=self.address) + self.xknx.devices.add(self.device) + + +class KNXExposeSensor(object): + """Object to Expose HASS entity to KNX bus.""" + + def __init__(self, hass, xknx, expose_type, entity_id, address): + """Initialize of Expose class.""" + self.hass = hass + self.xknx = xknx + self.type = expose_type + self.entity_id = entity_id + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + from xknx.devices import ExposeSensor + self.device = ExposeSensor( + self.xknx, + name=self.entity_id, + group_address=self.address, + value_type=self.type) + self.xknx.devices.add(self.device) + async_track_state_change( + self.hass, self.entity_id, self._async_entity_changed) + + async def _async_entity_changed(self, entity_id, old_state, new_state): + """Callback after entity changed.""" + if new_state is None: + return + await self.device.set(float(new_state.state)) diff --git a/homeassistant/components/scene/knx.py b/homeassistant/components/scene/knx.py new file mode 100644 index 00000000000..1329b440e5f --- /dev/null +++ b/homeassistant/components/scene/knx.py @@ -0,0 +1,79 @@ +""" +Support for KNX scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.knx/ +""" +import asyncio + +import voluptuous as vol + +from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX +from homeassistant.components.scene import CONF_PLATFORM, Scene +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_SCENE_NUMBER = 'scene_number' + +DEFAULT_NAME = 'KNX SCENE' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'knx', + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_SCENE_NUMBER): cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the scenes for KNX platform.""" + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up scenes for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXScene(device)) + async_add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up scene for KNX platform configured within platform.""" + import xknx + scene = xknx.devices.Scene( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + scene_number=config.get(CONF_SCENE_NUMBER)) + hass.data[DATA_KNX].xknx.devices.add(scene) + async_add_devices([KNXScene(scene)]) + + +class KNXScene(Scene): + """Representation of a KNX scene.""" + + def __init__(self, scene): + """Init KNX scene.""" + self.scene = scene + + @property + def name(self): + """Return the name of the scene.""" + return self.scene.name + + @asyncio.coroutine + def async_activate(self): + """Activate the scene.""" + yield from self.scene.run() From 6e6ae173fd88be1c20ad8d5229aed68da2858653 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 26 Feb 2018 08:48:21 +0100 Subject: [PATCH 030/191] Added config validator for future group platforms (#12592) * Added cv.EntitiesDoamin(domain) validator * Check if all entities in string or list belong to domain * Added tests * Use factory function and entity_ids * Different error message * Typo * Added entity_domain validator for a single entity_id * Image_processing platform now uses cv.entity_domain for source validation --- .../components/image_processing/__init__.py | 2 +- homeassistant/helpers/config_validation.py | 25 +++++++++- tests/helpers/test_config_validation.py | 50 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 2c2b8364823..061fd5d7074 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -43,7 +43,7 @@ DEFAULT_TIMEOUT = 10 DEFAULT_CONFIDENCE = 80 SOURCE_SCHEMA = vol.Schema({ - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_domain('camera'), vol.Optional(CONF_NAME): cv.string, }) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e32b041ffa2..f8f08fd118f 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, CONF_CONDITION, CONF_BELOW, CONF_ABOVE, CONF_TIMEOUT, SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) -from homeassistant.core import valid_entity_id +from homeassistant.core import valid_entity_id, split_entity_id from homeassistant.exceptions import TemplateError import homeassistant.util.dt as dt_util from homeassistant.util import slugify as util_slugify @@ -147,6 +147,29 @@ def entity_ids(value: Union[str, Sequence]) -> Sequence[str]: return [entity_id(ent_id) for ent_id in value] +def entity_domain(domain: str): + """Validate that entity belong to domain.""" + def validate(value: Any) -> str: + """Test if entity domain is domain.""" + ent_domain = entities_domain(domain) + return ent_domain(value)[0] + return validate + + +def entities_domain(domain: str): + """Validate that entities belong to domain.""" + def validate(values: Union[str, Sequence]) -> Sequence[str]: + """Test if entity domain is domain.""" + values = entity_ids(values) + for ent_id in values: + if split_entity_id(ent_id)[0] != domain: + raise vol.Invalid( + "Entity ID '{}' does not belong to domain '{}'" + .format(ent_id, domain)) + return values + return validate + + def enum(enumClass): """Create validator for specified enum.""" return vol.All(vol.In(enumClass.__members__), enumClass.__getitem__) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 66f0597fc93..90be56bbc7c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -164,6 +164,55 @@ def test_entity_ids(): ] +def test_entity_domain(): + """Test entity domain validation.""" + schema = vol.Schema(cv.entity_domain('sensor')) + + options = ( + 'invalid_entity', + 'cover.demo', + ) + + for value in options: + with pytest.raises(vol.MultipleInvalid): + print(value) + schema(value) + + assert schema('sensor.LIGHT') == 'sensor.light' + + +def test_entities_domain(): + """Test entities domain validation.""" + schema = vol.Schema(cv.entities_domain('sensor')) + + options = ( + None, + '', + 'invalid_entity', + ['sensor.light', 'cover.demo'], + ['sensor.light', 'sensor_invalid'], + ) + + for value in options: + with pytest.raises(vol.MultipleInvalid): + schema(value) + + options = ( + 'sensor.light', + ['SENSOR.light'], + ['sensor.light', 'sensor.demo'] + ) + for value in options: + schema(value) + + assert schema('sensor.LIGHT, sensor.demo ') == [ + 'sensor.light', 'sensor.demo' + ] + assert schema(['sensor.light', 'SENSOR.demo']) == [ + 'sensor.light', 'sensor.demo' + ] + + def test_ensure_list_csv(): """Test ensure_list_csv.""" schema = vol.Schema(cv.ensure_list_csv) @@ -453,6 +502,7 @@ def test_deprecated(caplog): ) deprecated_schema({'venus': True}) + # pylint: disable=len-as-condition assert len(caplog.records) == 0 deprecated_schema({'mars': True}) From bf41674e066d8494942fd1adaebe6112b7aae81b Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 26 Feb 2018 08:01:01 +0000 Subject: [PATCH 031/191] Adds simulated sensor (#12539) * Create simulated.py * Create test_simulated.py * Update .coveragerc * Drop numpy and fix attributes Drop numpy and fix attributes to be machine readble * Update test_simulated.py * Update simulated.py * Update test_simulated.py * Update simulated.py * Update test_simulated.py * Update simulated.py * Update simulated.py * Update test_simulated.py * Update simulated.py * Fix default random seed error * Update simulated.py * Addresses balloob comments * Update simulated.py --- .coveragerc | 1 + homeassistant/components/sensor/simulated.py | 146 +++++++++++++++++++ tests/components/sensor/test_simulated.py | 50 +++++++ 3 files changed, 197 insertions(+) create mode 100644 homeassistant/components/sensor/simulated.py create mode 100644 tests/components/sensor/test_simulated.py diff --git a/.coveragerc b/.coveragerc index f073f72cf24..46981341bac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -624,6 +624,7 @@ omit = homeassistant/components/sensor/serial.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/shodan.py + homeassistant/components/sensor/simulated.py homeassistant/components/sensor/skybeacon.py homeassistant/components/sensor/sma.py homeassistant/components/sensor/snmp.py diff --git a/homeassistant/components/sensor/simulated.py b/homeassistant/components/sensor/simulated.py new file mode 100644 index 00000000000..297f2db9fc0 --- /dev/null +++ b/homeassistant/components/sensor/simulated.py @@ -0,0 +1,146 @@ +""" +Adds a simulated sensor. + +For more details about this platform, refer to the documentation at +https://home-assistant.io/components/sensor.simulated/ +""" +import asyncio +import datetime as datetime +import math +from random import Random +import logging + +import voluptuous as vol + +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = datetime.timedelta(seconds=1) +ICON = 'mdi:chart-line' + +CONF_UNIT = 'unit' +CONF_AMP = 'amplitude' +CONF_MEAN = 'mean' +CONF_PERIOD = 'period' +CONF_PHASE = 'phase' +CONF_FWHM = 'spread' +CONF_SEED = 'seed' + +DEFAULT_NAME = 'simulated' +DEFAULT_UNIT = 'value' +DEFAULT_AMP = 1 +DEFAULT_MEAN = 0 +DEFAULT_PERIOD = 60 +DEFAULT_PHASE = 0 +DEFAULT_FWHM = 0 +DEFAULT_SEED = 999 + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, + vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), + vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float), + vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, + vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float), + vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), + vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the simulated sensor.""" + name = config.get(CONF_NAME) + unit = config.get(CONF_UNIT) + amp = config.get(CONF_AMP) + mean = config.get(CONF_MEAN) + period = config.get(CONF_PERIOD) + phase = config.get(CONF_PHASE) + fwhm = config.get(CONF_FWHM) + seed = config.get(CONF_SEED) + + sensor = SimulatedSensor( + name, unit, amp, mean, period, phase, fwhm, seed + ) + add_devices([sensor], True) + + +class SimulatedSensor(Entity): + """Class for simulated sensor.""" + + def __init__(self, name, unit, amp, mean, period, phase, fwhm, seed): + """Init the class.""" + self._name = name + self._unit = unit + self._amp = amp + self._mean = mean + self._period = period + self._phase = phase # phase in degrees + self._fwhm = fwhm + self._seed = seed + self._random = Random(seed) # A local seeded Random + self._start_time = dt_util.utcnow() + self._state = None + + def time_delta(self): + """"Return the time delta.""" + dt0 = self._start_time + dt1 = dt_util.utcnow() + return dt1 - dt0 + + def signal_calc(self): + """Calculate the signal.""" + mean = self._mean + amp = self._amp + time_delta = self.time_delta().total_seconds()*1e6 # to milliseconds + period = self._period*1e6 # to milliseconds + fwhm = self._fwhm/2 + phase = math.radians(self._phase) + if period == 0: + periodic = 0 + else: + periodic = amp * (math.sin((2*math.pi*time_delta/period) + phase)) + noise = self._random.gauss(mu=0, sigma=fwhm) + return mean + periodic + noise + + @asyncio.coroutine + def async_update(self): + """Update the sensor.""" + self._state = self.signal_calc() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + 'amplitude': self._amp, + 'mean': self._mean, + 'period': self._period, + 'phase': self._phase, + 'spread': self._fwhm, + 'seed': self._seed, + } + return attr diff --git a/tests/components/sensor/test_simulated.py b/tests/components/sensor/test_simulated.py new file mode 100644 index 00000000000..3bfccc629fd --- /dev/null +++ b/tests/components/sensor/test_simulated.py @@ -0,0 +1,50 @@ +"""The tests for the simulated sensor.""" +import unittest + +from homeassistant.components.sensor.simulated import ( + CONF_UNIT, CONF_AMP, CONF_MEAN, CONF_PERIOD, CONF_PHASE, CONF_FWHM, + CONF_SEED, DEFAULT_NAME, DEFAULT_AMP, DEFAULT_MEAN, + DEFAULT_PHASE, DEFAULT_FWHM, DEFAULT_SEED) +from homeassistant.const import CONF_FRIENDLY_NAME +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +class TestSimulatedSensor(unittest.TestCase): + """Test the simulated sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_default_config(self): + """Test default config.""" + config = { + 'sensor': { + 'platform': 'simulated'} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + self.hass.block_till_done() + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.simulated') + assert state.attributes.get( + CONF_FRIENDLY_NAME) == DEFAULT_NAME + assert state.attributes.get( + CONF_AMP) == DEFAULT_AMP + assert state.attributes.get( + CONF_UNIT) is None + assert state.attributes.get( + CONF_MEAN) == DEFAULT_MEAN + assert state.attributes.get( + CONF_PERIOD) == 60.0 + assert state.attributes.get( + CONF_PHASE) == DEFAULT_PHASE + assert state.attributes.get( + CONF_FWHM) == DEFAULT_FWHM + assert state.attributes.get( + CONF_SEED) == DEFAULT_SEED From a8c93038920b2bc0b33cc205a2e6e5449e72e77a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Feb 2018 00:28:25 -0800 Subject: [PATCH 032/191] Add history_graph component to demo (#12681) --- homeassistant/components/demo.py | 11 +++++++++++ tests/components/test_demo.py | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index b85c2d9a53b..64ce3cda073 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -118,6 +118,17 @@ def async_setup(hass, config): tasks2 = [] + # Set up history graph + tasks2.append(bootstrap.async_setup_component( + hass, 'history_graph', + {'history_graph': {'switches': { + 'name': 'Recent Switches', + 'entities': switches, + 'hours_to_show': 1, + 'refresh': 60 + }}} + )) + # Set up scripts tasks2.append(bootstrap.async_setup_component( hass, 'script', diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py index 93aac65ecb5..258e3d96297 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -10,6 +10,12 @@ from homeassistant.components import demo, device_tracker from homeassistant.remote import JSONEncoder +@pytest.fixture(autouse=True) +def mock_history(hass): + """Mock history component loaded.""" + hass.config.components.add('history') + + @pytest.fixture def minimize_demo_platforms(hass): """Cleanup demo component for tests.""" From 1d14a17ffd09ac078366b53dfa33bf203b67b573 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Feb 2018 01:11:28 -0800 Subject: [PATCH 033/191] Harmony: make activity optional (#12679) --- homeassistant/components/remote/harmony.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 25a1a684d3c..c84d214d826 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -31,7 +31,7 @@ CONF_DEVICE_CACHE = 'harmony_device_cache' SERVICE_SYNC = 'harmony_sync' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(ATTR_ACTIVITY): cv.string, + vol.Optional(ATTR_ACTIVITY): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float), From f8a0a0ba595a5ff53bc5d81810bb69abd6e3990c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Feb 2018 08:01:00 -0800 Subject: [PATCH 034/191] Fix mysensor defaults (#12687) --- homeassistant/components/mysensors.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 390da7ed0e0..37e257e5eb9 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -152,10 +152,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX, default=''): - valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX, default=''): - valid_publish_topic, + vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, + vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, }] ), @@ -358,8 +356,8 @@ def setup(hass, config): hass.config.path('mysensors{}.pickle'.format(index + 1))) baud_rate = gway.get(CONF_BAUD_RATE) tcp_port = gway.get(CONF_TCP_PORT) - in_prefix = gway.get(CONF_TOPIC_IN_PREFIX) - out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX) + in_prefix = gway.get(CONF_TOPIC_IN_PREFIX, '') + out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX, '') ready_gateway = setup_gateway( device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) From 2c1083bda11d865a90058226389beba6a85b108d Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 26 Feb 2018 21:54:06 +0100 Subject: [PATCH 035/191] Next generation of Xiaomi Aqara devices added (#12659) * Next generation of Xiaomi Aqara devices added: ctrl_neutral1.aq1, ctrl_neutral2.aq1, ctrl_ln1.aq1, ctrl_ln2.aq1, ctrl_86plug.aq1 * The Aqara wireless button (3rd gen, sensor_switch.aq3) supports a click_type called "shake". * Warning added to spot new features. --- homeassistant/components/binary_sensor/xiaomi_aqara.py | 3 +++ homeassistant/components/switch/xiaomi_aqara.py | 10 +++++----- homeassistant/components/xiaomi_aqara.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 99037f60107..2ed0de66b18 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -319,7 +319,10 @@ class XiaomiButton(XiaomiBinarySensor): click_type = 'double' elif value == 'both_click': click_type = 'both' + elif value == 'shake': + click_type = 'shake' else: + _LOGGER.warning("Unsupported click_type detected: %s", value) return False self._hass.bus.fire('click', { diff --git a/homeassistant/components/switch/xiaomi_aqara.py b/homeassistant/components/switch/xiaomi_aqara.py index 1688b6b89e1..939fc70660a 100644 --- a/homeassistant/components/switch/xiaomi_aqara.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -28,22 +28,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if model == 'plug': devices.append(XiaomiGenericSwitch(device, "Plug", 'status', True, gateway)) - elif model == 'ctrl_neutral1': + elif model in ['ctrl_neutral1', 'ctrl_neutral1.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch', 'channel_0', False, gateway)) - elif model == 'ctrl_ln1': + elif model in ['ctrl_ln1', 'ctrl_ln1.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch LN', 'channel_0', False, gateway)) - elif model == 'ctrl_neutral2': + elif model in ['ctrl_neutral2', 'ctrl_neutral2.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch Left', 'channel_0', False, gateway)) devices.append(XiaomiGenericSwitch(device, 'Wall Switch Right', 'channel_1', False, gateway)) - elif model == 'ctrl_ln2': + elif model in ['ctrl_ln2', 'ctrl_ln2.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch LN Left', 'channel_0', @@ -52,7 +52,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'Wall Switch LN Right', 'channel_1', False, gateway)) - elif model == '86plug': + elif model in ['86plug', 'ctrl_86plug.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Plug', 'status', True, gateway)) add_devices(devices) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index e5942f97139..385ce0e0ac9 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.8.1'] +REQUIREMENTS = ['PyXiaomiGateway==0.8.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 93710e6506e..0de78b07f7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.8.1 +PyXiaomiGateway==0.8.2 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From 10570f5ad65a832f6765aa5b1b8953ae40a13f45 Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Mon, 26 Feb 2018 22:27:20 +0100 Subject: [PATCH 036/191] Unbreak tahoma (#12719) * Update requirements_all.txt * Update tahoma.py --- homeassistant/components/tahoma.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index b288a704d74..7c8d047fbcf 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['tahoma-api==0.0.12'] +REQUIREMENTS = ['tahoma-api==0.0.13'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 0de78b07f7b..4549cf2f4f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1156,7 +1156,7 @@ steamodd==4.21 suds-py3==1.3.3.0 # homeassistant.components.tahoma -tahoma-api==0.0.12 +tahoma-api==0.0.13 # homeassistant.components.sensor.tank_utility tank_utility==1.4.0 From 6a665ffb84a4da39b3eb6e3ec0f107f910ead9df Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 26 Feb 2018 22:29:52 +0100 Subject: [PATCH 037/191] Fix homekit: temperature calculation (#12720) --- homeassistant/components/homekit/sensors.py | 14 ++++++-------- tests/components/homekit/test_sensors.py | 1 + 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py index 05465cd1397..968f6834b1d 100644 --- a/homeassistant/components/homekit/sensors.py +++ b/homeassistant/components/homekit/sensors.py @@ -2,7 +2,7 @@ import logging from homeassistant.const import ( - STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT, TEMP_FAHRENHEIT, TEMP_CELSIUS) + ATTR_UNIT_OF_MEASUREMENT, TEMP_FAHRENHEIT, TEMP_CELSIUS) from homeassistant.helpers.event import async_track_state_change from . import TYPES @@ -21,21 +21,19 @@ def calc_temperature(state, unit=TEMP_CELSIUS): Always return temperature as Celsius value. Conversion is handled on the device. """ - if state == STATE_UNKNOWN: + try: + value = float(state) + except ValueError: return None - if unit == TEMP_FAHRENHEIT: - value = round((float(state) - 32) / 1.8, 2) - else: - value = float(state) - return value + return round((value - 32) / 1.8, 2) if unit == TEMP_FAHRENHEIT else value @TYPES.register('TemperatureSensor') class TemperatureSensor(HomeAccessory): """Generate a TemperatureSensor accessory for a temperature sensor. - Sensor entity must return temperature in °C, °F or STATE_UNKNOWN. + Sensor entity must return temperature in °C, °F. """ def __init__(self, hass, entity_id, display_name): diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py index 8e453cea0f2..86a832f570a 100644 --- a/tests/components/homekit/test_sensors.py +++ b/tests/components/homekit/test_sensors.py @@ -12,6 +12,7 @@ from tests.common import get_test_home_assistant def test_calc_temperature(): """Test if temperature in Celsius is calculated correctly.""" assert calc_temperature(STATE_UNKNOWN) is None + assert calc_temperature('test') is None assert calc_temperature('20') == 20 assert calc_temperature('20.12', TEMP_CELSIUS) == 20.12 From 446390a8d1a3fa6f644178ca2403f99630c8025d Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 27 Feb 2018 02:08:37 +0200 Subject: [PATCH 038/191] AsusWRT log exceptions (#12668) * logexception * Improve err message #2978 * not quiet * tests --- homeassistant/components/device_tracker/asuswrt.py | 10 +++++----- tests/components/device_tracker/test_asuswrt.py | 7 +++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 1956e42cb78..14aea561c8e 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -283,15 +283,15 @@ class SshConnection(_Connection): lines = self._ssh.before.split(b'\n')[1:-1] return [line.decode('utf-8') for line in lines] except exceptions.EOF as err: - _LOGGER.error("Connection refused. SSH enabled?") + _LOGGER.error("Connection refused. %s", self._ssh.before) self.disconnect() return None except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", str(err)) + _LOGGER.error("Unexpected SSH error: %s", err) self.disconnect() return None except AssertionError as err: - _LOGGER.error("Connection to router unavailable: %s", str(err)) + _LOGGER.error("Connection to router unavailable: %s", err) self.disconnect() return None @@ -301,10 +301,10 @@ class SshConnection(_Connection): self._ssh = pxssh.pxssh() if self._ssh_key: - self._ssh.login(self._host, self._username, + self._ssh.login(self._host, self._username, quiet=False, ssh_key=self._ssh_key, port=self._port) else: - self._ssh.login(self._host, self._username, + self._ssh.login(self._host, self._username, quiet=False, password=self._password, port=self._port) super().connect() diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 48ddf1d3692..f1f3d9c5224 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -32,8 +32,7 @@ VALID_CONFIG_ROUTER_SSH = {DOMAIN: { CONF_PROTOCOL: 'ssh', CONF_MODE: 'router', CONF_PORT: '22' -} -} +}} WL_DATA = [ 'assoclist 01:02:03:04:06:08\r', @@ -249,7 +248,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, - mock.call('fake_host', 'fake_user', + mock.call('fake_host', 'fake_user', quiet=False, ssh_key=FAKEFILE, port=22) ) @@ -275,7 +274,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, - mock.call('fake_host', 'fake_user', + mock.call('fake_host', 'fake_user', quiet=False, password='fake_pass', port=22) ) From 4821858afb751b27596b3f9e4d205385876c2eb3 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 27 Feb 2018 02:09:49 +0200 Subject: [PATCH 039/191] Homekit schema gracefully fail with integer (#12725) * Homekit schema gracefully fail with integer * Fix return value * Added test * Fix 2 --- homeassistant/components/homekit/__init__.py | 8 ++++---- tests/components/homekit/test_homekit.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 52b90af7b9b..77c69c14596 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -19,7 +19,7 @@ from homeassistant.util.decorator import Registry TYPES = Registry() _LOGGER = logging.getLogger(__name__) -_RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$") +_RE_VALID_PINCODE = r"^(\d{3}-\d{2}-\d{3})$" DOMAIN = 'homekit' REQUIREMENTS = ['HAP-python==1.1.7'] @@ -32,10 +32,10 @@ HOMEKIT_FILE = '.homekit.state' def valid_pin(value): """Validate pin code value.""" - match = _RE_VALID_PINCODE.findall(value.strip()) - if match == []: + match = re.match(_RE_VALID_PINCODE, str(value).strip()) + if not match: raise vol.Invalid("Pin must be in the format: '123-45-678'") - return match[0] + return match.group(0) CONFIG_SCHEMA = vol.Schema({ diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index ca6bf8a8510..2ab284e829a 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -73,7 +73,7 @@ class TestHomeKit(unittest.TestCase): """Test async_setup with invalid config option.""" schema = vol.Schema(valid_pin) - for value in ('', '123-456-78', 'a23-45-678', '12345678'): + for value in ('', '123-456-78', 'a23-45-678', '12345678', 1234): with self.assertRaises(vol.MultipleInvalid): schema(value) From c1a6131aa88420eb7e99e1ba1f2f225b743ff050 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Mon, 26 Feb 2018 22:20:24 -0500 Subject: [PATCH 040/191] Update core HSV color scaling to standard scales: (#12649) Hue is scaled 0-360 Sat is scaled 0-100 Val is scaled 0-100 --- homeassistant/components/light/hue.py | 10 +++++----- homeassistant/components/light/lifx.py | 15 ++++++++++----- homeassistant/util/color.py | 22 ++++++++++++++++------ tests/util/test_color.py | 26 +++++++++++++------------- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index ffca48743e9..40c2f647940 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -287,17 +287,17 @@ class HueLight(Light): if self.info.get('manufacturername') == 'OSRAM': color_hue, sat = color_util.color_xy_to_hs( *kwargs[ATTR_XY_COLOR]) - command['hue'] = color_hue - command['sat'] = sat + command['hue'] = color_hue / 360 * 65535 + command['sat'] = sat / 100 * 255 else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: if self.info.get('manufacturername') == 'OSRAM': hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - command['hue'] = hsv[0] - command['sat'] = hsv[1] - command['bri'] = hsv[2] + command['hue'] = hsv[0] / 360 * 65535 + command['sat'] = hsv[1] / 100 * 255 + command['bri'] = hsv[2] / 100 * 255 else: xyb = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 71a261e3806..2b76a01dbc9 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -169,13 +169,15 @@ def find_hsbk(**kwargs): if ATTR_RGB_COLOR in kwargs: hue, saturation, brightness = \ color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR]) - saturation = convert_8_to_16(saturation) - brightness = convert_8_to_16(brightness) + hue = hue / 360 * 65535 + saturation = saturation / 100 * 65535 + brightness = brightness / 100 * 65535 kelvin = 3500 if ATTR_XY_COLOR in kwargs: hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - saturation = convert_8_to_16(saturation) + hue = hue / 360 * 65535 + saturation = saturation / 100 * 65535 kelvin = 3500 if ATTR_COLOR_TEMP in kwargs: @@ -612,8 +614,11 @@ class LIFXColor(LIFXLight): """Return the RGB value.""" hue, sat, bri, _ = self.device.color - return color_util.color_hsv_to_RGB( - hue, convert_16_to_8(sat), convert_16_to_8(bri)) + hue = hue / 65535 * 360 + sat = sat / 65535 * 100 + bri = bri / 65535 * 100 + + return color_util.color_hsv_to_RGB(hue, sat, bri) class LIFXStrip(LIFXColor): diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 089e1e733ed..24621772050 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -300,16 +300,26 @@ def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: # pylint: disable=invalid-sequence-index -def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[int, int, int]: - """Convert an rgb color to its hsv representation.""" +def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: + """Convert an rgb color to its hsv representation. + + Hue is scaled 0-360 + Sat is scaled 0-100 + Val is scaled 0-100 + """ fHSV = colorsys.rgb_to_hsv(iR/255.0, iG/255.0, iB/255.0) - return (int(fHSV[0]*65536), int(fHSV[1]*255), int(fHSV[2]*255)) + return round(fHSV[0]*360, 3), round(fHSV[1]*100, 3), round(fHSV[2]*100, 3) # pylint: disable=invalid-sequence-index -def color_hsv_to_RGB(iH: int, iS: int, iV: int) -> Tuple[int, int, int]: - """Convert an hsv color into its rgb representation.""" - fRGB = colorsys.hsv_to_rgb(iH/65536, iS/255, iV/255) +def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: + """Convert an hsv color into its rgb representation. + + Hue is scaled 0-360 + Sat is scaled 0-100 + Val is scaled 0-100 + """ + fRGB = colorsys.hsv_to_rgb(iH/360, iS/100, iV/100) return (int(fRGB[0]*255), int(fRGB[1]*255), int(fRGB[2]*255)) diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 8b75e9e9e3f..acdeaa72b95 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -44,16 +44,16 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((0, 0, 0), color_util.color_RGB_to_hsv(0, 0, 0)) - self.assertEqual((0, 0, 255), + self.assertEqual((0, 0, 100), color_util.color_RGB_to_hsv(255, 255, 255)) - self.assertEqual((43690, 255, 255), + self.assertEqual((240, 100, 100), color_util.color_RGB_to_hsv(0, 0, 255)) - self.assertEqual((21845, 255, 255), + self.assertEqual((120, 100, 100), color_util.color_RGB_to_hsv(0, 255, 0)) - self.assertEqual((0, 255, 255), + self.assertEqual((0, 100, 100), color_util.color_RGB_to_hsv(255, 0, 0)) def test_color_hsv_to_RGB(self): @@ -62,16 +62,16 @@ class TestColorUtil(unittest.TestCase): color_util.color_hsv_to_RGB(0, 0, 0)) self.assertEqual((255, 255, 255), - color_util.color_hsv_to_RGB(0, 0, 255)) + color_util.color_hsv_to_RGB(0, 0, 100)) self.assertEqual((0, 0, 255), - color_util.color_hsv_to_RGB(43690, 255, 255)) + color_util.color_hsv_to_RGB(240, 100, 100)) self.assertEqual((0, 255, 0), - color_util.color_hsv_to_RGB(21845, 255, 255)) + color_util.color_hsv_to_RGB(120, 100, 100)) self.assertEqual((255, 0, 0), - color_util.color_hsv_to_RGB(0, 255, 255)) + color_util.color_hsv_to_RGB(0, 100, 100)) def test_color_hsb_to_RGB(self): """Test color_hsb_to_RGB.""" @@ -92,19 +92,19 @@ class TestColorUtil(unittest.TestCase): def test_color_xy_to_hs(self): """Test color_xy_to_hs.""" - self.assertEqual((8609, 255), + self.assertEqual((47.294, 100), color_util.color_xy_to_hs(1, 1)) - self.assertEqual((6950, 32), + self.assertEqual((38.182, 12.941), color_util.color_xy_to_hs(.35, .35)) - self.assertEqual((62965, 255), + self.assertEqual((345.882, 100), color_util.color_xy_to_hs(1, 0)) - self.assertEqual((21845, 255), + self.assertEqual((120, 100), color_util.color_xy_to_hs(0, 1)) - self.assertEqual((40992, 255), + self.assertEqual((225.176, 100), color_util.color_xy_to_hs(0, 0)) def test_rgb_hex_to_rgb_list(self): From 5b8aeafdb9d9c1aaf35b106ce8a836ae820c3871 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Feb 2018 22:20:58 -0800 Subject: [PATCH 041/191] Frontend bump to 20180227.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2a9a7a8a38a..3863a4d390b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180221.1', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180227.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 4549cf2f4f4..3fb52327157 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180221.1 +home-assistant-frontend==20180227.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c0a4d1a586..e5cc8da8367 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180221.1 +home-assistant-frontend==20180227.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 111e515da71702a29b0572dcbe768ea9b498480a Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Tue, 27 Feb 2018 08:31:47 +0200 Subject: [PATCH 042/191] Fix a problem with calling `deconz.close` (#12657) * Fix a problem with calling `deconz.close` The event object (`EVENT_HOMEASSISTANT_STOP`) is sent as an argument to the callable passed to `async_listen_once`. However, `deconz.close` is a bound method that takes no arguments. Therefore, it needs a wrapper to discard the event object. * Removed unnecessary code and added a docstring. * Fix the docstring according to guidelines. * Removed unnecessary whitespace. --- homeassistant/components/deconz/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 693f3e4470a..18197b84b61 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.discovery import SERVICE_DECONZ from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -143,7 +144,18 @@ def async_setup_deconz(hass, config, deconz_config): hass.services.async_register( DOMAIN, 'configure', async_configure, schema=SERVICE_SCHEMA) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz.close) + @callback + def deconz_shutdown(event): + """ + Wrap the call to deconz.close. + + Used as an argument to EventBus.async_listen_once - EventBus calls + this method with the event as the first argument, which should not + be passed on to deconz.close. + """ + deconz.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz_shutdown) return True From 71cab65df6f6c91674340a6464650c1327cb2c78 Mon Sep 17 00:00:00 2001 From: Thiago Oliveira Date: Mon, 26 Feb 2018 22:39:26 -0800 Subject: [PATCH 043/191] correct air index unit (#12730) AirVisual's air index unit is AQI (Air Quality Index), not PSI (Pressure per Square Inch). More details can be found at https://airvisual.com/ Cheers, --- homeassistant/components/sensor/airvisual.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index d67415fc65e..b4007c8d744 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -210,7 +210,7 @@ class AirQualityIndexSensor(AirVisualBaseSensor): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return 'PSI' + return 'AQI' def update(self): """Update the status of the sensor.""" From 98fef81d194d670b874ebbac23f6fed6bc625234 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 27 Feb 2018 07:40:46 +0100 Subject: [PATCH 044/191] Fix harmony duplicate detection (#12729) --- homeassistant/components/remote/harmony.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index c84d214d826..842dce087e8 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port) # Ignore hub name when checking if this hub is known - ip and port only - if host and host[1:] in (h.host for h in DEVICES): + if host[1:] in ((h.host, h.port) for h in DEVICES): _LOGGER.debug("Discovered host already known: %s", host) return elif CONF_HOST in config: @@ -139,7 +139,7 @@ class HarmonyRemote(remote.RemoteDevice): _LOGGER.debug("HarmonyRemote device init started for: %s", name) self._name = name self.host = host - self._port = port + self.port = port self._state = None self._current_activity = None self._default_activity = activity From c1c23bb4b641c56a74c3706b9bdeafb96fc24638 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 27 Feb 2018 07:41:37 +0100 Subject: [PATCH 045/191] Remove automatic sqlite vacuum (#12728) --- homeassistant/components/recorder/__init__.py | 3 +-- homeassistant/components/recorder/purge.py | 1 - tests/components/recorder/test_purge.py | 17 ++++++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index bffe29ec59b..392bccb56d4 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -165,7 +165,6 @@ class Recorder(threading.Thread): self.hass = hass self.keep_days = keep_days self.purge_interval = purge_interval - self.did_vacuum = False self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri @@ -269,7 +268,7 @@ class Recorder(threading.Thread): def async_purge(now): """Trigger the purge and schedule the next run.""" self.queue.put( - PurgeTask(self.keep_days, repack=not self.did_vacuum)) + PurgeTask(self.keep_days, repack=False)) self.hass.helpers.event.async_track_point_in_time( async_purge, now + timedelta(days=self.purge_interval)) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index d2afb6076e3..4af2a62151f 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -66,6 +66,5 @@ def purge_old_data(instance, purge_days, repack): _LOGGER.debug("Vacuuming SQLite to free space") try: instance.engine.execute("VACUUM") - instance.did_vacuum = True except exc.OperationalError as err: _LOGGER.error("Error vacuuming SQLite: %s.", err) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 7bac7c25e7e..91aa69b4484 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timedelta import unittest +from unittest.mock import patch from homeassistant.components import recorder from homeassistant.components.recorder.const import DATA_INSTANCE @@ -199,10 +200,12 @@ class TestRecorderPurge(unittest.TestCase): event.event_type for event in events.all())) # run purge method - correct service data, with repack - service_data['repack'] = True - self.assertFalse(self.hass.data[DATA_INSTANCE].did_vacuum) - self.hass.services.call('recorder', 'purge', - service_data=service_data) - self.hass.block_till_done() - self.hass.data[DATA_INSTANCE].block_till_done() - self.assertTrue(self.hass.data[DATA_INSTANCE].did_vacuum) + with patch('homeassistant.components.recorder.purge._LOGGER') \ + as mock_logger: + service_data['repack'] = True + self.hass.services.call('recorder', 'purge', + service_data=service_data) + self.hass.block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() + self.assertEqual(mock_logger.debug.mock_calls[4][1][0], + "Vacuuming SQLite to free space") From 6bdb2fe5a03b33c5fd14a0bc0195fa229ce65da8 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Tue, 27 Feb 2018 07:42:32 +0100 Subject: [PATCH 046/191] fix for https://github.com/home-assistant/home-assistant/issues/12673 (#12726) --- homeassistant/components/sensor/alpha_vantage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 81c84a7f918..896497a93d5 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -70,8 +70,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from alpha_vantage.foreignexchange import ForeignExchange api_key = config.get(CONF_API_KEY) - symbols = config.get(CONF_SYMBOLS) - conversions = config.get(CONF_FOREIGN_EXCHANGE) + symbols = config.get(CONF_SYMBOLS, []) + conversions = config.get(CONF_FOREIGN_EXCHANGE, []) if not symbols and not conversions: msg = 'Warning: No symbols or currencies configured.' From 21d8ecdacd2419ec4d1c10038411a59649181fb4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 27 Feb 2018 07:44:11 +0100 Subject: [PATCH 047/191] Bugfix: Update of sources for non AVR-X devices always fails (#12711) * Basic support of post 2016 AVR-X receivers * Bugfix: Update of sources for non AVR-X devices always fails --- homeassistant/components/media_player/denonavr.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 5bc16d11d64..fe8fc46c24b 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.6.0'] +REQUIREMENTS = ['denonavr==0.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3fb52327157..0ac9e45cf77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ defusedxml==0.5.0 deluge-client==1.0.5 # homeassistant.components.media_player.denonavr -denonavr==0.6.0 +denonavr==0.6.1 # homeassistant.components.media_player.directv directpy==0.2 From f396266c74d8974038c70bbef918616621d362d9 Mon Sep 17 00:00:00 2001 From: tumik Date: Tue, 27 Feb 2018 08:44:57 +0200 Subject: [PATCH 048/191] Component deconz: Fix dark attribute on presence sensors (#12691) pydeconz changed PRESENCE to be an array in v25, so this code hasn't worked since that change. --- homeassistant/components/binary_sensor/deconz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 8fea7891c3d..28e78db90ec 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -99,6 +99,6 @@ class DeconzBinarySensor(BinarySensorDevice): attr = { ATTR_BATTERY_LEVEL: self._sensor.battery, } - if self._sensor.type == PRESENCE: + if self._sensor.type in PRESENCE: attr['dark'] = self._sensor.dark return attr From 4242411089ae6966f00d1695161a18a4a789b78a Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 27 Feb 2018 01:53:54 -0500 Subject: [PATCH 049/191] Disable asuswrt tests (#12663) --- .coveragerc | 1 + tests/components/device_tracker/test_asuswrt.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/.coveragerc b/.coveragerc index 46981341bac..c550bc407a6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -353,6 +353,7 @@ omit = homeassistant/components/cover/scsgate.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py + homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/automatic.py homeassistant/components/device_tracker/bbox.py homeassistant/components/device_tracker/bluetooth_le_tracker.py diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index f1f3d9c5224..27f28412561 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -19,6 +19,7 @@ from homeassistant.components.device_tracker.asuswrt import ( from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) +import pytest from tests.common import ( get_test_home_assistant, get_test_config_dir, assert_setup_component, mock_component) @@ -118,6 +119,10 @@ def teardown_module(): os.remove(FAKEFILE) +@pytest.mark.skip( + reason="These tests are performing actual failing network calls. They " + "need to be cleaned up before they are re-enabled. They're frequently " + "failing in Travis.") class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): """Tests for the ASUSWRT device tracker platform.""" @@ -468,6 +473,10 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): self.assertEqual({}, scanner._get_leases(NEIGH_DEVICES.copy())) +@pytest.mark.skip( + reason="These tests are performing actual failing network calls. They " + "need to be cleaned up before they are re-enabled. They're frequently " + "failing in Travis.") class TestSshConnection(unittest.TestCase): """Testing SshConnection.""" @@ -508,6 +517,10 @@ class TestSshConnection(unittest.TestCase): self.assertIsNone(self.connection._ssh) +@pytest.mark.skip( + reason="These tests are performing actual failing network calls. They " + "need to be cleaned up before they are re-enabled. They're frequently " + "failing in Travis.") class TestTelnetConnection(unittest.TestCase): """Testing TelnetConnection.""" From d052d457129de90d44be7041ee41a5bf43832b85 Mon Sep 17 00:00:00 2001 From: Jesse Hills Date: Tue, 27 Feb 2018 20:07:39 +1300 Subject: [PATCH 050/191] Fix getting state from iglo (#12685) --- homeassistant/components/light/iglo.py | 12 ++++++------ requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index e39b5dbf540..1e110b5c397 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['iglo==1.2.5'] +REQUIREMENTS = ['iglo==1.2.6'] _LOGGER = logging.getLogger(__name__) @@ -56,13 +56,13 @@ class IGloLamp(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return int((self._lamp.state['brightness'] / 200.0) * 255) + return int((self._lamp.state()['brightness'] / 200.0) * 255) @property def color_temp(self): """Return the color temperature.""" return color_util.color_temperature_kelvin_to_mired( - self._lamp.state['white']) + self._lamp.state()['white']) @property def min_mireds(self): @@ -79,12 +79,12 @@ class IGloLamp(Light): @property def rgb_color(self): """Return the RGB value.""" - return self._lamp.state['rgb'] + return self._lamp.state()['rgb'] @property def effect(self): """Return the current effect.""" - return self._lamp.state['effect'] + return self._lamp.state()['effect'] @property def effect_list(self): @@ -100,7 +100,7 @@ class IGloLamp(Light): @property def is_on(self): """Return true if light is on.""" - return self._lamp.state['on'] + return self._lamp.state()['on'] def turn_on(self, **kwargs): """Turn the light on.""" diff --git a/requirements_all.txt b/requirements_all.txt index 0ac9e45cf77..d4f223bd0a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -400,7 +400,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # i2csense==0.0.4 # homeassistant.components.light.iglo -iglo==1.2.5 +iglo==1.2.6 # homeassistant.components.ihc ihcsdk==2.1.1 From dc8c331c686367f4cb2d0fad343da950553c4da5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Feb 2018 00:05:29 -0800 Subject: [PATCH 051/191] Update ZHA deps (#12737) --- homeassistant/components/zha/__init__.py | 4 ++-- requirements_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index bb29cb28b0f..9a8c88e6f23 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,8 +17,8 @@ from homeassistant.helpers import discovery, entity from homeassistant.util import slugify REQUIREMENTS = [ - 'bellows==0.5.0', - 'zigpy==0.0.1', + 'bellows==0.5.1', + 'zigpy==0.0.3', 'zigpy-xbee==0.0.2', ] diff --git a/requirements_all.txt b/requirements_all.txt index d4f223bd0a2..77dc3fa7793 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -133,7 +133,7 @@ batinfo==0.4.2 beautifulsoup4==4.6.0 # homeassistant.components.zha -bellows==0.5.0 +bellows==0.5.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.3.0 @@ -1298,4 +1298,4 @@ ziggo-mediabox-xl==1.0.0 zigpy-xbee==0.0.2 # homeassistant.components.zha -zigpy==0.0.1 +zigpy==0.0.3 From 9751fed493c229c802863c5667e658c93fc80c6f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 27 Feb 2018 10:58:45 +0100 Subject: [PATCH 052/191] Don't allow to use a old unsecure library (#12715) * Don't allow to use a old unsecury library * Update gen_requirements_all.py * Cryptodome fix for python-broadlink * Coinbase cryptodome fix --- homeassistant/components/coinbase.py | 4 +++- homeassistant/components/sensor/broadlink.py | 4 +++- homeassistant/components/switch/broadlink.py | 4 +++- homeassistant/package_constraints.txt | 3 +++ requirements_all.txt | 14 +++++++------- script/gen_requirements_all.py | 3 +++ 6 files changed, 22 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py index 10123752c99..515da3e4f54 100644 --- a/homeassistant/components/coinbase.py +++ b/homeassistant/components/coinbase.py @@ -14,7 +14,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['coinbase==2.0.7'] +REQUIREMENTS = [ + 'https://github.com/balloob/coinbase-python/archive/' + '3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 1440e2496fe..47cefe50aec 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -19,7 +19,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['broadlink==0.5'] +REQUIREMENTS = [ + 'https://github.com/balloob/python-broadlink/archive/' + '3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 91ecc9c7111..38888733ba6 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -22,7 +22,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle from homeassistant.util.dt import utcnow -REQUIREMENTS = ['broadlink==0.5'] +REQUIREMENTS = [ + 'https://github.com/balloob/python-broadlink/archive/' + '3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2eb42b94389..7b6a5f09330 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,3 +15,6 @@ attrs==17.4.0 # Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 + +# This is a old unmaintained library and is replaced with pycryptodome +pycrypto==1000000000.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 77dc3fa7793..9cb0f4b6fc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -165,10 +165,6 @@ boto3==1.4.7 # homeassistant.scripts.credstash botocore==1.7.34 -# homeassistant.components.sensor.broadlink -# homeassistant.components.switch.broadlink -broadlink==0.5 - # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar buienradar==0.91 @@ -179,9 +175,6 @@ caldav==0.5.0 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 -# homeassistant.components.coinbase -coinbase==2.0.7 - # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.2.1 @@ -370,6 +363,13 @@ httplib2==0.10.3 # homeassistant.components.media_player.braviatv https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 +# homeassistant.components.coinbase +https://github.com/balloob/coinbase-python/archive/3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1 + +# homeassistant.components.sensor.broadlink +# homeassistant.components.switch.broadlink +https://github.com/balloob/python-broadlink/archive/3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1 + # homeassistant.components.media_player.spotify https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 806700cfc97..3d8a7d1d8e6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -112,6 +112,9 @@ CONSTRAINT_PATH = os.path.join(os.path.dirname(__file__), CONSTRAINT_BASE = """ # Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 + +# This is a old unmaintained library and is replaced with pycryptodome +pycrypto==1000000000.0.0 """ From be64422d1c4bd5553331d0f935b89f96e3e65043 Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Tue, 27 Feb 2018 15:22:52 +0200 Subject: [PATCH 053/191] Fix Citybikes naming (#12661) * Fix Citybikes naming. * Semantic fixes upon requests. Entity ID generation now includes the base name (again), resulting in preservation of the entity ID format (the change is non-breaking) * Use `async_generate_entity_id`. --- homeassistant/components/sensor/citybikes.py | 38 ++++++++++---------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index a13d2ca8d56..b7635f729e2 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -13,15 +13,14 @@ import async_timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT from homeassistant.const import ( CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_FRIENDLY_NAME, STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, - ATTR_ID) + STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, ATTR_ID) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, distance @@ -41,7 +40,7 @@ CONF_NETWORK = 'network' CONF_STATIONS_LIST = 'stations' DEFAULT_ENDPOINT = 'https://api.citybik.es/{uri}' -DOMAIN = 'citybikes' +PLATFORM = 'citybikes' MONITORED_NETWORKS = 'monitored-networks' @@ -132,8 +131,8 @@ def async_citybikes_request(hass, uri, schema): def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the CityBikes platform.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {MONITORED_NETWORKS: {}} + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {MONITORED_NETWORKS: {}} latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -148,14 +147,14 @@ def async_setup_platform(hass, config, async_add_devices, network_id = yield from CityBikesNetwork.get_closest_network_id( hass, latitude, longitude) - if network_id not in hass.data[DOMAIN][MONITORED_NETWORKS]: + if network_id not in hass.data[PLATFORM][MONITORED_NETWORKS]: network = CityBikesNetwork(hass, network_id) - hass.data[DOMAIN][MONITORED_NETWORKS][network_id] = network + hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network hass.async_add_job(network.async_refresh) async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL) else: - network = hass.data[DOMAIN][MONITORED_NETWORKS][network_id] + network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id] yield from network.ready.wait() @@ -169,7 +168,7 @@ def async_setup_platform(hass, config, async_add_devices, if radius > dist or stations_list.intersection((station_id, station_uid)): - devices.append(CityBikesStation(network, station_id, name)) + devices.append(CityBikesStation(hass, network, station_id, name)) async_add_devices(devices, True) @@ -238,12 +237,17 @@ class CityBikesNetwork: class CityBikesStation(Entity): """CityBikes API Sensor.""" - def __init__(self, network, station_id, base_name=''): + def __init__(self, hass, network, station_id, base_name=''): """Initialize the sensor.""" self._network = network self._station_id = station_id self._station_data = {} - self._base_name = base_name + if base_name: + uid = "_".join([network.network_id, base_name, station_id]) + else: + uid = "_".join([network.network_id, station_id]) + self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, + hass=hass) @property def state(self): @@ -253,10 +257,9 @@ class CityBikesStation(Entity): @property def name(self): """Return the name of the sensor.""" - if self._base_name: - return "{} {} {}".format(self._network.network_id, self._base_name, - self._station_id) - return "{} {}".format(self._network.network_id, self._station_id) + if ATTR_NAME in self._station_data: + return self._station_data[ATTR_NAME] + return None @asyncio.coroutine def async_update(self): @@ -277,7 +280,6 @@ class CityBikesStation(Entity): ATTR_LATITUDE: self._station_data[ATTR_LATITUDE], ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE], ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS], - ATTR_FRIENDLY_NAME: self._station_data[ATTR_NAME], ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP], } return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} From 8645f244dab0f7fa41d46f6464018661b92ff710 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 27 Feb 2018 20:18:20 +0100 Subject: [PATCH 054/191] Fix MQTT async_add_job in sync context (#12744) --- homeassistant/components/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 0485d82a274..8a5fdb5b86b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -588,7 +588,7 @@ class MQTT(object): def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" - self.hass.async_add_job(self._mqtt_handle_message, msg) + self.hass.add_job(self._mqtt_handle_message, msg) @callback def _mqtt_handle_message(self, msg): From 4e522448b11e890d1ad0fa6d829e3fef5af1bee1 Mon Sep 17 00:00:00 2001 From: Philip Rosenberg-Watt Date: Tue, 27 Feb 2018 12:19:02 -0700 Subject: [PATCH 055/191] Fix DarkSky floating-point math (#12753) DarkSky delivers relative humidity from 0-100% as a 0-1 decimal value, so we convert it by multiplying by 100.0. Unfortunately, due to floating point math, the display of a raw value of 0.29 ends up looking like 28.999999999999996% relative humidity. This change rounds the value to two decimal places. --- homeassistant/components/weather/darksky.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index 21f67ce080a..139f8abfce6 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -96,7 +96,7 @@ class DarkSkyWeather(WeatherEntity): @property def humidity(self): """Return the humidity.""" - return self._ds_currently.get('humidity') * 100.0 + return round(self._ds_currently.get('humidity') * 100.0, 2) @property def wind_speed(self): From 138350fe3d17b743f7e9538ec99028e70658b2b2 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Tue, 27 Feb 2018 21:27:52 +0100 Subject: [PATCH 056/191] Xiaomi MiIO Light: Flag the device as unavailable if not reachable (#12449) * Unavailable state introduced if the device isn't reachable. A new configuration option "model" can be used to define the device type. ``` light: - platform: xiaomi_miio name: Xiaomi Philips Smart LED Ball host: 192.168.130.67 token: da548d86f55996413d82eea94279d2ff # Bypass of the device model detection. # Optional but required to add an unavailable device model: philips.light.bulb ``` New attribute "scene" and "delay_off_countdown" added. New service xiaomi_miio_set_delay_off introduced. * Service xiaomi_miio_set_delayed_turn_off updated. The attribute "delayed_turn_off" is a timestamp now. * None must be a valid model. * Math. * Microseconds removed because of the low resolution. * Comment updated. * Update the ATTR_DELAYED_TURN_OFF on a deviation of 4 seconds (max latency) only. * Import of datetime fixed. * Typo fixed. * pylint issues fixed. * Naming. * Service parameter renamed. * New ceiling lamp model (philips.light.zyceiling) added. * Use positive timedelta instead of seconds. * Use a unique data key per domain. --- homeassistant/components/light/services.yaml | 10 ++ homeassistant/components/light/xiaomi_miio.py | 170 +++++++++++++----- 2 files changed, 134 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 0bcf6933e68..bf769aec6fb 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -169,3 +169,13 @@ xiaomi_miio_set_scene: scene: description: Number of the fixed scene, between 1 and 4. example: 1 + +xiaomi_miio_set_delayed_turn_off: + description: Delayed turn off. + fields: + entity_id: + description: Name of the light entity. + example: 'light.xiaomi_miio' + time_period: + description: Time period for the delayed turn off. + example: 5, '0:05', {'minutes': 5} diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index eaf41691903..d9b7d6c76db 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -8,6 +8,8 @@ import asyncio from functools import partial import logging from math import ceil +from datetime import timedelta +import datetime import voluptuous as vol @@ -18,16 +20,24 @@ from homeassistant.components.light import ( from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) from homeassistant.exceptions import PlatformNotReady +from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Philips Light' -PLATFORM = 'xiaomi_miio' +DATA_KEY = 'light.xiaomi_miio' + +CONF_MODEL = 'model' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODEL): vol.In( + ['philips.light.sread1', + 'philips.light.ceiling', + 'philips.light.zyceiling', + 'philips.light.bulb']), }) REQUIREMENTS = ['python-miio==0.3.7'] @@ -36,25 +46,38 @@ REQUIREMENTS = ['python-miio==0.3.7'] CCT_MIN = 1 CCT_MAX = 100 +DELAYED_TURN_OFF_MAX_DEVIATION = 4 + SUCCESS = ['ok'] ATTR_MODEL = 'model' ATTR_SCENE = 'scene' +ATTR_DELAYED_TURN_OFF = 'delayed_turn_off' +ATTR_TIME_PERIOD = 'time_period' SERVICE_SET_SCENE = 'xiaomi_miio_set_scene' +SERVICE_SET_DELAYED_TURN_OFF = 'xiaomi_miio_set_delayed_turn_off' XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SERVICE_SCHEMA_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ +SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=4)) }) +SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_TIME_PERIOD): + vol.All(cv.time_period, cv.positive_timedelta) +}) + SERVICE_TO_METHOD = { + SERVICE_SET_DELAYED_TURN_OFF: { + 'method': 'async_set_delayed_turn_off', + 'schema': SERVICE_SCHEMA_SET_DELAYED_TURN_OFF}, SERVICE_SET_SCENE: { 'method': 'async_set_scene', - 'schema': SERVICE_SCHEMA_SCENE} + 'schema': SERVICE_SCHEMA_SET_SCENE}, } @@ -63,46 +86,48 @@ SERVICE_TO_METHOD = { def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the light from config.""" from miio import Device, DeviceException - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} host = config.get(CONF_HOST) name = config.get(CONF_NAME) token = config.get(CONF_TOKEN) + model = config.get(CONF_MODEL) _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - try: - light = Device(host, token) - device_info = light.info() - _LOGGER.info("%s %s %s initialized", - device_info.model, - device_info.firmware_version, - device_info.hardware_version) + if model is None: + try: + miio_device = Device(host, token) + device_info = miio_device.info() + model = device_info.model + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) + except DeviceException: + raise PlatformNotReady - if device_info.model == 'philips.light.sread1': - from miio import PhilipsEyecare - light = PhilipsEyecare(host, token) - device = XiaomiPhilipsEyecareLamp(name, light, device_info) - elif device_info.model == 'philips.light.ceiling': - from miio import Ceil - light = Ceil(host, token) - device = XiaomiPhilipsCeilingLamp(name, light, device_info) - elif device_info.model == 'philips.light.bulb': - from miio import PhilipsBulb - light = PhilipsBulb(host, token) - device = XiaomiPhilipsLightBall(name, light, device_info) - else: - _LOGGER.error( - 'Unsupported device found! Please create an issue at ' - 'https://github.com/rytilahti/python-miio/issues ' - 'and provide the following data: %s', device_info.model) - return False + if model == 'philips.light.sread1': + from miio import PhilipsEyecare + light = PhilipsEyecare(host, token) + device = XiaomiPhilipsEyecareLamp(name, light, model) + elif model in ['philips.light.ceiling', 'philips.light.zyceiling']: + from miio import Ceil + light = Ceil(host, token) + device = XiaomiPhilipsCeilingLamp(name, light, model) + elif model == 'philips.light.bulb': + from miio import PhilipsBulb + light = PhilipsBulb(host, token) + device = XiaomiPhilipsLightBall(name, light, model) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/rytilahti/python-miio/issues ' + 'and provide the following data: %s', model) + return False - except DeviceException: - raise PlatformNotReady - - hass.data[PLATFORM][host] = device + hass.data[DATA_KEY][host] = device async_add_devices([device], update_before_add=True) @asyncio.coroutine @@ -113,10 +138,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if key != ATTR_ENTITY_ID} entity_ids = service.data.get(ATTR_ENTITY_ID) if entity_ids: - target_devices = [dev for dev in hass.data[PLATFORM].values() + target_devices = [dev for dev in hass.data[DATA_KEY].values() if dev.entity_id in entity_ids] else: - target_devices = hass.data[PLATFORM].values() + target_devices = hass.data[DATA_KEY].values() update_tasks = [] for target_device in target_devices: @@ -136,10 +161,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiPhilipsGenericLight(Light): """Representation of a Xiaomi Philips Light.""" - def __init__(self, name, light, device_info): + def __init__(self, name, light, model): """Initialize the light device.""" self._name = name - self._device_info = device_info + self._model = model self._brightness = None self._color_temp = None @@ -147,7 +172,9 @@ class XiaomiPhilipsGenericLight(Light): self._light = light self._state = None self._state_attrs = { - ATTR_MODEL: self._device_info.model, + ATTR_MODEL: self._model, + ATTR_SCENE: None, + ATTR_DELAYED_TURN_OFF: None, } @property @@ -217,14 +244,14 @@ class XiaomiPhilipsGenericLight(Light): if result: self._brightness = brightness - - self._state = yield from self._try_command( - "Turning the light on failed.", self._light.on) + else: + yield from self._try_command( + "Turning the light on failed.", self._light.on) @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the light off.""" - self._state = yield from self._try_command( + yield from self._try_command( "Turning the light off failed.", self._light.off) @asyncio.coroutine @@ -236,9 +263,20 @@ class XiaomiPhilipsGenericLight(Light): _LOGGER.debug("Got new state: %s", state) self._state = state.is_on - self._brightness = ceil((255/100.0) * state.brightness) + self._brightness = ceil((255 / 100.0) * state.brightness) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) @asyncio.coroutine @@ -248,6 +286,13 @@ class XiaomiPhilipsGenericLight(Light): "Setting a fixed scene failed.", self._light.set_scene, scene) + @asyncio.coroutine + def async_set_delayed_turn_off(self, time_period: timedelta): + """Set delay off. The unit is different per device.""" + yield from self._try_command( + "Setting the delay off failed.", + self._light.delay_off, time_period.total_seconds()) + @staticmethod def translate(value, left_min, left_max, right_min, right_max): """Map a value from left span to right span.""" @@ -256,6 +301,28 @@ class XiaomiPhilipsGenericLight(Light): value_scaled = float(value - left_min) / float(left_span) return int(right_min + (value_scaled * right_span)) + @staticmethod + def delayed_turn_off_timestamp(countdown: int, + current: datetime, + previous: datetime): + """Update the turn off timestamp only if necessary.""" + if countdown > 0: + new = current.replace(microsecond=0) + \ + timedelta(seconds=countdown) + + if previous is None: + return new + + lower = timedelta(seconds=-DELAYED_TURN_OFF_MAX_DEVIATION) + upper = timedelta(seconds=DELAYED_TURN_OFF_MAX_DEVIATION) + diff = previous - new + if lower < diff < upper: + return previous + + return new + + return None + class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): """Representation of a Xiaomi Philips Light Ball.""" @@ -339,7 +406,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): self._brightness = brightness else: - self._state = yield from self._try_command( + yield from self._try_command( "Turning the light on failed.", self._light.on) @asyncio.coroutine @@ -351,13 +418,24 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): _LOGGER.debug("Got new state: %s", state) self._state = state.is_on - self._brightness = ceil((255/100.0) * state.brightness) + self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds) + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + }) + except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) From 6ed765698ad977634c481919cb8443142b1c1ba1 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 27 Feb 2018 22:29:17 +0200 Subject: [PATCH 057/191] Check_config await error (#12722) * not awaited * hounded --- homeassistant/scripts/check_config.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index a062c1724ae..ecbd7ca22eb 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -10,6 +10,7 @@ from unittest.mock import patch from typing import Dict, List, Sequence +from homeassistant.core import callback from homeassistant import bootstrap, loader, setup, config as config_util import homeassistant.util.yaml as yaml from homeassistant.exceptions import HomeAssistantError @@ -35,9 +36,9 @@ MOCKS = { bootstrap._LOGGER.error), } SILENCE = ( - 'homeassistant.bootstrap.async_enable_logging', + 'homeassistant.bootstrap.async_enable_logging', # callback 'homeassistant.bootstrap.clear_secret_cache', - 'homeassistant.bootstrap.async_register_signal_handling', + 'homeassistant.bootstrap.async_register_signal_handling', # callback 'homeassistant.config.process_ha_config_upgrade', ) PATCHES = {} @@ -46,8 +47,9 @@ C_HEAD = 'bold' ERROR_STR = 'General Errors' -async def mock_coro(*args): - """Coroutine that returns None.""" +@callback +def mock_cb(*args): + """Callback that returns None.""" return None @@ -246,7 +248,7 @@ def check(config_path): # Patches to skip functions for sil in SILENCE: - PATCHES[sil] = patch(sil, return_value=mock_coro()) + PATCHES[sil] = patch(sil, return_value=mock_cb()) # Patches with local mock functions for key, val in MOCKS.items(): From 39cee987d9ba98d3cafec2bbe943bd0f88be1ef9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 27 Feb 2018 22:21:56 +0100 Subject: [PATCH 058/191] Add Songpal ("Sony Audio Control API") platform (#12143) * Add Songpal ("Sony Audio Control API") platform This adds support for a variety of Sony soundbars and potentially many more devices using the same API. See http://vssupport.sony.net/en_ww/device.html for list of supported devices. * add songpal requirement * update coveragerc * fix linting * add service description to yaml * add entity_id * make pylint also happy. * raise PlatformNotReady when initialization fails, bump requirement for better exception handling * use noqa instead of pylint's disable, fix newline error * use async defs and awaits * Make linter happy * Changes based on code review, fix set_sound_setting. * define set_sound_setting schema on top of the file * Move initialization back to async_setup_platform * Fix linting * Fixes based on code review * Fix coveragerc ordering * Do not print out the whole exception trace when failing to update * Do not bail out when receiving more than one volume control * Set to unavailable when no volume controls are available --- .coveragerc | 1 + homeassistant/components/discovery.py | 1 + .../components/media_player/services.yaml | 14 + .../components/media_player/songpal.py | 252 ++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 271 insertions(+) create mode 100644 homeassistant/components/media_player/songpal.py diff --git a/.coveragerc b/.coveragerc index c550bc407a6..8cc8077312a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -465,6 +465,7 @@ omit = homeassistant/components/media_player/russound_rio.py homeassistant/components/media_player/russound_rnet.py homeassistant/components/media_player/snapcast.py + homeassistant/components/media_player/songpal.py homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 980ac7d661c..d1045143bb2 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -71,6 +71,7 @@ SERVICE_HANDLERS = { 'sabnzbd': ('sensor', 'sabnzbd'), 'bose_soundtouch': ('media_player', 'soundtouch'), 'bluesound': ('media_player', 'bluesound'), + 'songpal': ('media_player', 'songpal'), } CONF_IGNORE = 'ignore' diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 7ac250b1d30..4d488a92300 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -364,3 +364,17 @@ bluesound_clear_sleep_timer: entity_id: description: Name(s) of entities that will have the timer cleared. example: 'media_player.bluesound_livingroom' + +songpal_set_sound_setting: + description: Change sound setting. + + fields: + entity_id: + description: Target device. + example: 'media_player.my_soundbar' + name: + description: Name of the setting. + example: 'nightMode' + value: + description: Value to set. + example: 'on' diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py new file mode 100644 index 00000000000..83637633ba1 --- /dev/null +++ b/homeassistant/components/media_player/songpal.py @@ -0,0 +1,252 @@ +""" +Support for Songpal-enabled (Sony) media devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.songpal/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_SET, + SUPPORT_TURN_ON, MediaPlayerDevice, DOMAIN) +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF, ATTR_ENTITY_ID) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-songpal==0.0.6'] + +SUPPORT_SONGPAL = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM = "songpal" + +SET_SOUND_SETTING = "songpal_set_sound_setting" + +PARAM_NAME = "name" +PARAM_VALUE = "value" + +CONF_ENDPOINT = "endpoint" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_ENDPOINT): cv.string, +}) + +SET_SOUND_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(PARAM_NAME): cv.string, + vol.Required(PARAM_VALUE): cv.string}) + + +async def async_setup_platform(hass, config, + async_add_devices, discovery_info=None): + """Set up the Songpal platform.""" + from songpal import SongpalException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + if discovery_info is not None: + name = discovery_info["name"] + endpoint = discovery_info["properties"]["endpoint"] + _LOGGER.debug("Got autodiscovered %s - endpoint: %s", name, endpoint) + + device = SongpalDevice(name, endpoint) + else: + name = config.get(CONF_NAME) + endpoint = config.get(CONF_ENDPOINT) + device = SongpalDevice(name, endpoint) + + try: + await device.initialize() + except SongpalException as ex: + _LOGGER.error("Unable to get methods from songpal: %s", ex) + raise PlatformNotReady + + hass.data[PLATFORM][endpoint] = device + + async_add_devices([device], True) + + async def async_service_handler(service): + """Service handler.""" + entity_id = service.data.get("entity_id", None) + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + + for device in hass.data[PLATFORM].values(): + if device.entity_id == entity_id or entity_id is None: + _LOGGER.debug("Calling %s (entity: %s) with params %s", + service, entity_id, params) + + await device.async_set_sound_setting(params[PARAM_NAME], + params[PARAM_VALUE]) + + hass.services.async_register( + DOMAIN, SET_SOUND_SETTING, async_service_handler, + schema=SET_SOUND_SCHEMA) + + +class SongpalDevice(MediaPlayerDevice): + """Class representing a Songpal device.""" + + def __init__(self, name, endpoint): + """Init.""" + import songpal + self._name = name + self.endpoint = endpoint + self.dev = songpal.Protocol(self.endpoint) + self._sysinfo = None + + self._state = False + self._available = False + self._initialized = False + + self._volume_control = None + self._volume_min = 0 + self._volume_max = 1 + self._volume = 0 + self._is_muted = False + + self._sources = [] + + async def initialize(self): + """Initialize the device.""" + await self.dev.get_supported_methods() + self._sysinfo = await self.dev.get_system_info() + + @property + def name(self): + """Return name of the device.""" + return self._name + + @property + def unique_id(self): + """Return an unique ID.""" + return self._sysinfo.macAddr + + @property + def available(self): + """Return availability of the device.""" + return self._available + + async def async_set_sound_setting(self, name, value): + """Change a setting on the device.""" + await self.dev.set_sound_settings(name, value) + + async def async_update(self): + """Fetch updates from the device.""" + from songpal import SongpalException + try: + volumes = await self.dev.get_volume_information() + if not volumes: + _LOGGER.error("Got no volume controls, bailing out") + self._available = False + return + + if len(volumes) > 1: + _LOGGER.warning("Got %s volume controls, using the first one", + volumes) + + volume = volumes.pop() + _LOGGER.debug("Current volume: %s", volume) + + self._volume_max = volume.maxVolume + self._volume_min = volume.minVolume + self._volume = volume.volume + self._volume_control = volume + self._is_muted = self._volume_control.is_muted + + status = await self.dev.get_power() + self._state = status.status + _LOGGER.debug("Got state: %s", status) + + inputs = await self.dev.get_inputs() + _LOGGER.debug("Got ins: %s", inputs) + self._sources = inputs + + self._available = True + except SongpalException as ex: + # if we were available, print out the exception + if self._available: + _LOGGER.error("Got an exception: %s", ex) + self._available = False + + async def async_select_source(self, source): + """Select source.""" + for out in self._sources: + if out.title == source: + await out.activate() + return + + _LOGGER.error("Unable to find output: %s", source) + + @property + def source_list(self): + """Return list of available sources.""" + return [x.title for x in self._sources] + + @property + def state(self): + """Return current state.""" + if self._state: + return STATE_ON + return STATE_OFF + + @property + def source(self): + """Return currently active source.""" + for out in self._sources: + if out.active: + return out.title + + return None + + @property + def volume_level(self): + """Return volume level.""" + volume = self._volume / self._volume_max + return volume + + async def async_set_volume_level(self, volume): + """Set volume level.""" + volume = int(volume * self._volume_max) + _LOGGER.debug("Setting volume to %s", volume) + return await self._volume_control.set_volume(volume) + + async def async_volume_up(self): + """Set volume up.""" + return await self._volume_control.set_volume("+1") + + async def async_volume_down(self): + """Set volume down.""" + return await self._volume_control.set_volume("-1") + + async def async_turn_on(self): + """Turn the device on.""" + return await self.dev.set_power(True) + + async def async_turn_off(self): + """Turn the device off.""" + return await self.dev.set_power(False) + + async def async_mute_volume(self, mute): + """Mute or unmute the device.""" + _LOGGER.debug("Set mute: %s", mute) + return await self._volume_control.set_mute(mute) + + @property + def is_volume_muted(self): + """Return whether the device is muted.""" + return self._is_muted + + @property + def supported_features(self): + """Return supported features.""" + return SUPPORT_SONGPAL diff --git a/requirements_all.txt b/requirements_all.txt index 9cb0f4b6fc2..2082da5a89c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -958,6 +958,9 @@ python-roku==3.1.5 # homeassistant.components.sensor.sochain python-sochain-api==0.0.2 +# homeassistant.components.media_player.songpal +python-songpal==0.0.6 + # homeassistant.components.sensor.synologydsm python-synology==0.1.0 From 14d052d242b2e789517c6b0381ead9f526304e6d Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 28 Feb 2018 00:16:49 +0100 Subject: [PATCH 059/191] Quote services.yaml string (#12763) --- homeassistant/components/light/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index bf769aec6fb..44e887e62c4 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -178,4 +178,4 @@ xiaomi_miio_set_delayed_turn_off: example: 'light.xiaomi_miio' time_period: description: Time period for the delayed turn off. - example: 5, '0:05', {'minutes': 5} + example: "5, '0:05', {'minutes': 5}" From efd155dd3cfaa6b9be0fe2315489d46514447508 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Feb 2018 18:02:21 -0800 Subject: [PATCH 060/191] Intent: Set light color (#12633) * Make color_name_to_rgb raise * Add Light Set Color intent * Move some methods around * Cleanup * Prevent 1 more func call * Make a generic Set intent for light * Lint * lint --- homeassistant/components/light/__init__.py | 73 +++++++++++++++- homeassistant/helpers/intent.py | 82 ++++++++++-------- homeassistant/util/color.py | 6 +- tests/components/light/test_init.py | 96 +++++++++++++++++++++- tests/components/test_init.py | 2 +- tests/util/test_color.py | 19 ++++- 6 files changed, 231 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index b7e8966394e..14e86eeb1fb 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -21,6 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import intent from homeassistant.loader import bind_hass import homeassistant.util.color as color_util @@ -135,6 +136,8 @@ PROFILE_SCHEMA = vol.Schema( vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) ) +INTENT_SET = 'HassLightSet' + _LOGGER = logging.getLogger(__name__) @@ -228,7 +231,12 @@ def preprocess_turn_on_alternatives(params): color_name = params.pop(ATTR_COLOR_NAME, None) if color_name is not None: - params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + try: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + except ValueError: + _LOGGER.warning('Got unknown color %s, falling back to white', + color_name) + params[ATTR_RGB_COLOR] = (255, 255, 255) kelvin = params.pop(ATTR_KELVIN, None) if kelvin is not None: @@ -240,6 +248,67 @@ def preprocess_turn_on_alternatives(params): params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100) +class SetIntentHandler(intent.IntentHandler): + """Handle set color intents.""" + + intent_type = INTENT_SET + slot_schema = { + vol.Required('name'): cv.string, + vol.Optional('color'): color_util.color_name_to_rgb, + vol.Optional('brightness'): vol.All(vol.Coerce(int), vol.Range(0, 100)) + } + + async def async_handle(self, intent_obj): + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots['name']['value'], + [state for state in hass.states.async_all() + if state.domain == DOMAIN]) + + service_data = { + ATTR_ENTITY_ID: state.entity_id, + } + speech_parts = [] + + if 'color' in slots: + intent.async_test_feature( + state, SUPPORT_RGB_COLOR, 'changing colors') + service_data[ATTR_RGB_COLOR] = slots['color']['value'] + # Use original passed in value of the color because we don't have + # human readable names for that internally. + speech_parts.append('the color {}'.format( + intent_obj.slots['color']['value'])) + + if 'brightness' in slots: + intent.async_test_feature( + state, SUPPORT_BRIGHTNESS, 'changing brightness') + service_data[ATTR_BRIGHTNESS_PCT] = slots['brightness']['value'] + speech_parts.append('{}% brightness'.format( + slots['brightness']['value'])) + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) + + response = intent_obj.create_response() + + if not speech_parts: # No attributes changed + speech = 'Turned on {}'.format(state.name) + else: + parts = ['Changed {} to'.format(state.name)] + for index, part in enumerate(speech_parts): + if index == 0: + parts.append(' {}'.format(part)) + elif index != len(speech_parts) - 1: + parts.append(', {}'.format(part)) + else: + parts.append(' and {}'.format(part)) + speech = ''.join(parts) + + response.async_set_speech(speech) + return response + + async def async_setup(hass, config): """Expose light control via state machine and services.""" component = EntityComponent( @@ -291,6 +360,8 @@ async def async_setup(hass, config): DOMAIN, SERVICE_TOGGLE, async_handle_light_service, schema=LIGHT_TOGGLE_SCHEMA) + hass.helpers.intent.async_register(SetIntentHandler()) + return True diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index dfbce4e82a5..26c2bca34c7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -4,6 +4,7 @@ import re import voluptuous as vol +from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -33,6 +34,8 @@ def async_register(hass, handler): if intents is None: intents = hass.data[DATA_KEY] = {} + assert handler.intent_type is not None, 'intent_type cannot be None' + if handler.intent_type in intents: _LOGGER.warning('Intent %s is being overwritten by %s.', handler.intent_type, handler) @@ -56,35 +59,59 @@ async def async_handle(hass, platform, intent_type, slots=None, result = await handler.async_handle(intent) return result except vol.Invalid as err: + _LOGGER.warning('Received invalid slot info for %s: %s', + intent_type, err) raise InvalidSlotInfo( 'Received invalid slot info for {}'.format(intent_type)) from err + except IntentHandleError: + raise except Exception as err: - raise IntentHandleError( + raise IntentUnexpectedError( 'Error handling {}'.format(intent_type)) from err class IntentError(HomeAssistantError): """Base class for intent related errors.""" - pass - class UnknownIntent(IntentError): """When the intent is not registered.""" - pass - class InvalidSlotInfo(IntentError): """When the slot data is invalid.""" - pass - class IntentHandleError(IntentError): """Error while handling intent.""" - pass + +class IntentUnexpectedError(IntentError): + """Unexpected error while handling intent.""" + + +@callback +@bind_hass +def async_match_state(hass, name, states=None): + """Find a state that matches the name.""" + if states is None: + states = hass.states.async_all() + + entity = _fuzzymatch(name, states, lambda state: state.name) + + if entity is None: + raise IntentHandleError('Unable to find entity {}'.format(name)) + + return entity + + +@callback +def async_test_feature(state, feature, feature_name): + """Test is state supports a feature.""" + if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: + raise IntentHandleError( + 'Entity {} does not support {}'.format( + state.name, feature_name)) class IntentHandler: @@ -122,16 +149,17 @@ class IntentHandler: return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) -def fuzzymatch(name, entities): +def _fuzzymatch(name, items, key): """Fuzzy matching function.""" matches = [] pattern = '.*?'.join(name) regex = re.compile(pattern, re.IGNORECASE) - for entity_id, entity_name in entities.items(): - match = regex.search(entity_name) + for item in items: + match = regex.search(key(item)) if match: - matches.append((len(match.group()), match.start(), entity_id)) - return [x for _, _, x in sorted(matches)] + matches.append((len(match.group()), match.start(), item)) + + return sorted(matches)[0][2] if matches else None class ServiceIntentHandler(IntentHandler): @@ -141,7 +169,7 @@ class ServiceIntentHandler(IntentHandler): """ slot_schema = { - 'name': cv.string, + vol.Required('name'): cv.string, } def __init__(self, intent_type, domain, service, speech): @@ -155,30 +183,14 @@ class ServiceIntentHandler(IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - response = intent_obj.create_response() + state = async_match_state(hass, slots['name']['value']) - name = slots['name']['value'] - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - - matches = fuzzymatch(name, entities) - entity_id = matches[0] if matches else None - _LOGGER.debug("%s matched entity: %s", name, entity_id) + await hass.services.async_call(self.domain, self.service, { + ATTR_ENTITY_ID: state.entity_id + }) response = intent_obj.create_response() - if not entity_id: - response.async_set_speech( - "Could not find entity id matching {}.".format(name)) - _LOGGER.error("Could not find entity id matching %s", name) - return response - - await hass.services.async_call( - self.domain, self.service, { - ATTR_ENTITY_ID: entity_id - }) - - response.async_set_speech( - self.speech.format(name)) + response.async_set_speech(self.speech.format(state.name)) return response diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 24621772050..70863a0ab90 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,12 +1,9 @@ """Color util methods.""" -import logging import math import colorsys from typing import Tuple -_LOGGER = logging.getLogger(__name__) - # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against @@ -171,8 +168,7 @@ def color_name_to_rgb(color_name): # spaces in it as well for matching purposes hex_value = COLORS.get(color_name.replace(' ', '').lower()) if not hex_value: - _LOGGER.error('unknown color supplied %s default to white', color_name) - hex_value = COLORS['white'] + raise ValueError('Unknown color') return hex_value diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index ecfe3f36761..d35321b4479 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -7,10 +7,12 @@ from homeassistant.setup import setup_component import homeassistant.loader as loader from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, - SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) + SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_SUPPORTED_FEATURES) import homeassistant.components.light as light +from homeassistant.helpers.intent import IntentHandleError -from tests.common import mock_service, get_test_home_assistant +from tests.common import ( + async_mock_service, mock_service, get_test_home_assistant) class TestLight(unittest.TestCase): @@ -302,3 +304,93 @@ class TestLight(unittest.TestCase): self.assertEqual( {light.ATTR_XY_COLOR: (.4, .6), light.ATTR_BRIGHTNESS: 100}, data) + + +async def test_intent_set_color(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello_2', 'off', { + ATTR_SUPPORTED_FEATURES: light.SUPPORT_RGB_COLOR + }) + hass.states.async_set('switch.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + result = await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + } + }) + await hass.async_block_till_done() + + assert result.speech['plain']['speech'] == \ + 'Changed hello 2 to the color blue' + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' + assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) + + +async def test_intent_set_color_tests_feature(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + try: + await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + } + }) + assert False, 'handling intent should have raised' + except IntentHandleError as err: + assert str(err) == 'Entity hello does not support changing colors' + + assert len(calls) == 0 + + +async def test_intent_set_color_and_brightness(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello_2', 'off', { + ATTR_SUPPORTED_FEATURES: ( + light.SUPPORT_RGB_COLOR | light.SUPPORT_BRIGHTNESS) + }) + hass.states.async_set('switch.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + result = await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + }, + 'brightness': { + 'value': '20' + } + }) + await hass.async_block_till_done() + + assert result.speech['plain']['speech'] == \ + 'Changed hello 2 to the color blue and 20% brightness' + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' + assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) + assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 diff --git a/tests/components/test_init.py b/tests/components/test_init.py index fff3b74c831..2005f658a71 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test lights' + assert response.speech['plain']['speech'] == 'Turned on test lights 2' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' diff --git a/tests/util/test_color.py b/tests/util/test_color.py index acdeaa72b95..86d303c23b7 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -2,6 +2,9 @@ import unittest import homeassistant.util.color as color_util +import pytest +import voluptuous as vol + class TestColorUtil(unittest.TestCase): """Test color util methods.""" @@ -150,10 +153,10 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((72, 61, 139), color_util.color_name_to_rgb('darkslate blue')) - def test_color_name_to_rgb_unknown_name_default_white(self): + def test_color_name_to_rgb_unknown_name_raises_value_error(self): """Test color_name_to_rgb.""" - self.assertEqual((255, 255, 255), - color_util.color_name_to_rgb('not a color')) + with pytest.raises(ValueError): + color_util.color_name_to_rgb('not a color') def test_color_rgb_to_rgbw(self): """Test color_rgb_to_rgbw.""" @@ -280,3 +283,13 @@ class ColorTemperatureToRGB(unittest.TestCase): rgb = color_util.color_temperature_to_rgb(6500) self.assertGreater(rgb[0], rgb[1]) self.assertGreater(rgb[0], rgb[2]) + + +def test_get_color_in_voluptuous(): + """Test using the get method in color validation.""" + schema = vol.Schema(color_util.color_name_to_rgb) + + with pytest.raises(vol.Invalid): + schema('not a color') + + assert schema('red') == (255, 0, 0) From f19b9348695ac405a7ecf81fb73fe6e909521e69 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 28 Feb 2018 03:04:00 +0100 Subject: [PATCH 061/191] Silence harmless sonos data structure warnings (#12767) --- homeassistant/components/media_player/sonos.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index d9236ae9a54..b86e69249b7 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -32,6 +32,7 @@ _LOGGER = logging.getLogger(__name__) # Quiet down soco logging to just actual problems. logging.getLogger('soco').setLevel(logging.WARNING) +logging.getLogger('soco.data_structures_entry').setLevel(logging.ERROR) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ From ab74ac8eca7b190847bd93fe10120159cb0b7fff Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Tue, 27 Feb 2018 21:04:30 -0500 Subject: [PATCH 062/191] bump fedex version (#12764) --- homeassistant/components/sensor/fedex.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 0c42ef28496..f86de1d865c 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_date import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fedexdeliverymanager==1.0.5'] +REQUIREMENTS = ['fedexdeliverymanager==1.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2082da5a89c..3d53a2edc6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -268,7 +268,7 @@ evohomeclient==0.2.5 fastdotcom==0.0.3 # homeassistant.components.sensor.fedex -fedexdeliverymanager==1.0.5 +fedexdeliverymanager==1.0.6 # homeassistant.components.feedreader # homeassistant.components.sensor.geo_rss_events From a63714dc868646b10479d8750071c10af52efbf1 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 28 Feb 2018 03:04:55 +0100 Subject: [PATCH 063/191] Revert optimized logbook SQL (#12762) --- homeassistant/components/logbook.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 1fc6d1587fc..e6e447884cb 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -47,11 +47,6 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) -ALL_EVENT_TYPES = [ - EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -] - GROUP_BY_MINUTES = 15 CONTINUOUS_DOMAINS = ['proximity', 'sensor'] @@ -271,18 +266,15 @@ def humanify(events): def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events, States + from homeassistant.components.recorder.models import Events from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by(Events.time_fired) \ - .outerjoin(States, (Events.event_id == States.event_id)) \ - .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ - .filter((Events.time_fired > start_day) - & (Events.time_fired < end_day)) \ - .filter((States.last_updated == States.last_changed) - | (States.last_updated.is_(None))) + query = session.query(Events).order_by( + Events.time_fired).filter( + (Events.time_fired > start_day) & + (Events.time_fired < end_day)) events = execute(query) return humanify(_exclude_events(events, config)) From bba1e2adc9d237e20e58106ab9d09a0c857f87eb Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Wed, 28 Feb 2018 03:05:38 +0100 Subject: [PATCH 064/191] updated to bimmer_connected 0.4.1 (#12759) fixes https://github.com/home-assistant/home-assistant/issues/12698 --- homeassistant/components/bmw_connected_drive.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive.py index 98c25df79f6..86048a56e22 100644 --- a/homeassistant/components/bmw_connected_drive.py +++ b/homeassistant/components/bmw_connected_drive.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD ) -REQUIREMENTS = ['bimmer_connected==0.3.0'] +REQUIREMENTS = ['bimmer_connected==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3d53a2edc6c..96a06467edf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -136,7 +136,7 @@ beautifulsoup4==4.6.0 bellows==0.5.1 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.3.0 +bimmer_connected==0.4.1 # homeassistant.components.blink blinkpy==0.6.0 From c5157c1027b0c47db234432f01b1cccb2eae8a03 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 27 Feb 2018 19:06:45 -0700 Subject: [PATCH 065/191] Update Yi platform to make use of async/await (#12713) --- homeassistant/components/camera/yi.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index 8e41429baea..41fe816c479 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -38,8 +38,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): """Set up a Yi Camera.""" _LOGGER.debug('Received configuration: %s', config) async_add_devices([YiCamera(hass, config)], True) @@ -107,31 +109,29 @@ class YiCamera(Camera): self.user, self.passwd, self.host, self.port, self.path, latest_dir, videos[-1]) - @asyncio.coroutine - def async_camera_image(self): + async def async_camera_image(self): """Return a still image response from the camera.""" from haffmpeg import ImageFrame, IMAGE_JPEG - url = yield from self.hass.async_add_job(self.get_latest_video_url) + url = await self.hass.async_add_job(self.get_latest_video_url) if url != self._last_url: ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - self._last_image = yield from asyncio.shield(ffmpeg.get_image( + self._last_image = await asyncio.shield(ffmpeg.get_image( url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments), loop=self.hass.loop) self._last_url = url return self._last_image - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) - yield from stream.open_camera( + await stream.open_camera( self._last_url, extra_cmd=self._extra_arguments) - yield from async_aiohttp_proxy_stream( + await async_aiohttp_proxy_stream( self.hass, request, stream, 'multipart/x-mixed-replace;boundary=ffserver') - yield from stream.close() + await stream.close() From f5c633415d6b04afd937943920091a5517b9f15e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Feb 2018 20:48:34 -0800 Subject: [PATCH 066/191] Bump frontend to 20180228.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3863a4d390b..4a9e8c9218a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180227.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180228.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 96a06467edf..24c469dde24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -349,7 +349,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180227.0 +home-assistant-frontend==20180228.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5cc8da8367..89347c0ec18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180227.0 +home-assistant-frontend==20180228.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From f6c504610b0804a69bbf5e7b78be2a6478d72986 Mon Sep 17 00:00:00 2001 From: James Marsh Date: Wed, 28 Feb 2018 06:16:31 +0000 Subject: [PATCH 067/191] Add custom header support for rest_command (#12646) * Add support for specifying custom headers for rest_command. * Added headers configuration to behave similarly to the rest sensor. * Replaced test_rest_command_content_type which only validated the configuration with test_rest_command_headers which tests several combinations of parameters that affect the request headers. --- homeassistant/components/rest_command.py | 10 ++- homeassistant/components/sensor/rest.py | 2 +- tests/components/test_rest_command.py | 85 ++++++++++++++++++++---- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/rest_command.py b/homeassistant/components/rest_command.py index 026f0e9a19b..4632315b757 100644 --- a/homeassistant/components/rest_command.py +++ b/homeassistant/components/rest_command.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_TIMEOUT, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_PAYLOAD, - CONF_METHOD) + CONF_METHOD, CONF_HEADERS) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -38,6 +38,7 @@ COMMAND_SCHEMA = vol.Schema({ vol.Required(CONF_URL): cv.template, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, vol.Optional(CONF_PAYLOAD): cv.template, @@ -77,9 +78,14 @@ def async_setup(hass, config): template_payload.hass = hass headers = None + if CONF_HEADERS in command_config: + headers = command_config[CONF_HEADERS] + if CONF_CONTENT_TYPE in command_config: content_type = command_config[CONF_CONTENT_TYPE] - headers = {hdrs.CONTENT_TYPE: content_type} + if headers is None: + headers = {} + headers[hdrs.CONTENT_TYPE] = content_type @asyncio.coroutine def async_service_handler(service): diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 19f5a1c271e..c295dcf16dc 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, vol.Optional(CONF_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), - vol.Optional(CONF_HEADERS): {cv.string: cv.string}, + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, diff --git a/tests/components/test_rest_command.py b/tests/components/test_rest_command.py index 9dbea53cd64..3ddcfae8c01 100644 --- a/tests/components/test_rest_command.py +++ b/tests/components/test_rest_command.py @@ -222,21 +222,82 @@ class TestRestCommandComponent(object): assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == b'data' - def test_rest_command_content_type(self, aioclient_mock): - """Call a rest command with a content type.""" - data = { - 'payload': 'item', - 'content_type': 'text/plain' + def test_rest_command_headers(self, aioclient_mock): + """Call a rest command with custom headers and content types.""" + header_config_variations = { + rc.DOMAIN: { + 'no_headers_test': {}, + 'content_type_test': { + 'content_type': 'text/plain' + }, + 'headers_test': { + 'headers': { + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0' + } + }, + 'headers_and_content_type_test': { + 'headers': { + 'Accept': 'application/json' + }, + 'content_type': 'text/plain' + }, + 'headers_and_content_type_override_test': { + 'headers': { + 'Accept': 'application/json', + aiohttp.hdrs.CONTENT_TYPE: 'application/pdf' + }, + 'content_type': 'text/plain' + } + } } - self.config[rc.DOMAIN]['post_test'].update(data) - with assert_setup_component(4): - setup_component(self.hass, rc.DOMAIN, self.config) + # add common parameters + for variation in header_config_variations[rc.DOMAIN].values(): + variation.update({'url': self.url, 'method': 'post', + 'payload': 'test data'}) + with assert_setup_component(5): + setup_component(self.hass, rc.DOMAIN, header_config_variations) + + # provide post request data aioclient_mock.post(self.url, content=b'success') - self.hass.services.call(rc.DOMAIN, 'post_test', {}) - self.hass.block_till_done() + for test_service in ['no_headers_test', + 'content_type_test', + 'headers_test', + 'headers_and_content_type_test', + 'headers_and_content_type_override_test']: + self.hass.services.call(rc.DOMAIN, test_service, {}) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b'item' + self.hass.block_till_done() + assert len(aioclient_mock.mock_calls) == 5 + + # no_headers_test + assert aioclient_mock.mock_calls[0][3] is None + + # content_type_test + assert len(aioclient_mock.mock_calls[1][3]) == 1 + assert aioclient_mock.mock_calls[1][3].get( + aiohttp.hdrs.CONTENT_TYPE) == 'text/plain' + + # headers_test + assert len(aioclient_mock.mock_calls[2][3]) == 2 + assert aioclient_mock.mock_calls[2][3].get( + 'Accept') == 'application/json' + assert aioclient_mock.mock_calls[2][3].get( + 'User-Agent') == 'Mozilla/5.0' + + # headers_and_content_type_test + assert len(aioclient_mock.mock_calls[3][3]) == 2 + assert aioclient_mock.mock_calls[3][3].get( + aiohttp.hdrs.CONTENT_TYPE) == 'text/plain' + assert aioclient_mock.mock_calls[3][3].get( + 'Accept') == 'application/json' + + # headers_and_content_type_override_test + assert len(aioclient_mock.mock_calls[4][3]) == 2 + assert aioclient_mock.mock_calls[4][3].get( + aiohttp.hdrs.CONTENT_TYPE) == 'text/plain' + assert aioclient_mock.mock_calls[4][3].get( + 'Accept') == 'application/json' From 9658f4383cd391145b4fb23f135437345eb3abba Mon Sep 17 00:00:00 2001 From: uchagani Date: Wed, 28 Feb 2018 01:37:12 -0500 Subject: [PATCH 068/191] Update samsungctl library to latest version (#12769) * update samsungctl library to latest version * add websocket dependency --- homeassistant/components/media_player/samsungtv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 4afd578211e..0b7fc3c078e 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -23,7 +23,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util -REQUIREMENTS = ['samsungctl==0.6.0', 'wakeonlan==1.0.0'] +REQUIREMENTS = ['samsungctl[websocket]==0.7.1', 'wakeonlan==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 24c469dde24..b3f5ee7508a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1079,7 +1079,7 @@ russound_rio==0.1.4 rxv==0.5.1 # homeassistant.components.media_player.samsungtv -samsungctl==0.6.0 +samsungctl[websocket]==0.7.1 # homeassistant.components.satel_integra satel_integra==0.1.0 From e82b358831ff1e2b5f631f724b47edfc2e14ec4b Mon Sep 17 00:00:00 2001 From: Philip Rosenberg-Watt Date: Wed, 28 Feb 2018 11:59:47 -0700 Subject: [PATCH 069/191] Round humidity for display purposes (#12766) Humidity was not being rounded as temperature was. This change fixes that. --- homeassistant/components/weather/__init__.py | 2 +- homeassistant/components/weather/darksky.py | 2 +- homeassistant/helpers/temperature.py | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index acb95c17814..b200d634ba9 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -110,7 +110,7 @@ class WeatherEntity(Entity): ATTR_WEATHER_TEMPERATURE: show_temp( self.hass, self.temperature, self.temperature_unit, self.precision), - ATTR_WEATHER_HUMIDITY: self.humidity, + ATTR_WEATHER_HUMIDITY: round(self.humidity) } ozone = self.ozone diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index 139f8abfce6..21f67ce080a 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -96,7 +96,7 @@ class DarkSkyWeather(WeatherEntity): @property def humidity(self): """Return the humidity.""" - return round(self._ds_currently.get('humidity') * 100.0, 2) + return self._ds_currently.get('humidity') * 100.0 @property def wind_speed(self): diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index a4626c33210..e4c985d5cfb 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -3,11 +3,12 @@ from numbers import Number from homeassistant.core import HomeAssistant from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS def display_temp(hass: HomeAssistant, temperature: float, unit: str, precision: float) -> float: - """Convert temperature into preferred units for display purposes.""" + """Convert temperature into preferred units/precision for display.""" temperature_unit = unit ha_unit = hass.config.units.temperature_unit @@ -25,9 +26,12 @@ def display_temp(hass: HomeAssistant, temperature: float, unit: str, temperature, temperature_unit, ha_unit) # Round in the units appropriate - if precision == 0.5: - return round(temperature * 2) / 2.0 - elif precision == 0.1: - return round(temperature, 1) + if precision == PRECISION_HALVES: + temperature = round(temperature * 2) / 2.0 + elif precision == PRECISION_TENTHS: + temperature = round(temperature, 1) # Integer as a fall back (PRECISION_WHOLE) - return round(temperature) + else: + temperature = round(temperature) + + return temperature From b556b8630187caf7cc73a6c4d355003a1acda542 Mon Sep 17 00:00:00 2001 From: Bas Schipper Date: Wed, 28 Feb 2018 22:07:23 +0100 Subject: [PATCH 070/191] Fixed missing optional keyerror data_bits (#12789) --- homeassistant/components/binary_sensor/rfxtrx.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index aedfc3364db..8c026131fd3 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -55,13 +55,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if device_id in rfxtrx.RFX_DEVICES: continue - if entity[CONF_DATA_BITS] is not None: + if entity.get(CONF_DATA_BITS) is not None: _LOGGER.debug( "Masked device id: %s", rfxtrx.get_pt2262_deviceid( - device_id, entity[CONF_DATA_BITS])) + device_id, entity.get(CONF_DATA_BITS))) _LOGGER.debug("Add %s rfxtrx.binary_sensor (class %s)", - entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) + entity[ATTR_NAME], entity.get(CONF_DEVICE_CLASS)) device = RfxtrxBinarySensor( event, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS), From a0eca9c6d13b4ab3920a649dbfc773eba32e6ffa Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 28 Feb 2018 14:09:57 -0700 Subject: [PATCH 071/191] Fixed Pollen.com bugs with ZIP codes and invalid API responses (#12790) --- homeassistant/components/sensor/pollen.py | 25 ++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 0771e7cbd2e..98252eb6f06 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -107,7 +107,7 @@ RATING_MAPPING = [{ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ZIP_CODE): cv.positive_int, + vol.Required(CONF_ZIP_CODE): cv.string, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), }) @@ -209,11 +209,16 @@ class AllergyAverageSensor(BaseSensor): """Update the status of the sensor.""" self.data.update() - data_attr = getattr(self.data, self._data_params['data_attr']) - indices = [ - p['Index'] - for p in data_attr['Location']['periods'] - ] + try: + data_attr = getattr(self.data, self._data_params['data_attr']) + indices = [ + p['Index'] + for p in data_attr['Location']['periods'] + ] + except KeyError: + _LOGGER.error("Pollen.com API didn't return any data") + return + average = round(mean(indices), 1) self._attrs[ATTR_TREND] = calculate_trend(indices) @@ -238,7 +243,12 @@ class AllergyIndexSensor(BaseSensor): """Update the status of the sensor.""" self.data.update() - location_data = self.data.current_data['Location'] + try: + location_data = self.data.current_data['Location'] + except KeyError: + _LOGGER.error("Pollen.com API didn't return any data") + return + [period] = [ p for p in location_data['periods'] if p['Type'] == self._data_params['key'] @@ -276,6 +286,7 @@ class DataBase(object): """Get data from a particular point in the API.""" from pypollencom.exceptions import HTTPError + data = {} try: data = getattr(getattr(self._client, module), operation)() _LOGGER.debug('Received "%s_%s" data: %s', module, From 222748dfbfabd9b0eb61b31028eb9f90d67c0a41 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 28 Feb 2018 22:15:45 +0100 Subject: [PATCH 072/191] Xiaomi MiIO Vacuum: Use a unique data key per domain (#12743) * Use a unique data key per domain. * Tests fixed. --- homeassistant/components/vacuum/xiaomi_miio.py | 12 ++++++------ tests/components/vacuum/test_xiaomi_miio.py | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 55f166c4004..1d4ab5eb7ca 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' ICON = 'mdi:roomba' -PLATFORM = 'xiaomi_miio' +DATA_KEY = 'vacuum.xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -88,8 +88,8 @@ SUPPORT_XIAOMI = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Xiaomi vacuum cleaner robot platform.""" from miio import Vacuum - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -100,7 +100,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): vacuum = Vacuum(host, token) mirobo = MiroboVacuum(name, vacuum) - hass.data[PLATFORM][host] = mirobo + hass.data[DATA_KEY][host] = mirobo async_add_devices([mirobo], update_before_add=True) @@ -112,10 +112,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if key != ATTR_ENTITY_ID} entity_ids = service.data.get(ATTR_ENTITY_ID) if entity_ids: - target_vacuums = [vac for vac in hass.data[PLATFORM].values() + target_vacuums = [vac for vac in hass.data[DATA_KEY].values() if vac.entity_id in entity_ids] else: - target_vacuums = hass.data[PLATFORM].values() + target_vacuums = hass.data[DATA_KEY].values() update_tasks = [] for vacuum in target_vacuums: diff --git a/tests/components/vacuum/test_xiaomi_miio.py b/tests/components/vacuum/test_xiaomi_miio.py index a4bf9f60dac..c4c1fb0e1b4 100644 --- a/tests/components/vacuum/test_xiaomi_miio.py +++ b/tests/components/vacuum/test_xiaomi_miio.py @@ -16,7 +16,7 @@ from homeassistant.components.vacuum.xiaomi_miio import ( ATTR_DO_NOT_DISTURB_START, ATTR_DO_NOT_DISTURB_END, ATTR_ERROR, ATTR_MAIN_BRUSH_LEFT, ATTR_SIDE_BRUSH_LEFT, ATTR_FILTER_LEFT, ATTR_CLEANING_COUNT, ATTR_CLEANED_TOTAL_AREA, ATTR_CLEANING_TOTAL_TIME, - CONF_HOST, CONF_NAME, CONF_TOKEN, PLATFORM, + CONF_HOST, CONF_NAME, CONF_TOKEN, SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP, SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL) from homeassistant.const import ( @@ -24,6 +24,8 @@ from homeassistant.const import ( STATE_ON) from homeassistant.setup import async_setup_component +PLATFORM = 'xiaomi_miio' + # calls made when device status is requested status_calls = [mock.call.Vacuum().status(), mock.call.Vacuum().consumable_status(), From bbd58d7357659fc62ac8ee76a20e310d7cadce3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 28 Feb 2018 23:17:12 +0200 Subject: [PATCH 073/191] Add UpCloud platform (#12011) * Add UpCloud platform * Update upcloud-api to 0.4.1 * Update upcloud-api to 0.4.2 * Convert UpCloud to use Entity, helpers.dispatcher * Lint --- .coveragerc | 3 + .../components/binary_sensor/upcloud.py | 42 +++++ homeassistant/components/switch/upcloud.py | 52 ++++++ homeassistant/components/upcloud.py | 170 ++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 270 insertions(+) create mode 100644 homeassistant/components/binary_sensor/upcloud.py create mode 100644 homeassistant/components/switch/upcloud.py create mode 100644 homeassistant/components/upcloud.py diff --git a/.coveragerc b/.coveragerc index 8cc8077312a..71e0f0471f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -244,6 +244,9 @@ omit = homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twilio_call.py + homeassistant/components/upcloud.py + homeassistant/components/*/upcloud.py + homeassistant/components/usps.py homeassistant/components/*/usps.py diff --git a/homeassistant/components/binary_sensor/upcloud.py b/homeassistant/components/binary_sensor/upcloud.py new file mode 100644 index 00000000000..dd76231ad75 --- /dev/null +++ b/homeassistant/components/binary_sensor/upcloud.py @@ -0,0 +1,42 @@ +""" +Support for monitoring the state of UpCloud servers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.upcloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.upcloud import ( + UpCloudServerEntity, CONF_SERVERS, DATA_UPCLOUD) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['upcloud'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the UpCloud server binary sensor.""" + upcloud = hass.data[DATA_UPCLOUD] + + servers = config.get(CONF_SERVERS) + + devices = [UpCloudBinarySensor(upcloud, uuid) for uuid in servers] + + add_devices(devices, True) + + +class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice): + """Representation of an UpCloud server sensor.""" + + def __init__(self, upcloud, uuid): + """Initialize a new UpCloud sensor.""" + UpCloudServerEntity.__init__(self, upcloud, uuid) diff --git a/homeassistant/components/switch/upcloud.py b/homeassistant/components/switch/upcloud.py new file mode 100644 index 00000000000..32d47670429 --- /dev/null +++ b/homeassistant/components/switch/upcloud.py @@ -0,0 +1,52 @@ +""" +Support for interacting with UpCloud servers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.upcloud/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import STATE_OFF +import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.upcloud import ( + UpCloudServerEntity, CONF_SERVERS, DATA_UPCLOUD) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['upcloud'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the UpCloud server switch.""" + upcloud = hass.data[DATA_UPCLOUD] + + servers = config.get(CONF_SERVERS) + + devices = [UpCloudSwitch(upcloud, uuid) for uuid in servers] + + add_devices(devices, True) + + +class UpCloudSwitch(UpCloudServerEntity, SwitchDevice): + """Representation of an UpCloud server switch.""" + + def __init__(self, upcloud, uuid): + """Initialize a new UpCloud server switch.""" + UpCloudServerEntity.__init__(self, upcloud, uuid) + + def turn_on(self, **kwargs): + """Start the server.""" + if self.state == STATE_OFF: + self.data.start() + + def turn_off(self, **kwargs): + """Stop the server.""" + if self.is_on: + self.data.stop() diff --git a/homeassistant/components/upcloud.py b/homeassistant/components/upcloud.py new file mode 100644 index 00000000000..e653e55b93b --- /dev/null +++ b/homeassistant/components/upcloud.py @@ -0,0 +1,170 @@ +""" +Support for UpCloud. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/upcloud/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, + STATE_ON, STATE_OFF, STATE_PROBLEM, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['upcloud-api==0.4.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CORE_NUMBER = 'core_number' +ATTR_HOSTNAME = 'hostname' +ATTR_MEMORY_AMOUNT = 'memory_amount' +ATTR_STATE = 'state' +ATTR_TITLE = 'title' +ATTR_UUID = 'uuid' +ATTR_ZONE = 'zone' + +CONF_SERVERS = 'servers' + +DATA_UPCLOUD = 'data_upcloud' +DOMAIN = 'upcloud' + +DEFAULT_COMPONENT_NAME = 'UpCloud {}' +DEFAULT_COMPONENT_DEVICE_CLASS = 'power' + +UPCLOUD_PLATFORMS = ['binary_sensor', 'switch'] + +SCAN_INTERVAL = timedelta(seconds=60) + +SIGNAL_UPDATE_UPCLOUD = "upcloud_update" + +STATE_MAP = { + "started": STATE_ON, + "stopped": STATE_OFF, + "error": STATE_PROBLEM, +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the UpCloud component.""" + import upcloud_api + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + manager = upcloud_api.CloudManager(username, password) + + try: + manager.authenticate() + hass.data[DATA_UPCLOUD] = UpCloud(manager) + except upcloud_api.UpCloudAPIError: + _LOGGER.error("Authentication failed.") + return False + + def upcloud_update(event_time): + """Call UpCloud to update information.""" + _LOGGER.debug("Updating UpCloud component") + hass.data[DATA_UPCLOUD].update() + dispatcher_send(hass, SIGNAL_UPDATE_UPCLOUD) + + # Call the UpCloud API to refresh data + track_time_interval(hass, upcloud_update, scan_interval) + + return True + + +class UpCloud(object): + """Handle all communication with the UpCloud API.""" + + def __init__(self, manager): + """Initialize the UpCloud connection.""" + self.data = {} + self.manager = manager + + def update(self): + """Update data from UpCloud API.""" + self.data = { + server.uuid: server for server in self.manager.get_servers() + } + + +class UpCloudServerEntity(Entity): + """Entity class for UpCloud servers.""" + + def __init__(self, upcloud, uuid): + """Initialize the UpCloud server entity.""" + self._upcloud = upcloud + self.uuid = uuid + self.data = None + + @property + def name(self): + """Return the name of the component.""" + try: + return DEFAULT_COMPONENT_NAME.format(self.data.title) + except (AttributeError, KeyError, TypeError): + return DEFAULT_COMPONENT_NAME.format(self.uuid) + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_UPCLOUD, self._update_callback) + + def _update_callback(self): + """Call update method.""" + self.schedule_update_ha_state(True) + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def state(self): + """Return state of the server.""" + try: + return STATE_MAP.get(self.data.state, STATE_UNKNOWN) + except AttributeError: + return STATE_UNKNOWN + + @property + def is_on(self): + """Return true if the server is on.""" + return self.state == STATE_ON + + @property + def device_class(self): + """Return the class of this server.""" + return DEFAULT_COMPONENT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the UpCloud server.""" + return { + x: getattr(self.data, x, None) + for x in (ATTR_UUID, ATTR_TITLE, ATTR_HOSTNAME, ATTR_ZONE, + ATTR_STATE, ATTR_CORE_NUMBER, ATTR_MEMORY_AMOUNT) + } + + def update(self): + """Update data of the UpCloud server.""" + self.data = self._upcloud.data.get(self.uuid) diff --git a/requirements_all.txt b/requirements_all.txt index b3f5ee7508a..f6f8472776b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1207,6 +1207,9 @@ twilio==5.7.0 # homeassistant.components.sensor.uber uber_rides==0.6.0 +# homeassistant.components.upcloud +upcloud-api==0.4.2 + # homeassistant.components.sensor.ups upsmychoice==1.0.6 From 44cad7df30b7c3b803c21aeb46cd7445cc52adc1 Mon Sep 17 00:00:00 2001 From: Bertbert <7685189+bertbert72@users.noreply.github.com> Date: Wed, 28 Feb 2018 21:18:50 +0000 Subject: [PATCH 074/191] Add Unit System Option For Fitbit (#11817) * Add Unit System Option For Fitbit * Update fitbit.py * Update fitbit.py --- homeassistant/components/sensor/fitbit.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 6ea18d318b8..8d64a8d8229 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -15,6 +15,7 @@ from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import CONF_UNIT_SYSTEM from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level import homeassistant.helpers.config_validation as cv @@ -144,7 +145,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), vol.Optional(CONF_CLOCK_FORMAT, default='24H'): - vol.In(['12H', '24H']) + vol.In(['12H', '24H']), + vol.Optional(CONF_UNIT_SYSTEM, default='default'): + vol.In(['en_GB', 'en_US', 'metric', 'default']) }) @@ -248,12 +251,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() - authd_client.system = authd_client.user_profile_get()["user"]["locale"] - if authd_client.system != 'en_GB': - if hass.config.units.is_metric: - authd_client.system = 'metric' - else: - authd_client.system = 'en_US' + unit_system = config.get(CONF_UNIT_SYSTEM) + if unit_system == 'default': + authd_client.system = authd_client. \ + user_profile_get()["user"]["locale"] + if authd_client.system != 'en_GB': + if hass.config.units.is_metric: + authd_client.system = 'metric' + else: + authd_client.system = 'en_US' + else: + authd_client.system = unit_system dev = [] registered_devs = authd_client.get_devices() From 3628fcf083cce9f3a6af23d1ab40d67d5bdafdcd Mon Sep 17 00:00:00 2001 From: Sean Wilson Date: Wed, 28 Feb 2018 16:26:25 -0500 Subject: [PATCH 075/191] Add 'lock' device class (#11640) * Add 'lock' device class * Invert lock settings as per https://github.com/home-assistant/home-assistant/pull/11640 --- homeassistant/components/binary_sensor/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 919abc678e4..ad475be76ca 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -28,6 +28,7 @@ DEVICE_CLASSES = [ 'gas', # On means gas detected, Off means no gas (clear) 'heat', # On means hot, Off means normal 'light', # On means light detected, Off means no light + 'lock', # On means open (unlocked), Off means closed (locked) 'moisture', # On means wet, Off means dry 'motion', # On means motion detected, Off means no motion (clear) 'moving', # On means moving, Off means not moving (stopped) From e2a2fe36fcbdf4938ed81f8dff61db376b594448 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Feb 2018 13:26:49 -0800 Subject: [PATCH 076/191] Bump frontend to 20180228.1 (#12786) * Bump frontend to 20180228.1 * Update reqs --- homeassistant/components/frontend/__init__.py | 26 +++---------------- requirements_all.txt | 5 +--- requirements_test_all.txt | 2 +- 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 4a9e8c9218a..e2b98c3e59c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180228.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180228.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -553,6 +553,8 @@ def _is_latest(js_option, request): Set according to user's preference and URL override. """ + import hass_frontend + if request is None: return js_option == 'latest' @@ -573,25 +575,5 @@ def _is_latest(js_option, request): return js_option == 'latest' useragent = request.headers.get('User-Agent') - if not useragent: - return False - from user_agents import parse - useragent = parse(useragent) - - # on iOS every browser is a Safari which we support from version 11. - if useragent.os.family == 'iOS': - # Was >= 10, temp setting it to 12 to work around issue #11387 - return useragent.os.version[0] >= 12 - - family_min_version = { - 'Chrome': 54, # Object.values - 'Chrome Mobile': 54, - 'Firefox': 47, # Object.values - 'Firefox Mobile': 47, - 'Opera': 41, # Object.values - 'Edge': 14, # Array.prototype.includes added in 14 - 'Safari': 10, # Many features not supported by 9 - } - version = family_min_version.get(useragent.browser.family) - return version and useragent.browser.version[0] >= version + return useragent and hass_frontend.version(useragent) diff --git a/requirements_all.txt b/requirements_all.txt index f6f8472776b..b20d8d66a97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -349,7 +349,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180228.0 +home-assistant-frontend==20180228.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -1213,9 +1213,6 @@ upcloud-api==0.4.2 # homeassistant.components.sensor.ups upsmychoice==1.0.6 -# homeassistant.components.frontend -user-agents==1.1.0 - # homeassistant.components.camera.uvc uvcclient==0.10.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89347c0ec18..da1a598a08a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180228.0 +home-assistant-frontend==20180228.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From f7e9215f5e12343986ea198c232026d307a7e4b6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Feb 2018 13:39:01 -0800 Subject: [PATCH 077/191] Fix when 2 states match with same name (#12771) --- homeassistant/helpers/intent.py | 13 +++++++------ tests/helpers/test_intent.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 tests/helpers/test_intent.py diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 26c2bca34c7..dac0f4c507b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -97,12 +97,12 @@ def async_match_state(hass, name, states=None): if states is None: states = hass.states.async_all() - entity = _fuzzymatch(name, states, lambda state: state.name) + state = _fuzzymatch(name, states, lambda state: state.name) - if entity is None: + if state is None: raise IntentHandleError('Unable to find entity {}'.format(name)) - return entity + return state @callback @@ -154,12 +154,13 @@ def _fuzzymatch(name, items, key): matches = [] pattern = '.*?'.join(name) regex = re.compile(pattern, re.IGNORECASE) - for item in items: + for idx, item in enumerate(items): match = regex.search(key(item)) if match: - matches.append((len(match.group()), match.start(), item)) + # Add index so we pick first match in case same group and start + matches.append((len(match.group()), match.start(), idx, item)) - return sorted(matches)[0][2] if matches else None + return sorted(matches)[0][3] if matches else None class ServiceIntentHandler(IntentHandler): diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py new file mode 100644 index 00000000000..a8d37a249bc --- /dev/null +++ b/tests/helpers/test_intent.py @@ -0,0 +1,12 @@ +"""Tests for the intent helpers.""" +from homeassistant.core import State +from homeassistant.helpers import intent + + +def test_async_match_state(): + """Test async_match_state helper.""" + state1 = State('light.kitchen', 'on') + state2 = State('switch.kitchen', 'on') + + state = intent.async_match_state(None, 'kitch', [state1, state2]) + assert state is state1 From c1aaef28a9b84ad93d7face467a679861043d166 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 28 Feb 2018 22:59:14 +0100 Subject: [PATCH 078/191] MQTT Static Typing (#12433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MQTT Typing * Tiny style change * Fixes I should've probably really sticked to limiting myself to static typing... * Small fix 😩 Ok, this seriously shouldn't have happened. --- homeassistant/components/mqtt/__init__.py | 207 +++++++++++++--------- homeassistant/helpers/typing.py | 1 + 2 files changed, 127 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8a5fdb5b86b..63662d2072d 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,9 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ import asyncio -from collections import namedtuple from itertools import groupby -from typing import Optional +from typing import Optional, Any, Union, Callable, List, cast # noqa: F401 from operator import attrgetter import logging import os @@ -16,15 +15,17 @@ import time import ssl import re import requests.certs +import attr import voluptuous as vol -from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType, ConfigType, \ + ServiceDataType +from homeassistant.core import callback, Event, ServiceCall from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.helpers import template, ConfigType, config_validation as cv +from homeassistant.helpers import template, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) @@ -89,7 +90,7 @@ ATTR_RETAIN = CONF_RETAIN MAX_RECONNECT_WAIT = 300 # seconds -def valid_subscribe_topic(value, invalid_chars='\0'): +def valid_subscribe_topic(value: Any, invalid_chars='\0') -> str: """Validate that we can subscribe using this MQTT topic.""" value = cv.string(value) if all(c not in value for c in invalid_chars): @@ -97,12 +98,12 @@ def valid_subscribe_topic(value, invalid_chars='\0'): raise vol.Invalid('Invalid MQTT topic name') -def valid_publish_topic(value): +def valid_publish_topic(value: Any) -> str: """Validate that we can publish using this MQTT topic.""" return valid_subscribe_topic(value, invalid_chars='#+\0') -def valid_discovery_topic(value): +def valid_discovery_topic(value: Any) -> str: """Validate a discovery topic.""" return valid_subscribe_topic(value, invalid_chars='#+\0/') @@ -185,7 +186,13 @@ MQTT_PUBLISH_SCHEMA = vol.Schema({ }, required=True) -def _build_publish_data(topic, qos, retain): +# pylint: disable=invalid-name +PublishPayloadType = Union[str, bytes, int, float, None] +SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None +MessageCallbackType = Callable[[str, SubscribePayloadType, int], None] + + +def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: """Build the arguments for the publish service without the payload.""" data = {ATTR_TOPIC: topic} if qos is not None: @@ -196,14 +203,16 @@ def _build_publish_data(topic, qos, retain): @bind_hass -def publish(hass, topic, payload, qos=None, retain=None): +def publish(hass: HomeAssistantType, topic, payload, qos=None, + retain=None) -> None: """Publish message to an MQTT topic.""" hass.add_job(async_publish, hass, topic, payload, qos, retain) @callback @bind_hass -def async_publish(hass, topic, payload, qos=None, retain=None): +def async_publish(hass: HomeAssistantType, topic: Any, payload, qos=None, + retain=None) -> None: """Publish message to an MQTT topic.""" data = _build_publish_data(topic, qos, retain) data[ATTR_PAYLOAD] = payload @@ -211,7 +220,8 @@ def async_publish(hass, topic, payload, qos=None, retain=None): @bind_hass -def publish_template(hass, topic, payload_template, qos=None, retain=None): +def publish_template(hass: HomeAssistantType, topic, payload_template, + qos=None, retain=None) -> None: """Publish message to an MQTT topic using a template payload.""" data = _build_publish_data(topic, qos, retain) data[ATTR_PAYLOAD_TEMPLATE] = payload_template @@ -220,21 +230,23 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None): @asyncio.coroutine @bind_hass -def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, - encoding='utf-8'): +def async_subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, + qos: int = DEFAULT_QOS, + encoding: str = 'utf-8'): """Subscribe to an MQTT topic. Call the return value to unsubscribe. """ - async_remove = \ - yield from hass.data[DATA_MQTT].async_subscribe(topic, msg_callback, - qos, encoding) + async_remove = yield from hass.data[DATA_MQTT].async_subscribe( + topic, msg_callback, qos, encoding) return async_remove @bind_hass -def subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, - encoding='utf-8'): +def subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, qos: int = DEFAULT_QOS, + encoding: str = 'utf-8') -> Callable[[], None]: """Subscribe to an MQTT topic.""" async_remove = run_coroutine_threadsafe( async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop @@ -248,12 +260,13 @@ def subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, @asyncio.coroutine -def _async_setup_server(hass, config): +def _async_setup_server(hass: HomeAssistantType, + config: ConfigType): """Try to start embedded MQTT broker. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) + conf = config.get(DOMAIN, {}) # type: ConfigType server = yield from async_prepare_setup_platform( hass, config, DOMAIN, 'server') @@ -265,26 +278,29 @@ def _async_setup_server(hass, config): success, broker_config = \ yield from server.async_start(hass, conf.get(CONF_EMBEDDED)) - return success and broker_config + if not success: + return None + return broker_config @asyncio.coroutine -def _async_setup_discovery(hass, config): +def _async_setup_discovery(hass: HomeAssistantType, + config: ConfigType): """Try to start the discovery of MQTT devices. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) + conf = config.get(DOMAIN, {}) # type: ConfigType discovery = yield from async_prepare_setup_platform( hass, config, DOMAIN, 'discovery') if discovery is None: _LOGGER.error("Unable to load MQTT discovery") - return None + return False success = yield from discovery.async_start( - hass, conf[CONF_DISCOVERY_PREFIX], config) + hass, conf[CONF_DISCOVERY_PREFIX], config) # type: bool return success @@ -292,13 +308,14 @@ def _async_setup_discovery(hass, config): @asyncio.coroutine def async_setup(hass: HomeAssistantType, config: ConfigType): """Start the MQTT protocol service.""" - conf = config.get(DOMAIN) + conf = config.get(DOMAIN) # type: Optional[ConfigType] if conf is None: conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + conf = cast(ConfigType, conf) - client_id = conf.get(CONF_CLIENT_ID) - keepalive = conf.get(CONF_KEEPALIVE) + client_id = conf.get(CONF_CLIENT_ID) # type: Optional[str] + keepalive = conf.get(CONF_KEEPALIVE) # type: int # Only setup if embedded config passed in or no broker specified if CONF_EMBEDDED not in conf and CONF_BROKER in conf: @@ -307,16 +324,16 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): broker_config = yield from _async_setup_server(hass, config) if CONF_BROKER in conf: - broker = conf[CONF_BROKER] - port = conf[CONF_PORT] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - certificate = conf.get(CONF_CERTIFICATE) - client_key = conf.get(CONF_CLIENT_KEY) - client_cert = conf.get(CONF_CLIENT_CERT) - tls_insecure = conf.get(CONF_TLS_INSECURE) - protocol = conf[CONF_PROTOCOL] - elif broker_config: + broker = conf[CONF_BROKER] # type: str + port = conf[CONF_PORT] # type: int + username = conf.get(CONF_USERNAME) # type: Optional[str] + password = conf.get(CONF_PASSWORD) # type: Optional[str] + certificate = conf.get(CONF_CERTIFICATE) # type: Optional[str] + client_key = conf.get(CONF_CLIENT_KEY) # type: Optional[str] + client_cert = conf.get(CONF_CLIENT_CERT) # type: Optional[str] + tls_insecure = conf.get(CONF_TLS_INSECURE) # type: Optional[bool] + protocol = conf[CONF_PROTOCOL] # type: str + elif broker_config is not None: # If no broker passed in, auto config to internal server broker, port, username, password, certificate, protocol = broker_config # Embedded broker doesn't have some ssl variables @@ -342,15 +359,15 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): if certificate == 'auto': certificate = requests.certs.where() - will_message = None + will_message = None # type: Optional[Message] if conf.get(CONF_WILL_MESSAGE) is not None: will_message = Message(**conf.get(CONF_WILL_MESSAGE)) - birth_message = None + birth_message = None # type: Optional[Message] if conf.get(CONF_BIRTH_MESSAGE) is not None: birth_message = Message(**conf.get(CONF_BIRTH_MESSAGE)) # Be able to override versions other than TLSv1.0 under Python3.6 - conf_tls_version = conf.get(CONF_TLS_VERSION) + conf_tls_version = conf.get(CONF_TLS_VERSION) # type: str if conf_tls_version == '1.2': tls_version = ssl.PROTOCOL_TLSv1_2 elif conf_tls_version == '1.1': @@ -376,24 +393,24 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): return False @asyncio.coroutine - def async_stop_mqtt(event): + def async_stop_mqtt(event: Event): """Stop MQTT component.""" yield from hass.data[DATA_MQTT].async_disconnect() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) - success = yield from hass.data[DATA_MQTT].async_connect() + success = yield from hass.data[DATA_MQTT].async_connect() # type: bool if not success: return False @asyncio.coroutine - def async_publish_service(call): + def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" - msg_topic = call.data[ATTR_TOPIC] + msg_topic = call.data[ATTR_TOPIC] # type: str payload = call.data.get(ATTR_PAYLOAD) payload_template = call.data.get(ATTR_PAYLOAD_TEMPLATE) - qos = call.data[ATTR_QOS] - retain = call.data[ATTR_RETAIN] + qos = call.data[ATTR_QOS] # type: int + retain = call.data[ATTR_RETAIN] # type: bool if payload_template is not None: try: payload = \ @@ -418,21 +435,36 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): return True -Subscription = namedtuple('Subscription', - ['topic', 'callback', 'qos', 'encoding']) -Subscription.__new__.__defaults__ = (0, 'utf-8') +@attr.s(slots=True, frozen=True) +class Subscription(object): + """Class to hold data about an active subscription.""" -Message = namedtuple('Message', ['topic', 'payload', 'qos', 'retain']) -Message.__new__.__defaults__ = (0, False) + topic = attr.ib(type=str) + callback = attr.ib(type=MessageCallbackType) + qos = attr.ib(type=int, default=0) + encoding = attr.ib(type=str, default='utf-8') + + +@attr.s(slots=True, frozen=True) +class Message(object): + """MQTT Message.""" + + topic = attr.ib(type=str) + payload = attr.ib(type=PublishPayloadType) + qos = attr.ib(type=int, default=0) + retain = attr.ib(type=bool, default=False) class MQTT(object): """Home Assistant MQTT client.""" - def __init__(self, hass, broker, port, client_id, keepalive, username, - password, certificate, client_key, client_cert, - tls_insecure, protocol, will_message: Optional[Message], - birth_message: Optional[Message], tls_version): + def __init__(self, hass: HomeAssistantType, broker: str, port: int, + client_id: Optional[str], keepalive: Optional[int], + username: Optional[str], password: Optional[str], + certificate: Optional[str], client_key: Optional[str], + client_cert: Optional[str], tls_insecure: Optional[bool], + protocol: Optional[str], will_message: Optional[Message], + birth_message: Optional[Message], tls_version) -> None: """Initialize Home Assistant MQTT client.""" import paho.mqtt.client as mqtt @@ -440,13 +472,13 @@ class MQTT(object): self.broker = broker self.port = port self.keepalive = keepalive - self.subscriptions = [] + self.subscriptions = [] # type: List[Subscription] self.birth_message = birth_message - self._mqttc = None + self._mqttc = None # type: mqtt.Client self._paho_lock = asyncio.Lock(loop=hass.loop) if protocol == PROTOCOL_31: - proto = mqtt.MQTTv31 + proto = mqtt.MQTTv31 # type: int else: proto = mqtt.MQTTv311 @@ -470,11 +502,12 @@ class MQTT(object): self._mqttc.on_disconnect = self._mqtt_on_disconnect self._mqttc.on_message = self._mqtt_on_message - if will_message: - self._mqttc.will_set(*will_message) + if will_message is not None: + self._mqttc.will_set(*attr.astuple(will_message)) @asyncio.coroutine - def async_publish(self, topic, payload, qos, retain): + def async_publish(self, topic: str, payload: PublishPayloadType, qos: int, + retain: bool): """Publish a MQTT message. This method must be run in the event loop and returns a coroutine. @@ -489,6 +522,7 @@ class MQTT(object): This method is a coroutine. """ + result = None # type: int result = yield from self.hass.async_add_job( self._mqttc.connect, self.broker, self.port, self.keepalive) @@ -500,6 +534,7 @@ class MQTT(object): return not result + @callback def async_disconnect(self): """Stop the MQTT client. @@ -513,7 +548,8 @@ class MQTT(object): return self.hass.async_add_job(stop) @asyncio.coroutine - def async_subscribe(self, topic, msg_callback, qos, encoding): + def async_subscribe(self, topic: str, msg_callback: MessageCallbackType, + qos: int, encoding: str): """Set up a subscription to a topic with the provided qos. This method is a coroutine. @@ -541,27 +577,31 @@ class MQTT(object): return async_remove @asyncio.coroutine - def _async_unsubscribe(self, topic): + def _async_unsubscribe(self, topic: str): """Unsubscribe from a topic. This method is a coroutine. """ with (yield from self._paho_lock): + result = None # type: int result, _ = yield from self.hass.async_add_job( self._mqttc.unsubscribe, topic) _raise_on_error(result) @asyncio.coroutine - def _async_perform_subscription(self, topic, qos): + def _async_perform_subscription(self, topic: str, + qos: int): """Perform a paho-mqtt subscription.""" _LOGGER.debug("Subscribing to %s", topic) with (yield from self._paho_lock): + result = None # type: int result, _ = yield from self.hass.async_add_job( self._mqttc.subscribe, topic, qos) _raise_on_error(result) - def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code): + def _mqtt_on_connect(self, _mqttc, _userdata, _flags, + result_code: int) -> None: """On connect callback. Resubscribe to all topics we were subscribed to and publish birth @@ -584,21 +624,22 @@ class MQTT(object): self.hass.add_job(self._async_perform_subscription, topic, max_qos) if self.birth_message: - self.hass.add_job(self.async_publish(*self.birth_message)) + self.hass.add_job( + self.async_publish(*attr.astuple(self.birth_message))) - def _mqtt_on_message(self, _mqttc, _userdata, msg): + def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: """Message received callback.""" self.hass.add_job(self._mqtt_handle_message, msg) @callback - def _mqtt_handle_message(self, msg): + def _mqtt_handle_message(self, msg) -> None: _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) for subscription in self.subscriptions: if not _match_topic(subscription.topic, msg.topic): continue - payload = msg.payload + payload = msg.payload # type: SubscribePayloadType if subscription.encoding is not None: try: payload = msg.payload.decode(subscription.encoding) @@ -612,7 +653,7 @@ class MQTT(object): self.hass.async_run_job(subscription.callback, msg.topic, payload, msg.qos) - def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): + def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" # When disconnected because of calling disconnect() if result_code == 0: @@ -637,18 +678,18 @@ class MQTT(object): tries += 1 -def _raise_on_error(result): +def _raise_on_error(result_code: int) -> None: """Raise error if error result.""" - if result != 0: + if result_code != 0: import paho.mqtt.client as mqtt raise HomeAssistantError( - 'Error talking to MQTT: {}'.format(mqtt.error_string(result))) + 'Error talking to MQTT: {}'.format(mqtt.error_string(result_code))) -def _match_topic(subscription, topic): +def _match_topic(subscription: str, topic: str) -> bool: """Test if topic matches subscription.""" - reg_ex_parts = [] + reg_ex_parts = [] # type: List[str] suffix = "" if subscription.endswith('#'): subscription = subscription[:-2] @@ -670,22 +711,26 @@ def _match_topic(subscription, topic): class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" - def __init__(self, availability_topic, qos, payload_available, - payload_not_available): + def __init__(self, availability_topic: Optional[str], qos: Optional[int], + payload_available: Optional[str], + payload_not_available: Optional[str]) -> None: """Initialize the availability mixin.""" self._availability_topic = availability_topic self._availability_qos = qos - self._available = availability_topic is None + self._available = availability_topic is None # type: bool self._payload_available = payload_available self._payload_not_available = payload_not_available + @asyncio.coroutine def async_added_to_hass(self): """Subscribe mqtt events. This method must be run in the event loop and returns a coroutine. """ @callback - def availability_message_received(topic, payload, qos): + def availability_message_received(topic: str, + payload: SubscribePayloadType, + qos: int) -> None: """Handle a new received MQTT availability message.""" if payload == self._payload_available: self._available = True diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index d0feab414da..3919d896fd1 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -8,6 +8,7 @@ import homeassistant.core GPSType = Tuple[float, float] ConfigType = Dict[str, Any] HomeAssistantType = homeassistant.core.HomeAssistant +ServiceDataType = Dict[str, Any] # Custom type for recorder Queries QueryType = Any From 001515bdc4aac4ad80f0f932363aeae6cf690b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Fr=C3=BCh?= Date: Wed, 28 Feb 2018 23:00:51 +0100 Subject: [PATCH 079/191] Add "headers" config parameter to rest switch (#12706) * Add "headers" config parameter to rest switch * Minor fix: line length --- homeassistant/components/switch/rest.py | 18 +++++++++++------- tests/components/switch/test_rest.py | 6 ++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index b68cc038e89..9c589d1d95b 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -13,8 +13,8 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, CONF_USERNAME, - CONF_PASSWORD) + CONF_HEADERS, CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, + CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -34,6 +34,7 @@ SUPPORT_REST_METHODS = ['post', 'put'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_HEADERS): {cv.string: cv.string}, vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template, vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template, vol.Optional(CONF_IS_ON_TEMPLATE): cv.template, @@ -54,6 +55,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): body_on = config.get(CONF_BODY_ON) is_on_template = config.get(CONF_IS_ON_TEMPLATE) method = config.get(CONF_METHOD) + headers = config.get(CONF_HEADERS) name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -72,8 +74,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) try: - switch = RestSwitch(name, resource, method, auth, body_on, body_off, - is_on_template, timeout) + switch = RestSwitch(name, resource, method, headers, auth, body_on, + body_off, is_on_template, timeout) req = yield from switch.get_device_state(hass) if req.status >= 400: @@ -90,13 +92,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class RestSwitch(SwitchDevice): """Representation of a switch that can be toggled using REST.""" - def __init__(self, name, resource, method, auth, body_on, body_off, - is_on_template, timeout): + def __init__(self, name, resource, method, headers, auth, body_on, + body_off, is_on_template, timeout): """Initialize the REST switch.""" self._state = None self._name = name self._resource = resource self._method = method + self._headers = headers self._auth = auth self._body_on = body_on self._body_off = body_off @@ -153,7 +156,8 @@ class RestSwitch(SwitchDevice): with async_timeout.timeout(self._timeout, loop=self.hass.loop): req = yield from getattr(websession, self._method)( - self._resource, auth=self._auth, data=bytes(body, 'utf-8')) + self._resource, auth=self._auth, data=bytes(body, 'utf-8'), + headers=self._headers) return req @asyncio.coroutine diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py index 1b8215660bd..064d0b1825b 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -82,6 +82,7 @@ class TestRestSwitchSetup: 'platform': 'rest', 'name': 'foo', 'resource': 'http://localhost', + 'headers': {'Content-type': 'application/json'}, 'body_on': 'custom on text', 'body_off': 'custom off text', } @@ -99,12 +100,13 @@ class TestRestSwitch: self.name = 'foo' self.method = 'post' self.resource = 'http://localhost/' + self.headers = {'Content-type': 'application/json'} self.auth = None self.body_on = Template('on', self.hass) self.body_off = Template('off', self.hass) self.switch = rest.RestSwitch( - self.name, self.resource, self.method, self.auth, self.body_on, - self.body_off, None, 10) + self.name, self.resource, self.method, self.headers, self.auth, + self.body_on, self.body_off, None, 10) self.switch.hass = self.hass def teardown_method(self): From 6fe6dcfac91c1b7c3938f8b64fc6b1746738819f Mon Sep 17 00:00:00 2001 From: kbickar Date: Wed, 28 Feb 2018 17:29:24 -0500 Subject: [PATCH 080/191] Added Sense energy monitor sensor (#11580) * Added Sense energy monitor sensor * Added missing = * Style updates * Added newline, but not blank line at end of file * Updated sense API to 0.2.2 * Moved import in function * Fixed tabs * Updated requirements * Removed bare except * Longer update times and more stats * sense api update * Updated to use string formatting * Setup to use monitored_conditions * Fix syntax * API update * blank space fixes * More blank fixes * API version update * Fixed comment format * removed unneeded function call --- .coveragerc | 1 + homeassistant/components/sensor/sense.py | 152 +++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 156 insertions(+) create mode 100644 homeassistant/components/sensor/sense.py diff --git a/.coveragerc b/.coveragerc index 71e0f0471f2..15e69f47c8a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -625,6 +625,7 @@ omit = homeassistant/components/sensor/ripple.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py + homeassistant/components/sensor/sense.py homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial.py homeassistant/components/sensor/serial_pm.py diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py new file mode 100644 index 00000000000..5eee9053db5 --- /dev/null +++ b/homeassistant/components/sensor/sense.py @@ -0,0 +1,152 @@ +""" +Support for monitoring a Sense energy sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sense/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_EMAIL, CONF_PASSWORD, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['sense_energy==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +ACTIVE_NAME = "Energy" +PRODUCTION_NAME = "Production" +CONSUMPTION_NAME = "Usage" + +ACTIVE_TYPE = 'active' + + +class SensorConfig(object): + """Data structure holding sensor config.""" + + def __init__(self, name, sensor_type): + """Sensor name and type to pass to API.""" + self.name = name + self.sensor_type = sensor_type + + +# Sensor types/ranges +SENSOR_TYPES = {'active': SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), + 'daily': SensorConfig('Daily', 'DAY'), + 'weekly': SensorConfig('Weekly', 'WEEK'), + 'monthly': SensorConfig('Monthly', 'MONTH'), + 'yearly': SensorConfig('Yearly', 'YEAR')} + +# Production/consumption variants +SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] + +# Valid sensors for configuration +VALID_SENSORS = ['%s_%s' % (typ, var) + for typ in SENSOR_TYPES + for var in SENSOR_VARIANTS] + +ICON = 'mdi:flash' + +MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) +MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=60) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(VALID_SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Sense sensor.""" + from sense_energy import Senseable + + username = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + + data = Senseable(username, password) + + @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) + def update_trends(): + """Update the daily power usage.""" + data.update_trend_data() + + @Throttle(MIN_TIME_BETWEEN_ACTIVE_UPDATES) + def update_active(): + """Update the active power usage.""" + data.get_realtime() + + devices = [] + for sensor in config.get(CONF_MONITORED_CONDITIONS): + config_name, prod = sensor.rsplit('_', 1) + name = SENSOR_TYPES[config_name].name + sensor_type = SENSOR_TYPES[config_name].sensor_type + is_production = prod == PRODUCTION_NAME.lower() + if sensor_type == ACTIVE_TYPE: + update_call = update_active + else: + update_call = update_trends + devices.append(Sense(data, name, sensor_type, + is_production, update_call)) + + add_devices(devices) + + +class Sense(Entity): + """Implementation of a Sense energy sensor.""" + + def __init__(self, data, name, sensor_type, is_production, update_call): + """Initialize the sensor.""" + name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME + self._name = "%s %s" % (name, name_type) + self._data = data + self._sensor_type = sensor_type + self.update_sensor = update_call + self._is_production = is_production + self._state = None + + if sensor_type == ACTIVE_TYPE: + self._unit_of_measurement = 'W' + else: + self._unit_of_measurement = 'kWh' + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data, update state.""" + self.update_sensor() + + if self._sensor_type == ACTIVE_TYPE: + if self._is_production: + self._state = round(self._data.active_solar_power) + else: + self._state = round(self._data.active_power) + else: + state = self._data.get_trend(self._sensor_type, + self._is_production) + self._state = round(state, 1) diff --git a/requirements_all.txt b/requirements_all.txt index b20d8d66a97..90c00310eaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,6 +1097,9 @@ sendgrid==5.3.0 # homeassistant.components.sensor.sensehat sense-hat==2.2.0 +# homeassistant.components.sensor.sense +sense_energy==0.3.1 + # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 From b1cc9bf452c9459978a1b896415b63f39af49223 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 1 Mar 2018 01:10:58 +0100 Subject: [PATCH 081/191] Fix dead Sonos web interface with some music sources (#12796) * Get data from soco, not event * Patch soco.events.parse_event_xml to ignore exceptions --- .../components/media_player/sonos.py | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index b86e69249b7..b0a7776ec82 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -120,6 +120,19 @@ class SonosData: def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" import soco + import soco.events + import soco.exceptions + + orig_parse_event_xml = soco.events.parse_event_xml + + def safe_parse_event_xml(xml): + """Avoid SoCo 0.14 event thread dying from invalid xml.""" + try: + return orig_parse_event_xml(xml) + except soco.exceptions.SoCoException: + return {} + + soco.events.parse_event_xml = safe_parse_event_xml if DATA_SONOS not in hass.data: hass.data[DATA_SONOS] = SonosData() @@ -452,14 +465,14 @@ class SonosDevice(MediaPlayerDevice): def process_avtransport_event(self, event): """Process a track change event coming from a coordinator.""" - variables = event.variables + transport_info = self.soco.get_current_transport_info() + new_status = transport_info.get('current_transport_state') # Ignore transitions, we should get the target state soon - new_status = variables.get('transport_state') if new_status == 'TRANSITIONING': return - self._play_mode = variables.get('current_play_mode', self._play_mode) + self._play_mode = self.soco.play_mode if self.soco.is_playing_tv: self._refresh_linein(SOURCE_TV) @@ -473,12 +486,12 @@ class SonosDevice(MediaPlayerDevice): ) if _is_radio_uri(track_info['uri']): - self._refresh_radio(variables, media_info, track_info) + self._refresh_radio(event.variables, media_info, track_info) else: - self._refresh_music(variables, media_info, track_info) + update_position = (new_status != self._status) + self._refresh_music(update_position, media_info, track_info) - if new_status: - self._status = new_status + self._status = new_status self.schedule_update_ha_state() @@ -586,9 +599,7 @@ class SonosDevice(MediaPlayerDevice): ) else: # "On Now" field in the sonos pc app - current_track_metadata = variables.get( - 'current_track_meta_data' - ) + current_track_metadata = variables.get('current_track_meta_data') if current_track_metadata: self._media_artist = \ current_track_metadata.radio_show.split(',')[0] @@ -626,7 +637,7 @@ class SonosDevice(MediaPlayerDevice): if fav.reference.get_uri() == media_info['CurrentURI']: self._source_name = fav.title - def _refresh_music(self, variables, media_info, track_info): + def _refresh_music(self, update_media_position, media_info, track_info): """Update state when playing music tracks.""" self._extra_features = SUPPORT_PAUSE | SUPPORT_SHUFFLE_SET |\ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK @@ -659,25 +670,21 @@ class SonosDevice(MediaPlayerDevice): rel_time = _timespan_secs(position_info.get("RelTime")) # player no longer reports position? - update_media_position = rel_time is None and \ + update_media_position |= rel_time is None and \ self._media_position is not None # player started reporting position? update_media_position |= rel_time is not None and \ self._media_position is None - if self._status != variables.get('transport_state'): - update_media_position = True - else: - # position jumped? - if rel_time is not None and self._media_position is not None: - time_diff = utcnow() - self._media_position_updated_at - time_diff = time_diff.total_seconds() + # position jumped? + if rel_time is not None and self._media_position is not None: + time_diff = utcnow() - self._media_position_updated_at + time_diff = time_diff.total_seconds() - calculated_position = self._media_position + time_diff + calculated_position = self._media_position + time_diff - update_media_position = \ - abs(calculated_position - rel_time) > 1.5 + update_media_position |= abs(calculated_position - rel_time) > 1.5 if update_media_position: self._media_position = rel_time From 3416d3f5f15f264695c1897263a1304f10703584 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Wed, 28 Feb 2018 19:21:10 -0500 Subject: [PATCH 082/191] TekSavvy Sensor unlimited bandwidth support (#12325) * Support TekSavvy Unlimited Plans Support TekSavvy account usage for unlimited plans. Seeing cap limit to 0 will now provide unlimited behaviour on usage calculations. * Add unit tests to sensor.teksavvy Add coverage unit tests to TekSavvy Sensor component, none existing previously. --- .coveragerc | 1 - homeassistant/components/sensor/teksavvy.py | 46 +++-- tests/components/sensor/test_teksavvy.py | 185 ++++++++++++++++++++ 3 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 tests/components/sensor/test_teksavvy.py diff --git a/.coveragerc b/.coveragerc index 15e69f47c8a..c1a5bc8d3a6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -647,7 +647,6 @@ omit = homeassistant/components/sensor/sytadin.py homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py - homeassistant/components/sensor/teksavvy.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/tibber.py homeassistant/components/sensor/time_date.py diff --git a/homeassistant/components/sensor/teksavvy.py b/homeassistant/components/sensor/teksavvy.py index 33e5c0cf4ce..9c4263422ff 100644 --- a/homeassistant/components/sensor/teksavvy.py +++ b/homeassistant/components/sensor/teksavvy.py @@ -31,11 +31,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds SENSOR_TYPES = { - 'usage': ['Usage', PERCENT, 'mdi:percent'], + 'usage': ['Usage Ratio', PERCENT, 'mdi:percent'], 'usage_gb': ['Usage', GIGABYTES, 'mdi:download'], 'limit': ['Data limit', GIGABYTES, 'mdi:download'], 'onpeak_download': ['On Peak Download', GIGABYTES, 'mdi:download'], - 'onpeak_upload': ['On Peak Upload ', GIGABYTES, 'mdi:upload'], + 'onpeak_upload': ['On Peak Upload', GIGABYTES, 'mdi:upload'], 'onpeak_total': ['On Peak Total', GIGABYTES, 'mdi:download'], 'offpeak_download': ['Off Peak download', GIGABYTES, 'mdi:download'], 'offpeak_upload': ['Off Peak Upload', GIGABYTES, 'mdi:upload'], @@ -128,7 +128,9 @@ class TekSavvyData(object): self.websession = websession self.api_key = api_key self.bandwidth_cap = bandwidth_cap - self.data = {"limit": self.bandwidth_cap} + # Set unlimited users to infinite, otherwise the cap. + self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \ + else {"limit": float('inf')} @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) @@ -143,17 +145,27 @@ class TekSavvyData(object): if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) return False - data = yield from req.json() - for (api, ha_name) in API_HA_MAP: - self.data[ha_name] = float(data["value"][0][api]) - on_peak_download = self.data["onpeak_download"] - on_peak_upload = self.data["onpeak_upload"] - off_peak_download = self.data["offpeak_download"] - off_peak_upload = self.data["offpeak_upload"] - limit = self.data["limit"] - self.data["usage"] = 100*on_peak_download/self.bandwidth_cap - self.data["usage_gb"] = on_peak_download - self.data["onpeak_total"] = on_peak_download + on_peak_upload - self.data["offpeak_total"] = off_peak_download + off_peak_upload - self.data["onpeak_remaining"] = limit - on_peak_download - return True + + try: + data = yield from req.json() + for (api, ha_name) in API_HA_MAP: + self.data[ha_name] = float(data["value"][0][api]) + on_peak_download = self.data["onpeak_download"] + on_peak_upload = self.data["onpeak_upload"] + off_peak_download = self.data["offpeak_download"] + off_peak_upload = self.data["offpeak_upload"] + limit = self.data["limit"] + # Support "unlimited" users + if self.bandwidth_cap > 0: + self.data["usage"] = 100*on_peak_download/self.bandwidth_cap + else: + self.data["usage"] = 0 + self.data["usage_gb"] = on_peak_download + self.data["onpeak_total"] = on_peak_download + on_peak_upload + self.data["offpeak_total"] =\ + off_peak_download + off_peak_upload + self.data["onpeak_remaining"] = limit - on_peak_download + return True + except ValueError: + _LOGGER.error("JSON Decode Failed") + return False diff --git a/tests/components/sensor/test_teksavvy.py b/tests/components/sensor/test_teksavvy.py new file mode 100644 index 00000000000..2c493d04050 --- /dev/null +++ b/tests/components/sensor/test_teksavvy.py @@ -0,0 +1,185 @@ +"""Tests for the TekSavvy sensor platform.""" +import asyncio +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor.teksavvy import TekSavvyData +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@asyncio.coroutine +def test_capped_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'teksavvy', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 400, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'onpeak_download', + 'onpeak_upload', + 'onpeak_total', + 'offpeak_download', + 'offpeak_upload', + 'offpeak_total', + 'onpeak_remaining']} + + result = '{"odata.metadata":"http://api.teksavvy.com/web/Usage/$metadata'\ + '#UsageSummaryRecords","value":[{'\ + '"StartDate":"2018-01-01T00:00:00",'\ + '"EndDate":"2018-01-31T00:00:00",'\ + '"OID":"999999","IsCurrent":true,'\ + '"OnPeakDownload":226.75,'\ + '"OnPeakUpload":8.82,'\ + '"OffPeakDownload":36.24,"OffPeakUpload":1.58'\ + '}]}' + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.teksavvy_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '400' + + state = hass.states.get('sensor.teksavvy_off_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '36.24' + + state = hass.states.get('sensor.teksavvy_off_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '1.58' + + state = hass.states.get('sensor.teksavvy_off_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '37.82' + + state = hass.states.get('sensor.teksavvy_on_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_on_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '8.82' + + state = hass.states.get('sensor.teksavvy_on_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '235.57' + + state = hass.states.get('sensor.teksavvy_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '56.69' + + state = hass.states.get('sensor.teksavvy_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '173.25' + + +@asyncio.coroutine +def test_unlimited_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'teksavvy', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 0, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'onpeak_download', + 'onpeak_upload', + 'onpeak_total', + 'offpeak_download', + 'offpeak_upload', + 'offpeak_total', + 'onpeak_remaining']} + + result = '{"odata.metadata":"http://api.teksavvy.com/web/Usage/$metadata'\ + '#UsageSummaryRecords","value":[{'\ + '"StartDate":"2018-01-01T00:00:00",'\ + '"EndDate":"2018-01-31T00:00:00",'\ + '"OID":"999999","IsCurrent":true,'\ + '"OnPeakDownload":226.75,'\ + '"OnPeakUpload":8.82,'\ + '"OffPeakDownload":36.24,"OffPeakUpload":1.58'\ + '}]}' + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.teksavvy_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + state = hass.states.get('sensor.teksavvy_off_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '36.24' + + state = hass.states.get('sensor.teksavvy_off_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '1.58' + + state = hass.states.get('sensor.teksavvy_off_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '37.82' + + state = hass.states.get('sensor.teksavvy_on_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_on_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '8.82' + + state = hass.states.get('sensor.teksavvy_on_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '235.57' + + state = hass.states.get('sensor.teksavvy_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '0' + + state = hass.states.get('sensor.teksavvy_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + +@asyncio.coroutine +def test_bad_return_code(hass, aioclient_mock): + """Test handling a return code that isn't HTTP OK.""" + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + status=404) + + tsd = TekSavvyData(hass.loop, async_get_clientsession(hass), + 'notakey', 400) + + result = yield from tsd.async_update() + assert result is False + + +@asyncio.coroutine +def test_bad_json_decode(hass, aioclient_mock): + """Test decoding invalid json result.""" + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + text='this is not json') + + tsd = TekSavvyData(hass.loop, async_get_clientsession(hass), + 'notakey', 400) + + result = yield from tsd.async_update() + assert result is False From 53078f30695dd9be09335b6093ae374625fa42b0 Mon Sep 17 00:00:00 2001 From: Reed Riley Date: Wed, 28 Feb 2018 19:54:19 -0500 Subject: [PATCH 083/191] iCloud location tracking improvements (#12399) * Add an error message when there are name collisions in iCloud * Teach icloud component to set interval based on proximity to nearest zone. --- .../components/device_tracker/icloud.py | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 472b48fef6e..7ca8a5cb232 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -189,10 +189,12 @@ class Icloud(DeviceScanner): for device in self.api.devices: status = device.status(DEVICESTATUSSET) devicename = slugify(status['name'].replace(' ', '', 99)) - if devicename not in self.devices: - self.devices[devicename] = device - self._intervals[devicename] = 1 - self._overridestates[devicename] = None + if devicename in self.devices: + _LOGGER.error('Multiple devices with name: %s', devicename) + continue + self.devices[devicename] = device + self._intervals[devicename] = 1 + self._overridestates[devicename] = None except PyiCloudNoDevicesException: _LOGGER.error('No iCloud Devices found!') @@ -319,14 +321,6 @@ class Icloud(DeviceScanner): def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" - distancefromhome = None - zone_state = self.hass.states.get('zone.home') - zone_state_lat = zone_state.attributes['latitude'] - zone_state_long = zone_state.attributes['longitude'] - distancefromhome = distance( - latitude, longitude, zone_state_lat, zone_state_long) - distancefromhome = round(distancefromhome / 1000, 1) - currentzone = active_zone(self.hass, latitude, longitude) if ((currentzone is not None and @@ -335,22 +329,48 @@ class Icloud(DeviceScanner): self._overridestates.get(devicename) == 'away')): return + zones = (self.hass.states.get(entity_id) for entity_id + in sorted(self.hass.states.async_entity_ids('zone'))) + + distances = [] + for zone_state in zones: + zone_state_lat = zone_state.attributes['latitude'] + zone_state_long = zone_state.attributes['longitude'] + zone_distance = distance( + latitude, longitude, zone_state_lat, zone_state_long) + distances.append(round(zone_distance / 1000, 1)) + + if distances: + mindistance = min(distances) + else: + mindistance = None + self._overridestates[devicename] = None if currentzone is not None: self._intervals[devicename] = 30 return - if distancefromhome is None: + if mindistance is None: return - if distancefromhome > 25: - self._intervals[devicename] = round(distancefromhome / 2, 0) - elif distancefromhome > 10: - self._intervals[devicename] = 5 - else: - self._intervals[devicename] = 1 - if battery is not None and battery <= 33 and distancefromhome > 3: - self._intervals[devicename] = self._intervals[devicename] * 2 + + # Calculate out how long it would take for the device to drive to the + # nearest zone at 120 km/h: + interval = round(mindistance / 2, 0) + + # Never poll more than once per minute + interval = max(interval, 1) + + if interval > 180: + # Three hour drive? This is far enough that they might be flying + # home - check every half hour + interval = 30 + + if battery is not None and battery <= 33 and mindistance > 3: + # Low battery - let's check half as often + interval = interval * 2 + + self._intervals[devicename] = interval def update_device(self, devicename): """Update the device_tracker entity.""" From a60712d8261c0b63c9295d924593f39dbfd1e868 Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Thu, 1 Mar 2018 01:53:51 +0000 Subject: [PATCH 084/191] Unique IDs for Plex Clients (#12799) * Unique IDs for Clients * HoundCI cleanup * debug output removal * Updates from feedback * More Updates from feedback * More Updates from feedback * Lint Fixes --- homeassistant/components/media_player/plex.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index dc38bb17dd3..a63bf8525ed 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.plex/ """ import json import logging + from datetime import timedelta import requests @@ -47,9 +48,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ cv.boolean, }) +PLEX_DATA = "plex" + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Plex platform.""" + if PLEX_DATA not in hass.data: + hass.data[PLEX_DATA] = {} + # get config from plex.conf file_config = load_json(hass.config.path(PLEX_CONFIG_FILE)) @@ -130,7 +136,7 @@ def setup_plexserver( _LOGGER.info('Connected to: %s://%s', http_prefix, host) - plex_clients = {} + plex_clients = hass.data[PLEX_DATA] plex_sessions = {} track_utc_time_change(hass, lambda now: update_devices(), second=30) From b434ffba2d4854c58c54a61ee96ea1149243cd6d Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 28 Feb 2018 22:31:38 -0500 Subject: [PATCH 085/191] Support serving of backend translations (#12453) * Add view to support backend translation fetching * Load backend translations from component json * Translations for season sensor * Scripts to merge and unpack Lokalise translations * Fix copy paste error * Serve post-lokalise translations to frontend * Linting * Auto-deploy translations with Travis * Commit post-lokalise translation files * Split logic into more helper functions * Fall back to English for missing keys * Move local translation copies to `.translations` * Linting * Initial tests * Remove unnecessary file check * Convert translation helper to async/await * Convert translation helper tests to async/await * Use set subtraction to find missing_components * load_translation_files use component->file mapping * Remove duplicated resources fetching Get to take advantage of the slick Python 3.5 dict merging here. * Switch to live project ID --- .gitignore | 3 + .travis.yml | 9 ++ homeassistant/components/frontend/__init__.py | 20 +++ .../sensor/.translations/season.en.json | 8 ++ homeassistant/components/sensor/season.py | 8 +- .../components/sensor/strings.season.json | 8 ++ homeassistant/helpers/translation.py | 126 ++++++++++++++++++ homeassistant/util/json.py | 4 +- script/translations_download | 39 ++++++ script/translations_download_split.py | 81 +++++++++++ script/translations_upload | 44 ++++++ script/translations_upload_merge.py | 81 +++++++++++ script/travis_deploy | 11 ++ tests/helpers/test_translation.py | 108 +++++++++++++++ .../switch/.translations/test.de.json | 6 + .../switch/.translations/test.en.json | 6 + .../switch/.translations/test.es.json | 5 + .../test_package/__init__.py | 7 + .../custom_components/test_standalone.py | 7 + 19 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/sensor/.translations/season.en.json create mode 100644 homeassistant/components/sensor/strings.season.json create mode 100644 homeassistant/helpers/translation.py create mode 100755 script/translations_download create mode 100755 script/translations_download_split.py create mode 100755 script/translations_upload create mode 100755 script/translations_upload_merge.py create mode 100755 script/travis_deploy create mode 100644 tests/helpers/test_translation.py create mode 100644 tests/testing_config/custom_components/switch/.translations/test.de.json create mode 100644 tests/testing_config/custom_components/switch/.translations/test.en.json create mode 100644 tests/testing_config/custom_components/switch/.translations/test.es.json create mode 100644 tests/testing_config/custom_components/test_package/__init__.py create mode 100644 tests/testing_config/custom_components/test_standalone.py diff --git a/.gitignore b/.gitignore index 0d55cae3c9d..b3774b06bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,6 @@ desktop.ini # mypy /.mypy_cache/* + +# Secrets +.lokalise_token diff --git a/.travis.yml b/.travis.yml index 027c1f25c62..c1d70d528b3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,4 +28,13 @@ cache: install: pip install -U tox coveralls language: python script: travis_wait 30 tox --develop +services: + - docker +before_deploy: + - docker pull lokalise/lokalise-cli +deploy: + provider: script + script: script/travis_deploy + on: + branch: dev after_success: coveralls diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e2b98c3e59c..9fbf936cccc 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,6 +21,7 @@ from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback +from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass REQUIREMENTS = ['home-assistant-frontend==20180228.1'] @@ -379,6 +380,8 @@ def async_setup(hass, config): async_setup_themes(hass, conf.get(CONF_THEMES)) + hass.http.register_view(TranslationsView) + return True @@ -541,6 +544,23 @@ class ThemesView(HomeAssistantView): }) +class TranslationsView(HomeAssistantView): + """View to return backend defined translations.""" + + url = '/api/translations/{language}' + name = 'api:translations' + + @asyncio.coroutine + def get(self, request, language): + """Return translations.""" + hass = request.app['hass'] + + resources = yield from async_get_translations(hass, language) + return self.json({ + 'resources': resources, + }) + + def _fingerprint(path): """Fingerprint a file.""" with open(path) as fil: diff --git a/homeassistant/components/sensor/.translations/season.en.json b/homeassistant/components/sensor/.translations/season.en.json new file mode 100644 index 00000000000..b42100215ca --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.en.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Autumn", + "spring": "Spring", + "summer": "Summer", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py index e02f3cac2b0..b04b7727e40 100644 --- a/homeassistant/components/sensor/season.py +++ b/homeassistant/components/sensor/season.py @@ -21,10 +21,10 @@ _LOGGER = logging.getLogger(__name__) NORTHERN = 'northern' SOUTHERN = 'southern' EQUATOR = 'equator' -STATE_SPRING = 'Spring' -STATE_SUMMER = 'Summer' -STATE_AUTUMN = 'Autumn' -STATE_WINTER = 'Winter' +STATE_SPRING = 'spring' +STATE_SUMMER = 'summer' +STATE_AUTUMN = 'autumn' +STATE_WINTER = 'winter' TYPE_ASTRONOMICAL = 'astronomical' TYPE_METEOROLOGICAL = 'meteorological' VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] diff --git a/homeassistant/components/sensor/strings.season.json b/homeassistant/components/sensor/strings.season.json new file mode 100644 index 00000000000..63136320d74 --- /dev/null +++ b/homeassistant/components/sensor/strings.season.json @@ -0,0 +1,8 @@ +{ + "state": { + "spring": "Spring", + "summer": "Summer", + "autumn": "Autumn", + "winter": "Winter" + } +} diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py new file mode 100644 index 00000000000..9d1773de4d2 --- /dev/null +++ b/homeassistant/helpers/translation.py @@ -0,0 +1,126 @@ +"""Translation string lookup helpers.""" +import logging +# pylint: disable=unused-import +from typing import Optional # NOQA +from os import path + +from homeassistant.loader import get_component, bind_hass +from homeassistant.util.json import load_json + +_LOGGER = logging.getLogger(__name__) + +TRANSLATION_STRING_CACHE = 'translation_string_cache' + + +def recursive_flatten(prefix, data): + """Return a flattened representation of dict data.""" + output = {} + for key, value in data.items(): + if isinstance(value, dict): + output.update( + recursive_flatten('{}{}.'.format(prefix, key), value)) + else: + output['{}{}'.format(prefix, key)] = value + return output + + +def flatten(data): + """Return a flattened representation of dict data.""" + return recursive_flatten('', data) + + +def component_translation_file(component, language): + """Return the translation json file location for a component.""" + if '.' in component: + name = component.split('.', 1)[1] + else: + name = component + + module = get_component(component) + component_path = path.dirname(module.__file__) + + # If loading translations for the package root, (__init__.py), the + # prefix should be skipped. + if module.__name__ == module.__package__: + filename = '{}.json'.format(language) + else: + filename = '{}.{}.json'.format(name, language) + + return path.join(component_path, '.translations', filename) + + +def load_translations_files(translation_files): + """Load and parse translation.json files.""" + loaded = {} + for component, translation_file in translation_files.items(): + loaded[component] = load_json(translation_file) + + return loaded + + +def build_resources(translation_cache, components): + """Build the resources response for the given components.""" + # Build response + resources = {} + for component in components: + if '.' not in component: + domain = component + else: + domain = component.split('.', 1)[0] + + if domain not in resources: + resources[domain] = {} + + # Add the translations for this component to the domain resources. + # Since clients cannot determine which platform an entity belongs to, + # all translations for a domain will be returned together. + resources[domain].update(translation_cache[component]) + + return resources + + +@bind_hass +async def async_get_component_resources(hass, language): + """Return translation resources for all components.""" + if TRANSLATION_STRING_CACHE not in hass.data: + hass.data[TRANSLATION_STRING_CACHE] = {} + if language not in hass.data[TRANSLATION_STRING_CACHE]: + hass.data[TRANSLATION_STRING_CACHE][language] = {} + translation_cache = hass.data[TRANSLATION_STRING_CACHE][language] + + # Get the set of components + components = hass.config.components + + # Calculate the missing components + missing_components = components - set(translation_cache) + missing_files = {} + for component in missing_components: + missing_files[component] = component_translation_file( + component, language) + + # Load missing files + if missing_files: + loaded_translations = await hass.async_add_job( + load_translations_files, missing_files) + + # Update cache + for component, translation_data in loaded_translations.items(): + translation_cache[component] = translation_data + + resources = build_resources(translation_cache, components) + + # Return the component translations resources under the 'component' + # translation namespace + return flatten({'component': resources}) + + +@bind_hass +async def async_get_translations(hass, language): + """Return all backend translations.""" + resources = await async_get_component_resources(hass, language) + if language != 'en': + # Fetch the English resources, as a fallback for missing keys + base_resources = await async_get_component_resources(hass, 'en') + resources = {**base_resources, **resources} + + return resources diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 7a326c34f15..b2577ff6be6 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -32,13 +32,13 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ return {} if default is _UNDEFINED else default -def save_json(filename: str, config: Union[List, Dict]): +def save_json(filename: str, data: Union[List, Dict]): """Save JSON data to a file. Returns True on success. """ try: - data = json.dumps(config, sort_keys=True, indent=4) + data = json.dumps(data, sort_keys=True, indent=4) with open(filename, 'w', encoding='utf-8') as fdesc: fdesc.write(data) return True diff --git a/script/translations_download b/script/translations_download new file mode 100755 index 00000000000..de1f4640988 --- /dev/null +++ b/script/translations_download @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# Safe bash settings +# -e Exit on command fail +# -u Exit on unset variable +# -o pipefail Exit if piped command has error code +set -eu -o pipefail + +cd "$(dirname "$0")/.." + +if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then + echo "Lokalise API token is required to download the latest set of" \ + "translations. Please create an account by using the following link:" \ + "https://lokalise.co/signup/130246255a974bd3b5e8a1.51616605/all/" \ + "Place your token in a new file \".lokalise_token\" in the repo" \ + "root directory." + exit 1 +fi + +# Load token from file if not already in the environment +[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)" + +PROJECT_ID="130246255a974bd3b5e8a1.51616605" +LOCAL_DIR="$(pwd)/build/translations-download" +FILE_FORMAT=json + +mkdir -p ${LOCAL_DIR} + +docker pull lokalise/lokalise-cli +docker run \ + -v ${LOCAL_DIR}:/opt/dest/locale \ + lokalise/lokalise-cli lokalise \ + --token ${LOKALISE_TOKEN} \ + export ${PROJECT_ID} \ + --export_empty skip \ + --type json \ + --unzip_to /opt/dest + +script/translations_download_split.py diff --git a/script/translations_download_split.py b/script/translations_download_split.py new file mode 100755 index 00000000000..08ea9fbcccc --- /dev/null +++ b/script/translations_download_split.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Merge all translation sources into a single JSON file.""" +import glob +import os +import re + +from homeassistant.util import json as json_util + +FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json') + + +def get_language(path): + """Get the language code for the given file path.""" + return os.path.splitext(os.path.basename(path))[0] + + +def get_component_path(lang, component): + """Get the component translation path.""" + if os.path.isdir(os.path.join("homeassistant", "components", component)): + return os.path.join( + "homeassistant", "components", component, ".translations", + "{}.json".format(lang)) + else: + return os.path.join( + "homeassistant", "components", ".translations", + "{}.{}.json".format(component, lang)) + + +def get_platform_path(lang, component, platform): + """Get the platform translation path.""" + if os.path.isdir(os.path.join( + "homeassistant", "components", component, platform)): + return os.path.join( + "homeassistant", "components", component, platform, + ".translations", "{}.json".format(lang)) + else: + return os.path.join( + "homeassistant", "components", component, ".translations", + "{}.{}.json".format(platform, lang)) + + +def get_component_translations(translations): + """Get the component level translations.""" + translations = translations.copy() + translations.pop('platform', None) + + return translations + + +def save_language_translations(lang, translations): + """Distribute the translations for this language.""" + components = translations.get('component', {}) + for component, component_translations in components.items(): + base_translations = get_component_translations(component_translations) + if base_translations: + path = get_component_path(lang, component) + os.makedirs(os.path.dirname(path), exist_ok=True) + json_util.save_json(path, base_translations) + + for platform, platform_translations in component_translations.get( + 'platform', {}).items(): + path = get_platform_path(lang, component, platform) + os.makedirs(os.path.dirname(path), exist_ok=True) + json_util.save_json(path, platform_translations) + + +def main(): + """Main section of the script.""" + if not os.path.isfile("requirements_all.txt"): + print("Run this from HA root dir") + return + + paths = glob.iglob("build/translations-download/*.json") + for path in paths: + lang = get_language(path) + translations = json_util.load_json(path) + save_language_translations(lang, translations) + + +if __name__ == '__main__': + main() diff --git a/script/translations_upload b/script/translations_upload new file mode 100755 index 00000000000..fcc12ef272f --- /dev/null +++ b/script/translations_upload @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Safe bash settings +# -e Exit on command fail +# -u Exit on unset variable +# -o pipefail Exit if piped command has error code +set -eu -o pipefail + +cd "$(dirname "$0")/.." + +if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then + echo "Lokalise API token is required to download the latest set of" \ + "translations. Please create an account by using the following link:" \ + "https://lokalise.co/signup/130246255a974bd3b5e8a1.51616605/all/" \ + "Place your token in a new file \".lokalise_token\" in the repo" \ + "root directory." + exit 1 +fi + +# Load token from file if not already in the environment +[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)" + +PROJECT_ID="130246255a974bd3b5e8a1.51616605" +LOCAL_FILE="$(pwd)/build/translations-upload.json" +LANG_ISO=en + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +if [ "${CURRENT_BRANCH-}" != "dev" ] && [ "${TRAVIS_BRANCH-}" != "dev" ] ; then + echo "Please only run the translations upload script from a clean checkout of dev." + exit 1 +fi + +script/translations_upload_merge.py + +docker pull lokalise/lokalise-cli +docker run \ + -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \ + lokalise/lokalise-cli lokalise \ + --token ${LOKALISE_TOKEN} \ + import ${PROJECT_ID} \ + --file /opt/src/${LOCAL_FILE} \ + --lang_iso ${LANG_ISO} \ + --replace 1 diff --git a/script/translations_upload_merge.py b/script/translations_upload_merge.py new file mode 100755 index 00000000000..6382c8d9abe --- /dev/null +++ b/script/translations_upload_merge.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Merge all translation sources into a single JSON file.""" +import glob +import itertools +import os +import re + +from homeassistant.util import json as json_util + +FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json') + + +def find_strings_files(): + """Return the paths of the strings source files.""" + return itertools.chain( + glob.iglob("strings*.json"), + glob.iglob("*{}strings*.json".format(os.sep)), + ) + + +def get_component_platform(path): + """Get the component and platform name from the path.""" + directory, filename = os.path.split(path) + match = FILENAME_FORMAT.search(filename) + suffix = match.group('suffix') if match else None + if directory: + return directory, suffix + else: + return suffix, None + + +def get_translation_dict(translations, component, platform): + """Return the dict to hold component translations.""" + if not component: + return translations['component'] + + if component not in translations: + translations['component'][component] = {} + + if not platform: + return translations['component'][component] + + if 'platform' not in translations['component'][component]: + translations['component'][component]['platform'] = {} + + if platform not in translations['component'][component]['platform']: + translations['component'][component]['platform'][platform] = {} + + return translations['component'][component]['platform'][platform] + + +def main(): + """Main section of the script.""" + if not os.path.isfile("requirements_all.txt"): + print("Run this from HA root dir") + return + + root = os.getcwd() + os.chdir(os.path.join("homeassistant", "components")) + + translations = { + 'component': {} + } + + paths = find_strings_files() + for path in paths: + component, platform = get_component_platform(path) + parent = get_translation_dict(translations, component, platform) + strings = json_util.load_json(path) + parent.update(strings) + + os.chdir(root) + + os.makedirs("build", exist_ok=True) + + json_util.save_json( + os.path.join("build", "translations-upload.json"), translations) + + +if __name__ == '__main__': + main() diff --git a/script/travis_deploy b/script/travis_deploy new file mode 100755 index 00000000000..359f6a46077 --- /dev/null +++ b/script/travis_deploy @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# Safe bash settings +# -e Exit on command fail +# -u Exit on unset variable +# -o pipefail Exit if piped command has error code +set -eu -o pipefail + +cd "$(dirname "$0")/.." + +script/translations_upload diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py new file mode 100644 index 00000000000..840f665f410 --- /dev/null +++ b/tests/helpers/test_translation.py @@ -0,0 +1,108 @@ +"""Test the translation helper.""" +# pylint: disable=protected-access +from os import path + +import homeassistant.helpers.translation as translation +from homeassistant.setup import async_setup_component + + +def test_flatten(): + """Test the flatten function.""" + data = { + "parent1": { + "child1": "data1", + "child2": "data2", + }, + "parent2": "data3", + } + + flattened = translation.flatten(data) + + assert flattened == { + "parent1.child1": "data1", + "parent1.child2": "data2", + "parent2": "data3", + } + + +async def test_component_translation_file(hass): + """Test the component translation file function.""" + assert await async_setup_component(hass, 'switch', { + 'switch': {'platform': 'test'} + }) + assert await async_setup_component(hass, 'test_standalone', { + 'test_standalone' + }) + assert await async_setup_component(hass, 'test_package', { + 'test_package' + }) + + assert path.normpath(translation.component_translation_file( + 'switch.test', 'en')) == path.normpath(hass.config.path( + 'custom_components', 'switch', '.translations', 'test.en.json')) + + assert path.normpath(translation.component_translation_file( + 'test_standalone', 'en')) == path.normpath(hass.config.path( + 'custom_components', '.translations', 'test_standalone.en.json')) + + assert path.normpath(translation.component_translation_file( + 'test_package', 'en')) == path.normpath(hass.config.path( + 'custom_components', 'test_package', '.translations', 'en.json')) + + +def test_load_translations_files(hass): + """Test the load translation files function.""" + # Test one valid and one invalid file + file1 = hass.config.path( + 'custom_components', 'switch', '.translations', 'test.en.json') + file2 = hass.config.path( + 'custom_components', 'switch', '.translations', 'invalid.json') + assert translation.load_translations_files({ + 'switch.test': file1, + 'invalid': file2 + }) == { + 'switch.test': { + 'state': { + 'string1': 'Value 1', + 'string2': 'Value 2', + } + }, + 'invalid': {}, + } + + +async def test_get_translations(hass): + """Test the get translations helper.""" + translations = await translation.async_get_translations(hass, 'en') + assert translations == {} + + assert await async_setup_component(hass, 'switch', { + 'switch': {'platform': 'test'} + }) + + translations = await translation.async_get_translations(hass, 'en') + assert translations == { + 'component.switch.state.string1': 'Value 1', + 'component.switch.state.string2': 'Value 2', + } + + translations = await translation.async_get_translations(hass, 'de') + assert translations == { + 'component.switch.state.string1': 'German Value 1', + 'component.switch.state.string2': 'German Value 2', + } + + # Test a partial translation + translations = await translation.async_get_translations(hass, 'es') + assert translations == { + 'component.switch.state.string1': 'Spanish Value 1', + 'component.switch.state.string2': 'Value 2', + } + + # Test that an untranslated language falls back to English. + translations = await translation.async_get_translations( + hass, 'invalid-language') + assert translations == { + 'component.switch.state.string1': 'Value 1', + 'component.switch.state.string2': 'Value 2', + } diff --git a/tests/testing_config/custom_components/switch/.translations/test.de.json b/tests/testing_config/custom_components/switch/.translations/test.de.json new file mode 100644 index 00000000000..fad78b12d63 --- /dev/null +++ b/tests/testing_config/custom_components/switch/.translations/test.de.json @@ -0,0 +1,6 @@ +{ + "state": { + "string1": "German Value 1", + "string2": "German Value 2" + } +} diff --git a/tests/testing_config/custom_components/switch/.translations/test.en.json b/tests/testing_config/custom_components/switch/.translations/test.en.json new file mode 100644 index 00000000000..f4ce728af05 --- /dev/null +++ b/tests/testing_config/custom_components/switch/.translations/test.en.json @@ -0,0 +1,6 @@ +{ + "state": { + "string1": "Value 1", + "string2": "Value 2" + } +} diff --git a/tests/testing_config/custom_components/switch/.translations/test.es.json b/tests/testing_config/custom_components/switch/.translations/test.es.json new file mode 100644 index 00000000000..b3590a6d321 --- /dev/null +++ b/tests/testing_config/custom_components/switch/.translations/test.es.json @@ -0,0 +1,5 @@ +{ + "state": { + "string1": "Spanish Value 1" + } +} diff --git a/tests/testing_config/custom_components/test_package/__init__.py b/tests/testing_config/custom_components/test_package/__init__.py new file mode 100644 index 00000000000..528f056948b --- /dev/null +++ b/tests/testing_config/custom_components/test_package/__init__.py @@ -0,0 +1,7 @@ +"""Provide a mock package component.""" +DOMAIN = 'test_package' + + +def setup(hass, config): + """Mock a successful setup.""" + return True diff --git a/tests/testing_config/custom_components/test_standalone.py b/tests/testing_config/custom_components/test_standalone.py new file mode 100644 index 00000000000..f0d4ba7982b --- /dev/null +++ b/tests/testing_config/custom_components/test_standalone.py @@ -0,0 +1,7 @@ +"""Provide a mock standalone component.""" +DOMAIN = 'test_standalone' + + +def setup(hass, config): + """Mock a successful setup.""" + return True From 9f35d4dfcaf067e5b975fba53e35ba7a2c194c09 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 28 Feb 2018 23:04:20 -0500 Subject: [PATCH 086/191] Translation cleanup (#12804) * Inline load/save JSON * Skip cleanup on travis deploy --- .travis.yml | 1 + script/translations_download_split.py | 33 +++++++++++++++++++++++---- script/translations_upload_merge.py | 31 +++++++++++++++++++++---- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index c1d70d528b3..48fb7b19560 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,7 @@ services: before_deploy: - docker pull lokalise/lokalise-cli deploy: + skip_cleanup: true provider: script script: script/travis_deploy on: diff --git a/script/translations_download_split.py b/script/translations_download_split.py index 08ea9fbcccc..03718cf7cab 100755 --- a/script/translations_download_split.py +++ b/script/translations_download_split.py @@ -1,14 +1,37 @@ #!/usr/bin/env python3 """Merge all translation sources into a single JSON file.""" import glob +import json import os import re - -from homeassistant.util import json as json_util +from typing import Union, List, Dict FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json') +def load_json(filename: str) \ + -> Union[List, Dict]: + """Load JSON data from a file and return as dict or list. + + Defaults to returning empty dict if file is not found. + """ + with open(filename, encoding='utf-8') as fdesc: + return json.loads(fdesc.read()) + return {} + + +def save_json(filename: str, data: Union[List, Dict]): + """Save JSON data to a file. + + Returns True on success. + """ + data = json.dumps(data, sort_keys=True, indent=4) + with open(filename, 'w', encoding='utf-8') as fdesc: + fdesc.write(data) + return True + return False + + def get_language(path): """Get the language code for the given file path.""" return os.path.splitext(os.path.basename(path))[0] @@ -55,13 +78,13 @@ def save_language_translations(lang, translations): if base_translations: path = get_component_path(lang, component) os.makedirs(os.path.dirname(path), exist_ok=True) - json_util.save_json(path, base_translations) + save_json(path, base_translations) for platform, platform_translations in component_translations.get( 'platform', {}).items(): path = get_platform_path(lang, component, platform) os.makedirs(os.path.dirname(path), exist_ok=True) - json_util.save_json(path, platform_translations) + save_json(path, platform_translations) def main(): @@ -73,7 +96,7 @@ def main(): paths = glob.iglob("build/translations-download/*.json") for path in paths: lang = get_language(path) - translations = json_util.load_json(path) + translations = load_json(path) save_language_translations(lang, translations) diff --git a/script/translations_upload_merge.py b/script/translations_upload_merge.py index 6382c8d9abe..450a4c9ba0f 100755 --- a/script/translations_upload_merge.py +++ b/script/translations_upload_merge.py @@ -2,14 +2,37 @@ """Merge all translation sources into a single JSON file.""" import glob import itertools +import json import os import re - -from homeassistant.util import json as json_util +from typing import Union, List, Dict FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json') +def load_json(filename: str) \ + -> Union[List, Dict]: + """Load JSON data from a file and return as dict or list. + + Defaults to returning empty dict if file is not found. + """ + with open(filename, encoding='utf-8') as fdesc: + return json.loads(fdesc.read()) + return {} + + +def save_json(filename: str, data: Union[List, Dict]): + """Save JSON data to a file. + + Returns True on success. + """ + data = json.dumps(data, sort_keys=True, indent=4) + with open(filename, 'w', encoding='utf-8') as fdesc: + fdesc.write(data) + return True + return False + + def find_strings_files(): """Return the paths of the strings source files.""" return itertools.chain( @@ -66,14 +89,14 @@ def main(): for path in paths: component, platform = get_component_platform(path) parent = get_translation_dict(translations, component, platform) - strings = json_util.load_json(path) + strings = load_json(path) parent.update(strings) os.chdir(root) os.makedirs("build", exist_ok=True) - json_util.save_json( + save_json( os.path.join("build", "translations-upload.json"), translations) From 4867ed23dcf8e50708c8399625ee3b6366d8ac53 Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Wed, 28 Feb 2018 23:05:37 -0500 Subject: [PATCH 087/191] Take ownership of Emby, Eight Sleep, Hikvision (#12803) --- CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index a5b5cfcb32c..fedab8f6ae4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -43,6 +43,7 @@ homeassistant/components/hassio.py @home-assistant/hassio # Individual components homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 @@ -54,6 +55,7 @@ homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti +homeassistant/components/media_player/emby.py @mezz64 homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko @@ -77,6 +79,8 @@ homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/axis.py @kane610 homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen +homeassistant/components/eight_sleep.py @mezz64 +homeassistant/components/*/eight_sleep.py @mezz64 homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline homeassistant/components/homekit/* @cdce8p From ed85e368e32d2501b5ad9fa9cabe2e878c6ada07 Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Wed, 28 Feb 2018 23:33:03 -0500 Subject: [PATCH 088/191] Bump pyHik version, digest auth, more device support (#12801) --- homeassistant/components/binary_sensor/hikvision.py | 5 ++++- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 36ec8b7b61a..e87fcb7980a 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.4'] +REQUIREMENTS = ['pyhik==0.1.8'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -48,6 +48,9 @@ DEVICE_CLASS_MAP = { 'Face Detection': 'motion', 'Scene Change Detection': 'motion', 'I/O': None, + 'Unattended Baggage': 'motion', + 'Attended Baggage': 'motion', + 'Recording Failure': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index 90c00310eaf..4e34dce7a19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -736,7 +736,7 @@ pyfttt==0.3 pyharmony==1.0.20 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.4 +pyhik==0.1.8 # homeassistant.components.hive pyhiveapi==0.2.11 From dbef8f0b78f23e3bcf33dd84041dec637b701fd8 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 28 Feb 2018 23:37:40 -0500 Subject: [PATCH 089/191] Only run deploy from lint branch (#12805) --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 48fb7b19560..cd068461392 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,4 +38,5 @@ deploy: script: script/travis_deploy on: branch: dev + condition: $TOXENV = lint after_success: coveralls From 491b3d707cd34cfe22f297c2cd175dcafb85f4cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 1 Mar 2018 07:35:12 -0800 Subject: [PATCH 090/191] Add optional words to conversation utterances (#12772) * Add optional words to conversation utterances * Conversation to handle singular/plural * Remove print * Add pronounce detection to shopping list * Lint * fix tests * Add optional 2 words * Fix tests * Conversation: coroutine -> async/await * Replace \s with space --- homeassistant/components/__init__.py | 5 +- homeassistant/components/conversation.py | 71 +++++++++++++++-------- homeassistant/components/shopping_list.py | 2 +- homeassistant/helpers/intent.py | 3 +- tests/components/test_conversation.py | 55 +++++++++++++++++- tests/components/test_init.py | 6 +- 6 files changed, 111 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 6b306adad5b..f0c4f7bb3e2 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -156,9 +156,10 @@ def async_setup(hass, config): hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, + "Turned {} off")) hass.helpers.intent.async_register(intent.ServiceIntentHandler( intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 9f325f3eb89..e96694ce0a3 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -4,7 +4,6 @@ Support for functionality to have conversations with Home Assistant. For more details about this component, please refer to the documentation at https://home-assistant.io/components/conversation/ """ -import asyncio import logging import re @@ -67,8 +66,7 @@ def async_register(hass, intent_type, utterances): conf.append(_create_matcher(utterance)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Register the process service.""" config = config.get(DOMAIN, {}) intents = hass.data.get(DOMAIN) @@ -84,49 +82,73 @@ def async_setup(hass, config): conf.extend(_create_matcher(utterance) for utterance in utterances) - @asyncio.coroutine - def process(service): + async def process(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] - yield from _process(hass, text) + try: + await _process(hass, text) + except intent.IntentHandleError as err: + _LOGGER.error('Error processing %s: %s', text, err) hass.services.async_register( DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) hass.http.register_view(ConversationProcessView) - async_register(hass, intent.INTENT_TURN_ON, - ['Turn {name} on', 'Turn on {name}']) - async_register(hass, intent.INTENT_TURN_OFF, - ['Turn {name} off', 'Turn off {name}']) - async_register(hass, intent.INTENT_TOGGLE, - ['Toggle {name}', '{name} toggle']) + # We strip trailing 's' from name because our state matcher will fail + # if a letter is not there. By removing 's' we can match singular and + # plural names. + + async_register(hass, intent.INTENT_TURN_ON, [ + 'Turn [the] [a] {name}[s] on', + 'Turn on [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TURN_OFF, [ + 'Turn [the] [a] [an] {name}[s] off', + 'Turn off [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TOGGLE, [ + 'Toggle [the] [a] [an] {name}[s]', + '[the] [a] [an] {name}[s] toggle', + ]) return True def _create_matcher(utterance): """Create a regex that matches the utterance.""" - parts = re.split(r'({\w+})', utterance) + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') pattern = ['^'] - for part in parts: - match = group_matcher.match(part) + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) - if match is None: + # Normal part + if group_match is None and optional_match is None: pattern.append(part) continue - pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) pattern.append('$') return re.compile(''.join(pattern), re.I) -@asyncio.coroutine -def _process(hass, text): +async def _process(hass, text): """Process a line of text.""" intents = hass.data.get(DOMAIN, {}) @@ -137,7 +159,7 @@ def _process(hass, text): if not match: continue - response = yield from hass.helpers.intent.async_handle( + response = await hass.helpers.intent.async_handle( DOMAIN, intent_type, {key: {'value': value} for key, value in match.groupdict().items()}, text) @@ -153,12 +175,15 @@ class ConversationProcessView(http.HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('text'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Send a request for processing.""" hass = request.app['hass'] - intent_result = yield from _process(hass, data['text']) + try: + intent_result = await _process(hass, data['text']) + except intent.IntentHandleError as err: + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) if intent_result is None: intent_result = intent.IntentResponse() diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 2452188a889..da3e2e7147d 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -43,7 +43,7 @@ def async_setup(hass, config): hass.http.register_view(ClearCompletedItemsView) hass.components.conversation.async_register(INTENT_ADD_ITEM, [ - 'Add {item} to my shopping list', + 'Add [the] [a] [an] {item} to my shopping list', ]) hass.components.conversation.async_register(INTENT_LAST_ITEMS, [ 'What is on my shopping list' diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index dac0f4c507b..5aa53f17e7b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -100,7 +100,8 @@ def async_match_state(hass, name, states=None): state = _fuzzymatch(name, states, lambda state: state.name) if state is None: - raise IntentHandleError('Unable to find entity {}'.format(name)) + raise IntentHandleError( + 'Unable to find an entity called {}'.format(name)) return state diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 8d629321853..466dc57017a 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -237,7 +237,7 @@ def test_http_api(hass, test_client): calls = async_mock_service(hass, 'homeassistant', 'turn_on') resp = yield from client.post('/api/conversation/process', json={ - 'text': 'Turn kitchen on' + 'text': 'Turn the kitchen on' }) assert resp.status == 200 @@ -267,3 +267,56 @@ def test_http_api_wrong_data(hass, test_client): resp = yield from client.post('/api/conversation/process', json={ }) assert resp.status == 400 + + +def test_create_matcher(): + """Test the create matcher method.""" + # Basic sentence + pattern = conversation._create_matcher('Hello world') + assert pattern.match('Hello world') is not None + + # Match a part + pattern = conversation._create_matcher('Hello {name}') + match = pattern.match('hello world') + assert match is not None + assert match.groupdict()['name'] == 'world' + no_match = pattern.match('Hello world, how are you?') + assert no_match is None + + # Optional and matching part + pattern = conversation._create_matcher('Turn on [the] {name}') + match = pattern.match('turn on the kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn off kitchen lights') + assert match is None + + # Two different optional parts, 1 matching part + pattern = conversation._create_matcher('Turn on [the] [a] {name}') + match = pattern.match('turn on the kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on a kitchen light') + assert match is not None + assert match.groupdict()['name'] == 'kitchen light' + + # Strip plural + pattern = conversation._create_matcher('Turn {name}[s] on') + match = pattern.match('turn kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen light' + + # Optional 2 words + pattern = conversation._create_matcher('Turn [the great] {name} on') + match = pattern.match('turn the great kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 2005f658a71..eca4763b4b3 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -212,7 +212,7 @@ def test_turn_on_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test light' + assert response.speech['plain']['speech'] == 'Turned test light on' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' @@ -234,7 +234,7 @@ def test_turn_off_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned off test light' + assert response.speech['plain']['speech'] == 'Turned test light off' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' @@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test lights 2' + assert response.speech['plain']['speech'] == 'Turned test lights 2 on' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' From 88021ba40476877b7bb017642dafca1649d28d69 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 1 Mar 2018 16:05:18 +0000 Subject: [PATCH 091/191] Bump pyloopenergy to 0.0.18. Fixes hassio connect issues. --- homeassistant/components/sensor/loopenergy.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 5be24b1532c..8bf95d4ef6e 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -17,7 +17,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyloopenergy==0.0.17'] +REQUIREMENTS = ['pyloopenergy==0.0.18'] CONF_ELEC = 'electricity' CONF_GAS = 'gas' diff --git a/requirements_all.txt b/requirements_all.txt index 4e34dce7a19..ab1583c8cd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ pylgtv==0.1.7 pylitejet==0.1 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.17 +pyloopenergy==0.0.18 # homeassistant.components.lutron_caseta pylutron-caseta==0.3.0 From 17ba813a6dfb13866a20d10cf006d6c8c6535544 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Thu, 1 Mar 2018 18:02:19 +0100 Subject: [PATCH 092/191] Changed default from `all` to `changed` (#12660) * Default option now '--changed' * to check all files, use: '--all' or 'tox -e lint' --- script/lint | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/lint b/script/lint index a024562e824..bfce996788e 100755 --- a/script/lint +++ b/script/lint @@ -3,7 +3,9 @@ cd "$(dirname "$0")/.." -if [ "$1" = "--changed" ]; then +if [ "$1" = "--all" ]; then + tox -e lint +else export files="`git diff upstream/dev... --name-only | grep -e '\.py$'`" echo "=================================================" echo "FILES CHANGED (git diff upstream/dev... --name-only)" @@ -22,6 +24,4 @@ if [ "$1" = "--changed" ]; then echo "================" pylint $files echo -else - tox -e lint fi From ff83efe376fb6d42fe8b4fad763044cfb55d5e8c Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Thu, 1 Mar 2018 19:55:58 +0200 Subject: [PATCH 093/191] is_allowed_path: Also unit test folder #12788 #12807 (#12810) --- homeassistant/core.py | 2 +- tests/test_core.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 8ff9d9cfd81..543aba2a0e7 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1064,7 +1064,7 @@ class Config(object): """Check if the path is valid for access from outside.""" assert path is not None - parent = pathlib.Path(path).parent + parent = pathlib.Path(path) try: parent = parent.resolve() # pylint: disable=no-member except (FileNotFoundError, RuntimeError, PermissionError): diff --git a/tests/test_core.py b/tests/test_core.py index 77a7872526f..261b6385b04 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -809,6 +809,7 @@ class TestConfig(unittest.TestCase): valid = [ test_file, + tmp_dir ] for path in valid: assert self.config.is_allowed_path(path) From 23c39ebefd30677510ab9cec96b5172f6e419707 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 1 Mar 2018 11:47:56 -0800 Subject: [PATCH 094/191] Fix flakiness in tests (#12806) --- .coveragerc | 11 +++++----- tests/components/device_tracker/test_ddwrt.py | 22 ++++++++----------- tests/components/test_pilight.py | 3 +++ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.coveragerc b/.coveragerc index c1a5bc8d3a6..640db5765a2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -62,6 +62,9 @@ omit = homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py + homeassistant/components/daikin.py + homeassistant/components/*/daikin.py + homeassistant/components/deconz/* homeassistant/components/*/deconz.py @@ -181,6 +184,9 @@ omit = homeassistant/components/opencv.py homeassistant/components/*/opencv.py + homeassistant/components/pilight.py + homeassistant/components/*/pilight.py + homeassistant/components/qwikswitch.py homeassistant/components/*/qwikswitch.py @@ -296,9 +302,6 @@ omit = homeassistant/components/zoneminder.py homeassistant/components/*/zoneminder.py - homeassistant/components/daikin.py - homeassistant/components/*/daikin.py - homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py @@ -315,7 +318,6 @@ omit = homeassistant/components/binary_sensor/hikvision.py homeassistant/components/binary_sensor/iss.py homeassistant/components/binary_sensor/mystrom.py - homeassistant/components/binary_sensor/pilight.py homeassistant/components/binary_sensor/ping.py homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py @@ -682,7 +684,6 @@ omit = homeassistant/components/switch/mystrom.py homeassistant/components/switch/netio.py homeassistant/components/switch/orvibo.py - homeassistant/components/switch/pilight.py homeassistant/components/switch/pulseaudio_loopback.py homeassistant/components/switch/rainbird.py homeassistant/components/switch/rainmachine.py diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py index c66029b5fca..57aeba5b9a5 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -132,13 +132,15 @@ class TestDdwrt(unittest.TestCase): to the DD-WRT Lan Status request response fixture. This effectively checks the data parsing functions. """ + status_lan = load_fixture('Ddwrt_Status_Lan.txt') + with requests_mock.Mocker() as mock_request: mock_request.register_uri( 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, text=load_fixture('Ddwrt_Status_Wireless.txt')) mock_request.register_uri( 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt')) + text=status_lan) with assert_setup_component(1, DOMAIN): assert setup_component( @@ -153,12 +155,8 @@ class TestDdwrt(unittest.TestCase): path = self.hass.config.path(device_tracker.YAML_DEVICES) devices = config.load_yaml_config_file(path) for device in devices: - self.assertIn( - devices[device]['mac'], - load_fixture('Ddwrt_Status_Lan.txt')) - self.assertIn( - slugify(devices[device]['name']), - load_fixture('Ddwrt_Status_Lan.txt')) + self.assertIn(devices[device]['mac'], status_lan) + self.assertIn(slugify(devices[device]['name']), status_lan) def test_device_name_no_data(self): """Test creating device info (MAC only) when no response.""" @@ -181,11 +179,10 @@ class TestDdwrt(unittest.TestCase): path = self.hass.config.path(device_tracker.YAML_DEVICES) devices = config.load_yaml_config_file(path) + status_lan = load_fixture('Ddwrt_Status_Lan.txt') for device in devices: _LOGGER.error(devices[device]) - self.assertIn( - devices[device]['mac'], - load_fixture('Ddwrt_Status_Lan.txt')) + self.assertIn(devices[device]['mac'], status_lan) def test_device_name_no_dhcp(self): """Test creating device info (MAC) when missing dhcp response.""" @@ -210,11 +207,10 @@ class TestDdwrt(unittest.TestCase): path = self.hass.config.path(device_tracker.YAML_DEVICES) devices = config.load_yaml_config_file(path) + status_lan = load_fixture('Ddwrt_Status_Lan.txt') for device in devices: _LOGGER.error(devices[device]) - self.assertIn( - devices[device]['mac'], - load_fixture('Ddwrt_Status_Lan.txt')) + self.assertIn(devices[device]['mac'], status_lan) def test_update_no_data(self): """Test error handling of no response when active devices checked.""" diff --git a/tests/components/test_pilight.py b/tests/components/test_pilight.py index 010136ee0e7..06ad84e7a34 100644 --- a/tests/components/test_pilight.py +++ b/tests/components/test_pilight.py @@ -5,6 +5,8 @@ from unittest.mock import patch import socket from datetime import timedelta +import pytest + from homeassistant import core as ha from homeassistant.setup import setup_component from homeassistant.components import pilight @@ -63,6 +65,7 @@ class PilightDaemonSim: _LOGGER.error('PilightDaemonSim callback: ' + str(function)) +@pytest.mark.skip("Flaky") class TestPilight(unittest.TestCase): """Test the Pilight component.""" From b186b2760049770756e11b00a029b1f4b4d89031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 1 Mar 2018 23:15:27 +0100 Subject: [PATCH 095/191] Tibber: retry if we fail to connect at startup (#12620) * Tibber: retry if we fail to connect at startup * Tibber: retry if we fail to connect at startup * Tibber: retry if we fail to connect at startup * Tibber: retry if we fail to connect at startup * Update tibber.py * Update tibber.py --- homeassistant/components/sensor/tibber.py | 24 ++++++++++++++--------- requirements_all.txt | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 519ff05cbd8..e56d5595e32 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -9,16 +9,18 @@ import asyncio import logging from datetime import timedelta +import aiohttp import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -REQUIREMENTS = ['pyTibber==0.2.1'] +REQUIREMENTS = ['pyTibber==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -33,14 +35,18 @@ SCAN_INTERVAL = timedelta(minutes=1) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Tibber sensor.""" - import Tibber - tibber = Tibber.Tibber(config[CONF_ACCESS_TOKEN], - websession=async_get_clientsession(hass)) - yield from tibber.update_info() - dev = [] - for home in tibber.get_homes(): - yield from home.update_info() - dev.append(TibberSensor(home)) + import tibber + tibber_connection = tibber.Tibber(config[CONF_ACCESS_TOKEN], + websession=async_get_clientsession(hass)) + + try: + yield from tibber_connection.update_info() + dev = [] + for home in tibber_connection.get_homes(): + yield from home.update_info() + dev.append(TibberSensor(home)) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() from None async_add_devices(dev, True) diff --git a/requirements_all.txt b/requirements_all.txt index ab1583c8cd1..8bbf005c0d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ pyHS100==0.3.0 pyRFXtrx==0.21.1 # homeassistant.components.sensor.tibber -pyTibber==0.2.1 +pyTibber==0.3.0 # homeassistant.components.switch.dlink pyW215==0.6.0 From b9d87897716ea4b0c37610ede9ef32d2ca6170a6 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Thu, 1 Mar 2018 23:25:52 +0100 Subject: [PATCH 096/191] Cast Python Async Await Syntax (#12816) --- homeassistant/components/media_player/cast.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index d3cf2f7b501..635951590ad 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.cast/ """ # pylint: disable=import-error -import asyncio import logging import threading import functools @@ -135,9 +134,8 @@ def _async_create_cast_device(hass, chromecast): return None -@asyncio.coroutine -def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_devices, discovery_info=None): +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the cast platform.""" import pychromecast @@ -187,7 +185,7 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType, try: func = functools.partial(pychromecast.Chromecast, *want_host, tries=SOCKET_CLIENT_RETRIES) - chromecast = yield from hass.async_add_job(func) + chromecast = await hass.async_add_job(func) except pychromecast.ChromecastConnectionError as err: _LOGGER.warning("Can't set up chromecast on %s: %s", want_host[0], err) @@ -439,8 +437,7 @@ class CastDevice(MediaPlayerDevice): self.cast_status = self.cast.status self.media_status = self.cast.media_controller.status - @asyncio.coroutine - def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" self._async_disconnect() From de3c76983a296192a5a95443c86841f24340cd3b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Mar 2018 23:03:01 +0000 Subject: [PATCH 097/191] Filter Sensor (#12650) * filter sensor platform implementation * added tests * default arguments * Fix for unavailable units during initial startup * unused variable * Addresses code review by @MartinHjelmare * fix * don't need hass in this test * Various Improvements * Added Throttle Filter * hound fixes * test throttle filter * fix * Address comments by @balloob * added test, reformulated filter tests * Precision handling * address comments from @balloob * Revert "Precision handling" This reverts commit f4abdd37021380d090b93a5c6b6f9e39cd59bc46. * removed stats * only round floats * Registry decorator usage * Tries to address remaining comments --- homeassistant/components/sensor/filter.py | 299 ++++++++++++++++++++++ tests/components/sensor/test_filter.py | 92 +++++++ 2 files changed, 391 insertions(+) create mode 100644 homeassistant/components/sensor/filter.py create mode 100644 tests/components/sensor/test_filter.py diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py new file mode 100644 index 00000000000..cde50699b29 --- /dev/null +++ b/homeassistant/components/sensor/filter.py @@ -0,0 +1,299 @@ +""" +Allows the creation of a sensor that filters state property. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.filter/ +""" +import logging +import statistics +from collections import deque, Counter +from numbers import Number + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, ATTR_ENTITY_ID, + ATTR_ICON, STATE_UNKNOWN, STATE_UNAVAILABLE) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.decorator import Registry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +FILTER_NAME_LOWPASS = 'lowpass' +FILTER_NAME_OUTLIER = 'outlier' +FILTER_NAME_THROTTLE = 'throttle' +FILTERS = Registry() + +CONF_FILTERS = 'filters' +CONF_FILTER_NAME = 'filter' +CONF_FILTER_WINDOW_SIZE = 'window_size' +CONF_FILTER_PRECISION = 'precision' +CONF_FILTER_RADIUS = 'radius' +CONF_FILTER_TIME_CONSTANT = 'time_constant' + +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 + +NAME_TEMPLATE = "{} filter" +ICON = 'mdi:chart-line-variant' + +FILTER_SCHEMA = vol.Schema({ + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), + vol.Optional(CONF_FILTER_PRECISION, + default=DEFAULT_PRECISION): vol.Coerce(int), +}) + +FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, + vol.Optional(CONF_FILTER_RADIUS, + default=DEFAULT_FILTER_RADIUS): vol.Coerce(float), +}) + +FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS, + vol.Optional(CONF_FILTER_TIME_CONSTANT, + default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), +}) + +FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_FILTERS): vol.All(cv.ensure_list, + [vol.Any(FILTER_OUTLIER_SCHEMA, + FILTER_LOWPASS_SCHEMA, + FILTER_THROTTLE_SCHEMA)]) +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the template sensors.""" + name = config.get(CONF_NAME) + entity_id = config.get(CONF_ENTITY_ID) + + filters = [FILTERS[_filter.pop(CONF_FILTER_NAME)]( + entity=entity_id, **_filter) + for _filter in config[CONF_FILTERS]] + + async_add_devices([SensorFilter(name, entity_id, filters)]) + + +class SensorFilter(Entity): + """Representation of a Filter Sensor.""" + + def __init__(self, name, entity_id, filters): + """Initialize the sensor.""" + self._name = name + self._entity = entity_id + self._unit_of_measurement = None + self._state = None + self._filters = filters + self._icon = None + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def filter_sensor_state_listener(entity, old_state, new_state): + """Handle device state changes.""" + if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return + + temp_state = new_state.state + + try: + for filt in self._filters: + filtered_state = filt.filter_state(temp_state) + _LOGGER.debug("%s(%s=%s) -> %s", filt.name, + self._entity, + temp_state, + "skip" if filt.skip_processing else + filtered_state) + if filt.skip_processing: + return + temp_state = filtered_state + except ValueError: + _LOGGER.error("Could not convert state: %s to number", + self._state) + return + + self._state = temp_state + + if self._icon is None: + self._icon = new_state.attributes.get( + ATTR_ICON, ICON) + + if self._unit_of_measurement is None: + self._unit_of_measurement = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT) + + self.async_schedule_update_ha_state() + + async_track_state_change( + self.hass, self._entity, filter_sensor_state_listener) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_ENTITY_ID: self._entity + } + return state_attr + + +class Filter(object): + """Filter skeleton. + + Args: + window_size (int): size of the sliding window that holds previous + values + precision (int): round filtered value to precision value + entity (string): used for debugging only + """ + + def __init__(self, name, window_size=1, precision=None, entity=None): + """Initialize common attributes.""" + self.states = deque(maxlen=window_size) + self.precision = precision + self._name = name + self._entity = entity + self._skip_processing = False + + @property + def name(self): + """Return filter name.""" + return self._name + + @property + def skip_processing(self): + """Return wether the current filter_state should be skipped.""" + return self._skip_processing + + def _filter_state(self, new_state): + """Implement filter.""" + raise NotImplementedError() + + def filter_state(self, new_state): + """Implement a common interface for filters.""" + filtered = self._filter_state(new_state) + if isinstance(filtered, Number): + filtered = round(float(filtered), self.precision) + self.states.append(filtered) + return filtered + + +@FILTERS.register(FILTER_NAME_OUTLIER) +class OutlierFilter(Filter): + """BASIC outlier filter. + + Determines if new state is in a band around the median. + + Args: + radius (float): band radius + """ + + def __init__(self, window_size, precision, entity, radius): + """Initialize Filter.""" + super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + self._radius = radius + self._stats_internal = Counter() + + def _filter_state(self, new_state): + """Implement the outlier filter.""" + new_state = float(new_state) + + if (self.states and + abs(new_state - statistics.median(self.states)) + > self._radius): + + self._stats_internal['erasures'] += 1 + + _LOGGER.debug("Outlier nr. %s in %s: %s", + self._stats_internal['erasures'], + self._entity, new_state) + return self.states[-1] + return new_state + + +@FILTERS.register(FILTER_NAME_LOWPASS) +class LowPassFilter(Filter): + """BASIC Low Pass Filter. + + Args: + time_constant (int): time constant. + """ + + def __init__(self, window_size, precision, entity, time_constant): + """Initialize Filter.""" + super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + self._time_constant = time_constant + + def _filter_state(self, new_state): + """Implement the low pass filter.""" + new_state = float(new_state) + + if not self.states: + return new_state + + new_weight = 1.0 / self._time_constant + prev_weight = 1.0 - new_weight + filtered = prev_weight * self.states[-1] + new_weight * new_state + + return filtered + + +@FILTERS.register(FILTER_NAME_THROTTLE) +class ThrottleFilter(Filter): + """Throttle Filter. + + One sample per window. + """ + + def __init__(self, window_size, precision, entity): + """Initialize Filter.""" + super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + + def _filter_state(self, new_state): + """Implement the throttle filter.""" + if not self.states or len(self.states) == self.states.maxlen: + self.states.clear() + self._skip_processing = False + else: + self._skip_processing = True + + return new_state diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py new file mode 100644 index 00000000000..dd1112d65f8 --- /dev/null +++ b/tests/components/sensor/test_filter.py @@ -0,0 +1,92 @@ +"""The test for the data filter sensor platform.""" +import unittest + +from homeassistant.components.sensor.filter import ( + LowPassFilter, OutlierFilter, ThrottleFilter) +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, assert_setup_component + + +class TestFilterSensor(unittest.TestCase): + """Test the Data Filter sensor.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.values = [20, 19, 18, 21, 22, 0] + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_fail(self): + """Test if filter doesn't exist.""" + config = { + 'sensor': { + 'platform': 'filter', + 'entity_id': 'sensor.test_monitored', + 'filters': [{'filter': 'nonexisting'}] + } + } + with assert_setup_component(0): + assert setup_component(self.hass, 'sensor', config) + + def test_chain(self): + """Test if filter chaining works.""" + config = { + 'sensor': { + 'platform': 'filter', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'filters': [{ + 'filter': 'outlier', + 'radius': 4.0 + }, { + 'filter': 'lowpass', + 'window_size': 4, + 'time_constant': 10, + 'precision': 2 + }] + } + } + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', config) + + for value in self.values: + self.hass.states.set(config['sensor']['entity_id'], value) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual('20.25', state.state) + + def test_outlier(self): + """Test if outlier filter works.""" + filt = OutlierFilter(window_size=10, + precision=2, + entity=None, + radius=4.0) + for state in self.values: + filtered = filt.filter_state(state) + self.assertEqual(22, filtered) + + def test_lowpass(self): + """Test if lowpass filter works.""" + filt = LowPassFilter(window_size=10, + precision=2, + entity=None, + time_constant=10) + for state in self.values: + filtered = filt.filter_state(state) + self.assertEqual(18.05, filtered) + + def test_throttle(self): + """Test if lowpass filter works.""" + filt = ThrottleFilter(window_size=3, + precision=2, + entity=None) + filtered = [] + for state in self.values: + new_state = filt.filter_state(state) + if not filt.skip_processing: + filtered.append(new_state) + self.assertEqual([20, 21], filtered) From d3386907a4db22be84cac5c83cde3908042d15a0 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 2 Mar 2018 00:06:26 +0100 Subject: [PATCH 098/191] MQTT Python 3.5 Async Await Syntax (#12815) * MQTT Async Await * Remove unused decorator. --- homeassistant/components/mqtt/__init__.py | 92 ++++++++++------------- tests/components/mqtt/test_init.py | 1 + 2 files changed, 41 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 63662d2072d..27e7c0358ad 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -228,17 +228,16 @@ def publish_template(hass: HomeAssistantType, topic, payload_template, hass.services.call(DOMAIN, SERVICE_PUBLISH, data) -@asyncio.coroutine @bind_hass -def async_subscribe(hass: HomeAssistantType, topic: str, - msg_callback: MessageCallbackType, - qos: int = DEFAULT_QOS, - encoding: str = 'utf-8'): +async def async_subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, + qos: int = DEFAULT_QOS, + encoding: str = 'utf-8'): """Subscribe to an MQTT topic. Call the return value to unsubscribe. """ - async_remove = yield from hass.data[DATA_MQTT].async_subscribe( + async_remove = await hass.data[DATA_MQTT].async_subscribe( topic, msg_callback, qos, encoding) return async_remove @@ -259,16 +258,15 @@ def subscribe(hass: HomeAssistantType, topic: str, return remove -@asyncio.coroutine -def _async_setup_server(hass: HomeAssistantType, - config: ConfigType): +async def _async_setup_server(hass: HomeAssistantType, + config: ConfigType): """Try to start embedded MQTT broker. This method is a coroutine. """ conf = config.get(DOMAIN, {}) # type: ConfigType - server = yield from async_prepare_setup_platform( + server = await async_prepare_setup_platform( hass, config, DOMAIN, 'server') if server is None: @@ -276,37 +274,35 @@ def _async_setup_server(hass: HomeAssistantType, return None success, broker_config = \ - yield from server.async_start(hass, conf.get(CONF_EMBEDDED)) + await server.async_start(hass, conf.get(CONF_EMBEDDED)) if not success: return None return broker_config -@asyncio.coroutine -def _async_setup_discovery(hass: HomeAssistantType, - config: ConfigType): +async def _async_setup_discovery(hass: HomeAssistantType, + config: ConfigType) -> bool: """Try to start the discovery of MQTT devices. This method is a coroutine. """ conf = config.get(DOMAIN, {}) # type: ConfigType - discovery = yield from async_prepare_setup_platform( + discovery = await async_prepare_setup_platform( hass, config, DOMAIN, 'discovery') if discovery is None: _LOGGER.error("Unable to load MQTT discovery") return False - success = yield from discovery.async_start( + success = await discovery.async_start( hass, conf[CONF_DISCOVERY_PREFIX], config) # type: bool return success -@asyncio.coroutine -def async_setup(hass: HomeAssistantType, config: ConfigType): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Start the MQTT protocol service.""" conf = config.get(DOMAIN) # type: Optional[ConfigType] @@ -321,7 +317,7 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): if CONF_EMBEDDED not in conf and CONF_BROKER in conf: broker_config = None else: - broker_config = yield from _async_setup_server(hass, config) + broker_config = await _async_setup_server(hass, config) if CONF_BROKER in conf: broker = conf[CONF_BROKER] # type: str @@ -392,19 +388,17 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): "Please check your settings and the broker itself") return False - @asyncio.coroutine - def async_stop_mqtt(event: Event): + async def async_stop_mqtt(event: Event): """Stop MQTT component.""" - yield from hass.data[DATA_MQTT].async_disconnect() + await hass.data[DATA_MQTT].async_disconnect() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) - success = yield from hass.data[DATA_MQTT].async_connect() # type: bool + success = await hass.data[DATA_MQTT].async_connect() # type: bool if not success: return False - @asyncio.coroutine - def async_publish_service(call: ServiceCall): + async def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" msg_topic = call.data[ATTR_TOPIC] # type: str payload = call.data.get(ATTR_PAYLOAD) @@ -422,7 +416,7 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): msg_topic, payload_template, exc) return - yield from hass.data[DATA_MQTT].async_publish( + await hass.data[DATA_MQTT].async_publish( msg_topic, payload, qos, retain) hass.services.async_register( @@ -430,7 +424,7 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): schema=MQTT_PUBLISH_SCHEMA) if conf.get(CONF_DISCOVERY): - yield from _async_setup_discovery(hass, config) + await _async_setup_discovery(hass, config) return True @@ -505,25 +499,23 @@ class MQTT(object): if will_message is not None: self._mqttc.will_set(*attr.astuple(will_message)) - @asyncio.coroutine - def async_publish(self, topic: str, payload: PublishPayloadType, qos: int, - retain: bool): + async def async_publish(self, topic: str, payload: PublishPayloadType, + qos: int, retain: bool) -> None: """Publish a MQTT message. This method must be run in the event loop and returns a coroutine. """ - with (yield from self._paho_lock): - yield from self.hass.async_add_job( + async with self._paho_lock: + await self.hass.async_add_job( self._mqttc.publish, topic, payload, qos, retain) - @asyncio.coroutine - def async_connect(self): + async def async_connect(self) -> bool: """Connect to the host. Does process messages yet. This method is a coroutine. """ result = None # type: int - result = yield from self.hass.async_add_job( + result = await self.hass.async_add_job( self._mqttc.connect, self.broker, self.port, self.keepalive) if result != 0: @@ -547,9 +539,9 @@ class MQTT(object): return self.hass.async_add_job(stop) - @asyncio.coroutine - def async_subscribe(self, topic: str, msg_callback: MessageCallbackType, - qos: int, encoding: str): + async def async_subscribe(self, topic: str, + msg_callback: MessageCallbackType, + qos: int, encoding: str) -> Callable[[], None]: """Set up a subscription to a topic with the provided qos. This method is a coroutine. @@ -560,10 +552,10 @@ class MQTT(object): subscription = Subscription(topic, msg_callback, qos, encoding) self.subscriptions.append(subscription) - yield from self._async_perform_subscription(topic, qos) + await self._async_perform_subscription(topic, qos) @callback - def async_remove(): + def async_remove() -> None: """Remove subscription.""" if subscription not in self.subscriptions: raise HomeAssistantError("Can't remove subscription twice") @@ -576,27 +568,24 @@ class MQTT(object): return async_remove - @asyncio.coroutine - def _async_unsubscribe(self, topic: str): + async def _async_unsubscribe(self, topic: str) -> None: """Unsubscribe from a topic. This method is a coroutine. """ - with (yield from self._paho_lock): + async with self._paho_lock: result = None # type: int - result, _ = yield from self.hass.async_add_job( + result, _ = await self.hass.async_add_job( self._mqttc.unsubscribe, topic) _raise_on_error(result) - @asyncio.coroutine - def _async_perform_subscription(self, topic: str, - qos: int): + async def _async_perform_subscription(self, topic: str, qos: int) -> None: """Perform a paho-mqtt subscription.""" _LOGGER.debug("Subscribing to %s", topic) - with (yield from self._paho_lock): + async with self._paho_lock: result = None # type: int - result, _ = yield from self.hass.async_add_job( + result, _ = await self.hass.async_add_job( self._mqttc.subscribe, topic, qos) _raise_on_error(result) @@ -721,8 +710,7 @@ class MqttAvailability(Entity): self._payload_available = payload_available self._payload_not_available = payload_not_available - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe mqtt events. This method must be run in the event loop and returns a coroutine. @@ -740,7 +728,7 @@ class MqttAvailability(Entity): self.async_schedule_update_ha_state() if self._availability_topic is not None: - yield from async_subscribe( + await async_subscribe( self.hass, self._availability_topic, availability_message_received, self._availability_qos) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 24308bc9a7e..1dd89a92f04 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -28,6 +28,7 @@ def async_mock_mqtt_client(hass, config=None): with mock.patch('paho.mqtt.client.Client') as mock_client: mock_client().connect.return_value = 0 mock_client().subscribe.return_value = (0, 0) + mock_client().unsubscribe.return_value = (0, 0) mock_client().publish.return_value = (0, 0) result = yield from async_setup_component(hass, mqtt.DOMAIN, { mqtt.DOMAIN: config From 168e1f0e2d0fb43a3bb96c935b36a014f04fd1fc Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 2 Mar 2018 00:20:02 +0100 Subject: [PATCH 099/191] Improved Homekit tests (#12800) * Added test for temperature fahrenheit * Restructured tests to use more mocks * Rearanged homekit constants * Improved 'test_homekit_class' * Added import statements * Fix Pylint Test errors --- homeassistant/components/homekit/__init__.py | 6 +- .../components/homekit/accessories.py | 17 +- homeassistant/components/homekit/const.py | 23 +-- homeassistant/components/homekit/covers.py | 3 + homeassistant/components/homekit/sensors.py | 1 + tests/components/homekit/test_accessories.py | 165 ++++++++++++++++++ tests/components/homekit/test_covers.py | 16 +- .../homekit/test_get_accessories.py | 21 ++- tests/components/homekit/test_homekit.py | 113 ++++++------ tests/components/homekit/test_sensors.py | 16 +- tests/mock/homekit.py | 133 ++++++++++++++ 11 files changed, 420 insertions(+), 94 deletions(-) create mode 100644 tests/components/homekit/test_accessories.py create mode 100644 tests/mock/homekit.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 77c69c14596..40d43e2e14c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -64,12 +64,10 @@ def async_setup(hass, config): def import_types(): - """Import all types from files in the HomeKit dir.""" + """Import all types from files in the HomeKit directory.""" _LOGGER.debug("Import type files.") # pylint: disable=unused-variable - from .covers import Window # noqa F401 - # pylint: disable=unused-variable - from .sensors import TemperatureSensor # noqa F401 + from . import covers, sensors # noqa F401 def get_accessory(hass, state): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index f2ad6067258..689bcb3377c 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -4,7 +4,7 @@ import logging from pyhap.accessory import Accessory, Bridge, Category from .const import ( - SERV_ACCESSORY_INFO, MANUFACTURER, + SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, MANUFACTURER, CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) @@ -46,17 +46,24 @@ def override_properties(char, new_properties): class HomeAccessory(Accessory): """Class to extend the Accessory class.""" - def __init__(self, display_name, model, category='OTHER'): + def __init__(self, display_name, model, category='OTHER', **kwargs): """Initialize a Accessory object.""" - super().__init__(display_name) + super().__init__(display_name, **kwargs) set_accessory_info(self, model) self.category = getattr(Category, category, Category.OTHER) + def _set_services(self): + add_preload_service(self, SERV_ACCESSORY_INFO) + class HomeBridge(Bridge): """Class to extend the Bridge class.""" - def __init__(self, display_name, model, pincode): + def __init__(self, display_name, model, pincode, **kwargs): """Initialize a Bridge object.""" - super().__init__(display_name, pincode=pincode) + super().__init__(display_name, pincode=pincode, **kwargs) set_accessory_info(self, model) + + def _set_services(self): + add_preload_service(self, SERV_ACCESSORY_INFO) + add_preload_service(self, SERV_BRIDGING_STATE) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index f514308c823..5201e21608a 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,21 +1,24 @@ """Constants used be the HomeKit component.""" MANUFACTURER = 'HomeAssistant' -# Service: AccessoryInfomation +# Services SERV_ACCESSORY_INFO = 'AccessoryInformation' -CHAR_MODEL = 'Model' -CHAR_MANUFACTURER = 'Manufacturer' -CHAR_SERIAL_NUMBER = 'SerialNumber' - -# Service: TemperatureSensor +SERV_BRIDGING_STATE = 'BridgingState' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' -CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' - -# Service: WindowCovering SERV_WINDOW_COVERING = 'WindowCovering' + +# Characteristics +CHAR_ACC_IDENTIFIER = 'AccessoryIdentifier' +CHAR_CATEGORY = 'Category' CHAR_CURRENT_POSITION = 'CurrentPosition' -CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' +CHAR_LINK_QUALITY = 'LinkQuality' +CHAR_MANUFACTURER = 'Manufacturer' +CHAR_MODEL = 'Model' CHAR_POSITION_STATE = 'PositionState' +CHAR_REACHABLE = 'Reachable' +CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_TARGET_POSITION = 'TargetPosition' # Properties PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} diff --git a/homeassistant/components/homekit/covers.py b/homeassistant/components/homekit/covers.py index e9fc3b08d76..47713f6c630 100644 --- a/homeassistant/components/homekit/covers.py +++ b/homeassistant/components/homekit/covers.py @@ -38,6 +38,9 @@ class Window(HomeAccessory): get_characteristic(CHAR_TARGET_POSITION) self.char_position_state = self.serv_cover. \ get_characteristic(CHAR_POSITION_STATE) + self.char_current_position.value = 0 + self.char_target_position.value = 0 + self.char_position_state.value = 0 self.char_target_position.setter_callback = self.move_cover diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py index 968f6834b1d..40f97ae3ef7 100644 --- a/homeassistant/components/homekit/sensors.py +++ b/homeassistant/components/homekit/sensors.py @@ -47,6 +47,7 @@ class TemperatureSensor(HomeAccessory): self.char_temp = self.serv_temp. \ get_characteristic(CHAR_CURRENT_TEMPERATURE) override_properties(self.char_temp, PROP_CELSIUS) + self.char_temp.value = 0 self.unit = None def run(self): diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py new file mode 100644 index 00000000000..a45aa82d981 --- /dev/null +++ b/tests/components/homekit/test_accessories.py @@ -0,0 +1,165 @@ +"""Test all functions related to the basic accessory implementation. + +This includes tests for all mock object types. +""" + +from unittest.mock import patch + +# pylint: disable=unused-import +from pyhap.loader import get_serv_loader, get_char_loader # noqa F401 + +from homeassistant.components.homekit.accessories import ( + set_accessory_info, add_preload_service, override_properties, + HomeAccessory, HomeBridge) +from homeassistant.components.homekit.const import ( + SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, + CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) + +from tests.mock.homekit import ( + get_patch_paths, mock_preload_service, + MockTypeLoader, MockAccessory, MockService, MockChar) + +PATH_SERV = 'pyhap.loader.get_serv_loader' +PATH_CHAR = 'pyhap.loader.get_char_loader' +PATH_ACC, _ = get_patch_paths() + + +@patch(PATH_CHAR, return_value=MockTypeLoader('char')) +@patch(PATH_SERV, return_value=MockTypeLoader('service')) +def test_add_preload_service(mock_serv, mock_char): + """Test method add_preload_service. + + The methods 'get_serv_loader' and 'get_char_loader' are mocked. + """ + acc = MockAccessory('Accessory') + serv = add_preload_service(acc, 'TestService', + ['TestChar', 'TestChar2'], + ['TestOptChar', 'TestOptChar2']) + + assert serv.display_name == 'TestService' + assert len(serv.characteristics) == 2 + assert len(serv.opt_characteristics) == 2 + + acc.services = [] + serv = add_preload_service(acc, 'TestService') + + assert not serv.characteristics + assert not serv.opt_characteristics + + acc.services = [] + serv = add_preload_service(acc, 'TestService', + 'TestChar', 'TestOptChar') + + assert len(serv.characteristics) == 1 + assert len(serv.opt_characteristics) == 1 + + assert serv.characteristics[0].display_name == 'TestChar' + assert serv.opt_characteristics[0].display_name == 'TestOptChar' + + +def test_override_properties(): + """Test override of characteristic properties with MockChar.""" + char = MockChar('TestChar') + new_prop = {1: 'Test', 2: 'Demo'} + override_properties(char, new_prop) + + assert char.properties == new_prop + + +def test_set_accessory_info(): + """Test setting of basic accessory information with MockAccessory.""" + acc = MockAccessory('Accessory') + set_accessory_info(acc, 'model', 'manufacturer', '0000') + + assert len(acc.services) == 1 + serv = acc.services[0] + + assert serv.display_name == SERV_ACCESSORY_INFO + assert len(serv.characteristics) == 3 + chars = serv.characteristics + + assert chars[0].display_name == CHAR_MODEL + assert chars[0].value == 'model' + assert chars[1].display_name == CHAR_MANUFACTURER + assert chars[1].value == 'manufacturer' + assert chars[2].display_name == CHAR_SERIAL_NUMBER + assert chars[2].value == '0000' + + +@patch(PATH_ACC, side_effect=mock_preload_service) +def test_home_accessory(mock_pre_serv): + """Test initializing a HomeAccessory object.""" + acc = HomeAccessory('TestAccessory', 'test.accessory', 'WINDOW') + + assert acc.display_name == 'TestAccessory' + assert acc.category == 13 # Category.WINDOW + assert len(acc.services) == 1 + + serv = acc.services[0] + assert serv.display_name == SERV_ACCESSORY_INFO + char_model = serv.get_characteristic(CHAR_MODEL) + assert char_model.get_value() == 'test.accessory' + + +@patch(PATH_ACC, side_effect=mock_preload_service) +def test_home_bridge(mock_pre_serv): + """Test initializing a HomeBridge object.""" + bridge = HomeBridge('TestBridge', 'test.bridge', b'123-45-678') + + assert bridge.display_name == 'TestBridge' + assert bridge.pincode == b'123-45-678' + assert len(bridge.services) == 2 + + assert bridge.services[0].display_name == SERV_ACCESSORY_INFO + assert bridge.services[1].display_name == SERV_BRIDGING_STATE + + char_model = bridge.services[0].get_characteristic(CHAR_MODEL) + assert char_model.get_value() == 'test.bridge' + + +def test_mock_accessory(): + """Test attributes and functions of a MockAccessory.""" + acc = MockAccessory('TestAcc') + serv = MockService('TestServ') + acc.add_service(serv) + + assert acc.display_name == 'TestAcc' + assert len(acc.services) == 1 + + assert acc.get_service('TestServ') == serv + assert acc.get_service('NewServ').display_name == 'NewServ' + assert len(acc.services) == 2 + + +def test_mock_service(): + """Test attributes and functions of a MockService.""" + serv = MockService('TestServ') + char = MockChar('TestChar') + opt_char = MockChar('TestOptChar') + serv.add_characteristic(char) + serv.add_opt_characteristic(opt_char) + + assert serv.display_name == 'TestServ' + assert len(serv.characteristics) == 1 + assert len(serv.opt_characteristics) == 1 + + assert serv.get_characteristic('TestChar') == char + assert serv.get_characteristic('TestOptChar') == opt_char + assert serv.get_characteristic('NewChar').display_name == 'NewChar' + assert len(serv.characteristics) == 2 + + +def test_mock_char(): + """Test attributes and functions of a MockChar.""" + def callback_method(value): + """Provide a callback options for 'set_value' method.""" + assert value == 'With callback' + + char = MockChar('TestChar') + char.set_value('Value') + + assert char.display_name == 'TestChar' + assert char.get_value() == 'Value' + + char.setter_callback = callback_method + char.set_value('With callback') diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py index f665a92682c..fe0ede5d8fb 100644 --- a/tests/components/homekit/test_covers.py +++ b/tests/components/homekit/test_covers.py @@ -1,5 +1,6 @@ """Test different accessory types: Covers.""" import unittest +from unittest.mock import patch from homeassistant.core import callback from homeassistant.components.cover import ( @@ -10,6 +11,9 @@ from homeassistant.const import ( ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('covers') class TestHomekitSensors(unittest.TestCase): @@ -35,13 +39,14 @@ class TestHomekitSensors(unittest.TestCase): """Test if accessory and HA are updated accordingly.""" window_cover = 'cover.window' - acc = Window(self.hass, window_cover, 'Cover') - acc.run() + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = Window(self.hass, window_cover, 'Cover') + acc.run() self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 - # self.assertEqual(acc.char_position_state.value, 0) + self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_UNKNOWN, {ATTR_CURRENT_POSITION: None}) @@ -49,8 +54,7 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 - # self.assertEqual(acc.char_position_state.value, 0) + self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_OPEN, {ATTR_CURRENT_POSITION: 50}) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e20e87871b8..6e49674a7b9 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -6,7 +6,7 @@ from homeassistant.components.homekit import ( TYPES, get_accessory, import_types) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, - TEMP_CELSIUS, STATE_UNKNOWN) + TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) def test_import_types(): @@ -26,21 +26,32 @@ def test_component_not_supported(): assert True if get_accessory(None, state) is None else False -def test_sensor_temperatur_celsius(): - """Test temperature sensor with celsius as unit.""" +def test_sensor_temperature_celsius(): + """Test temperature sensor with Celsius as unit.""" mock_type = MagicMock() with patch.dict(TYPES, {'TemperatureSensor': mock_type}): - state = State('sensor.temperatur', '23', + state = State('sensor.temperature', '23', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) get_accessory(None, state) assert len(mock_type.mock_calls) == 1 +# pylint: disable=invalid-name +def test_sensor_temperature_fahrenheit(): + """Test temperature sensor with Fahrenheit as unit.""" + mock_type = MagicMock() + with patch.dict(TYPES, {'TemperatureSensor': mock_type}): + state = State('sensor.temperature', '74', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + get_accessory(None, state) + assert len(mock_type.mock_calls) == 1 + + def test_cover_set_position(): """Test cover with support for set_cover_position.""" mock_type = MagicMock() with patch.dict(TYPES, {'Window': mock_type}): - state = State('cover.setposition', 'open', + state = State('cover.set_position', 'open', {ATTR_SUPPORTED_FEATURES: 4}) get_accessory(None, state) assert len(mock_type.mock_calls) == 1 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 2ab284e829a..58c197e69ec 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1,20 +1,25 @@ """Tests for the HomeKit component.""" import unittest -from unittest.mock import call, patch +from unittest.mock import call, patch, ANY import voluptuous as vol +# pylint: disable=unused-import +from pyhap.accessory_driver import AccessoryDriver # noqa F401 + from homeassistant import setup from homeassistant.core import Event from homeassistant.components.homekit import ( - CONF_PIN_CODE, BRIDGE_NAME, HOMEKIT_FILE, HomeKit, valid_pin) + CONF_PIN_CODE, HOMEKIT_FILE, HomeKit, valid_pin) from homeassistant.const import ( CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, PATH_HOMEKIT -HOMEKIT_PATH = 'homeassistant.components.homekit' +PATH_ACC, _ = get_patch_paths() +IP_ADDRESS = '127.0.0.1' CONFIG_MIN = {'homekit': {}} CONFIG = { @@ -36,39 +41,6 @@ class TestHomeKit(unittest.TestCase): """Stop down everything that was started.""" self.hass.stop() - @patch(HOMEKIT_PATH + '.HomeKit.start_driver') - @patch(HOMEKIT_PATH + '.HomeKit.setup_bridge') - @patch(HOMEKIT_PATH + '.HomeKit.__init__') - def test_setup_min(self, mock_homekit, mock_setup_bridge, - mock_start_driver): - """Test async_setup with minimal config option.""" - mock_homekit.return_value = None - - self.assertTrue(setup.setup_component( - self.hass, 'homekit', CONFIG_MIN)) - - mock_homekit.assert_called_once_with(self.hass, 51826) - mock_setup_bridge.assert_called_with(b'123-45-678') - mock_start_driver.assert_not_called() - - self.hass.start() - self.hass.block_till_done() - self.assertEqual(mock_start_driver.call_count, 1) - - @patch(HOMEKIT_PATH + '.HomeKit.start_driver') - @patch(HOMEKIT_PATH + '.HomeKit.setup_bridge') - @patch(HOMEKIT_PATH + '.HomeKit.__init__') - def test_setup_parameters(self, mock_homekit, mock_setup_bridge, - mock_start_driver): - """Test async_setup with full config option.""" - mock_homekit.return_value = None - - self.assertTrue(setup.setup_component( - self.hass, 'homekit', CONFIG)) - - mock_homekit.assert_called_once_with(self.hass, 11111) - mock_setup_bridge.assert_called_with(b'987-65-432') - def test_validate_pincode(self): """Test async_setup with invalid config option.""" schema = vol.Schema(valid_pin) @@ -80,45 +52,64 @@ class TestHomeKit(unittest.TestCase): for value in ('123-45-678', '234-56-789'): self.assertTrue(schema(value)) + @patch(PATH_HOMEKIT + '.HomeKit') + def test_setup_min(self, mock_homekit): + """Test async_setup with minimal config option.""" + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG_MIN)) + + self.assertEqual(mock_homekit.mock_calls, + [call(self.hass, 51826), + call().setup_bridge(b'123-45-678')]) + mock_homekit.reset_mock() + + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + self.hass.block_till_done() + + self.assertEqual(mock_homekit.mock_calls, + [call().start_driver(ANY)]) + + @patch(PATH_HOMEKIT + '.HomeKit') + def test_setup_parameters(self, mock_homekit): + """Test async_setup with full config option.""" + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG)) + + self.assertEqual(mock_homekit.mock_calls, + [call(self.hass, 11111), + call().setup_bridge(b'987-65-432')]) + @patch('pyhap.accessory_driver.AccessoryDriver') - @patch('pyhap.accessory.Bridge.add_accessory') - @patch(HOMEKIT_PATH + '.import_types') - @patch(HOMEKIT_PATH + '.get_accessory') - def test_homekit_pyhap_interaction( - self, mock_get_accessory, mock_import_types, - mock_add_accessory, mock_acc_driver): + def test_homekit_class(self, mock_acc_driver): """Test interaction between the HomeKit class and pyhap.""" - mock_get_accessory.side_effect = ['TemperatureSensor', 'Window'] - - homekit = HomeKit(self.hass, 51826) - homekit.setup_bridge(b'123-45-678') - - self.assertEqual(homekit.bridge.display_name, BRIDGE_NAME) + with patch(PATH_HOMEKIT + '.accessories.HomeBridge') as mock_bridge: + homekit = HomeKit(self.hass, 51826) + homekit.setup_bridge(b'123-45-678') + mock_bridge.reset_mock() self.hass.states.set('demo.demo1', 'on') self.hass.states.set('demo.demo2', 'off') - self.hass.start() - self.hass.block_till_done() - - with patch('homeassistant.util.get_local_ip', - return_value='127.0.0.1'): + with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc, \ + patch(PATH_HOMEKIT + '.import_types') as mock_import_types, \ + patch('homeassistant.util.get_local_ip') as mock_ip: + mock_get_acc.side_effect = ['TempSensor', 'Window'] + mock_ip.return_value = IP_ADDRESS homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) - ip_address = '127.0.0.1' path = self.hass.config.path(HOMEKIT_FILE) - self.assertEqual(mock_get_accessory.call_count, 2) self.assertEqual(mock_import_types.call_count, 1) + self.assertEqual(mock_get_acc.call_count, 2) + self.assertEqual(mock_bridge.mock_calls, + [call().add_accessory('TempSensor'), + call().add_accessory('Window')]) self.assertEqual(mock_acc_driver.mock_calls, - [call(homekit.bridge, 51826, ip_address, path), + [call(homekit.bridge, 51826, IP_ADDRESS, path), call().start()]) - - self.assertEqual(mock_add_accessory.mock_calls, - [call('TemperatureSensor'), call('Window')]) + mock_acc_driver.reset_mock() self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() - self.assertEqual(mock_acc_driver.mock_calls[2], call().stop()) - self.assertEqual(len(mock_acc_driver.mock_calls), 3) + self.assertEqual(mock_acc_driver.mock_calls, [call().stop()]) diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py index 86a832f570a..4698c363503 100644 --- a/tests/components/homekit/test_sensors.py +++ b/tests/components/homekit/test_sensors.py @@ -1,12 +1,17 @@ """Test different accessory types: Sensors.""" import unittest +from unittest.mock import patch +from homeassistant.components.homekit.const import PROP_CELSIUS from homeassistant.components.homekit.sensors import ( TemperatureSensor, calc_temperature) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('sensors') def test_calc_temperature(): @@ -27,19 +32,24 @@ class TestHomekitSensors(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() + get_patch_paths('sensors') def tearDown(self): """Stop down everything that was started.""" self.hass.stop() - def test_temperature_celsius(self): + def test_temperature(self): """Test if accessory is updated after state change.""" temperature_sensor = 'sensor.temperature' - acc = TemperatureSensor(self.hass, temperature_sensor, 'Temperature') - acc.run() + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = TemperatureSensor(self.hass, temperature_sensor, + 'Temperature') + acc.run() self.assertEqual(acc.char_temp.value, 0.0) + self.assertEqual(acc.char_temp.properties, PROP_CELSIUS) self.hass.states.set(temperature_sensor, STATE_UNKNOWN, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) diff --git a/tests/mock/homekit.py b/tests/mock/homekit.py new file mode 100644 index 00000000000..2872fa59f19 --- /dev/null +++ b/tests/mock/homekit.py @@ -0,0 +1,133 @@ +"""Basic mock functions and objects related to the HomeKit component.""" +PATH_HOMEKIT = 'homeassistant.components.homekit' + + +def get_patch_paths(name=None): + """Return paths to mock 'add_preload_service'.""" + path_acc = PATH_HOMEKIT + '.accessories.add_preload_service' + path_file = PATH_HOMEKIT + '.' + str(name) + '.add_preload_service' + return (path_acc, path_file) + + +def mock_preload_service(acc, service, chars=None, opt_chars=None): + """Mock alternative for function 'add_preload_service'.""" + service = MockService(service) + if chars: + chars = chars if isinstance(chars, list) else [chars] + for char_name in chars: + service.add_characteristic(char_name) + if opt_chars: + opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars] + for opt_char_name in opt_chars: + service.add_characteristic(opt_char_name) + acc.add_service(service) + return service + + +class MockAccessory(): + """Define all attributes and methods for a MockAccessory.""" + + def __init__(self, name): + """Initialize a MockAccessory object.""" + self.display_name = name + self.services = [] + + def __repr__(self): + """Return a representation of a MockAccessory. Use for debugging.""" + serv_list = [serv.display_name for serv in self.services] + return "".format( + self.display_name, serv_list) + + def add_service(self, service): + """Add service to list of services.""" + self.services.append(service) + + def get_service(self, name): + """Retrieve service from service list or return new MockService.""" + for serv in self.services: + if serv.display_name == name: + return serv + serv = MockService(name) + self.add_service(serv) + return serv + + +class MockService(): + """Define all attributes and methods for a MockService.""" + + def __init__(self, name): + """Initialize a MockService object.""" + self.characteristics = [] + self.opt_characteristics = [] + self.display_name = name + + def __repr__(self): + """Return a representation of a MockService. Use for debugging.""" + char_list = [char.display_name for char in self.characteristics] + opt_char_list = [ + char.display_name for char in self.opt_characteristics] + return "".format( + self.display_name, char_list, opt_char_list) + + def add_characteristic(self, char): + """Add characteristic to char list.""" + self.characteristics.append(char) + + def add_opt_characteristic(self, char): + """Add characteristic to opt_char list.""" + self.opt_characteristics.append(char) + + def get_characteristic(self, name): + """Get char for char lists or return new MockChar.""" + for char in self.characteristics: + if char.display_name == name: + return char + for char in self.opt_characteristics: + if char.display_name == name: + return char + char = MockChar(name) + self.add_characteristic(char) + return char + + +class MockChar(): + """Define all attributes and methods for a MockChar.""" + + def __init__(self, name): + """Initialize a MockChar object.""" + self.display_name = name + self.properties = {} + self.value = None + self.type_id = None + self.setter_callback = None + + def __repr__(self): + """Return a representation of a MockChar. Use for debugging.""" + return "".format( + self.display_name, self.value) + + def set_value(self, value, should_notify=True, should_callback=True): + """Set value of char.""" + self.value = value + if self.setter_callback is not None and should_callback: + # pylint: disable=not-callable + self.setter_callback(value) + + def get_value(self): + """Get char value.""" + return self.value + + +class MockTypeLoader(): + """Define all attributes and methods for a MockTypeLoader.""" + + def __init__(self, class_type): + """Initialize a MockTypeLoader object.""" + self.class_type = class_type + + def get(self, name): + """Return a MockService or MockChar object.""" + if self.class_type == 'service': + return MockService(name) + elif self.class_type == 'char': + return MockChar(name) From 03970764d81f4ed87673e737625d6b3f0f31a598 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 2 Mar 2018 02:14:26 +0100 Subject: [PATCH 100/191] Add light.group platform (#12229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add grouped_light platform * 📝 Fix Lint issues * 🎨 Reformat code with yapf * A Few changes * ✨ Python 3.5 magic * Improvements Included the comments from #11323 * Fixes * Updates * Fixes & Tests * Fix bad-whitespace * Domain Config Validation ... by rebasing onto #12592 * Style changes & Improvements * Lint * Changes according to Review Comments * Use blocking light.async_turn_* * Revert "Use blocking light.async_turn_*" This reverts commit 9e83198552af9347aede9efb547f91793275cc5f. * Update service calls and state reporting * Add group service call tests * Remove unused constant. --- .coveragerc | 1 + homeassistant/components/light/__init__.py | 17 +- homeassistant/components/light/group.py | 289 ++++++++++++++ tests/components/light/test_group.py | 417 +++++++++++++++++++++ 4 files changed, 720 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/light/group.py create mode 100644 tests/components/light/test_group.py diff --git a/.coveragerc b/.coveragerc index 640db5765a2..cb57afd317e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -409,6 +409,7 @@ omit = homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py homeassistant/components/light/greenwave.py + homeassistant/components/light/group.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py homeassistant/components/light/iglo.py diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 14e86eeb1fb..d7862f81975 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -12,7 +12,8 @@ import os import voluptuous as vol -from homeassistant.components import group +from homeassistant.components.group import \ + ENTITY_ID_FORMAT as GROUP_ENTITY_ID_FORMAT from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) @@ -30,7 +31,7 @@ DEPENDENCIES = ['group'] SCAN_INTERVAL = timedelta(seconds=30) GROUP_NAME_ALL_LIGHTS = 'all lights' -ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights') +ENTITY_ID_ALL_LIGHTS = GROUP_ENTITY_ID_FORMAT.format('all_lights') ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -209,8 +210,9 @@ def async_turn_off(hass, entity_id=None, transition=None): DOMAIN, SERVICE_TURN_OFF, data)) +@callback @bind_hass -def toggle(hass, entity_id=None, transition=None): +def async_toggle(hass, entity_id=None, transition=None): """Toggle all or specified light.""" data = { key: value for key, value in [ @@ -219,7 +221,14 @@ def toggle(hass, entity_id=None, transition=None): ] if value is not None } - hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, data)) + + +@bind_hass +def toggle(hass, entity_id=None, transition=None): + """Toggle all or specified light.""" + hass.add_job(async_toggle, hass, entity_id, transition) def preprocess_turn_on_alternatives(params): diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py new file mode 100644 index 00000000000..15e874db8f4 --- /dev/null +++ b/homeassistant/components/light/group.py @@ -0,0 +1,289 @@ +""" +This component allows several lights to be grouped into one light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.group/ +""" +import logging +import itertools +from typing import List, Tuple, Optional, Iterator, Any, Callable +from collections import Counter + +import voluptuous as vol + +from homeassistant.core import State, callback +from homeassistant.components import light +from homeassistant.const import (STATE_ON, ATTR_ENTITY_ID, CONF_NAME, + CONF_ENTITIES, STATE_UNAVAILABLE, + ATTR_SUPPORTED_FEATURES) +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.components.light import ( + SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_COLOR_TEMP, + SUPPORT_TRANSITION, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_XY_COLOR, + SUPPORT_WHITE_VALUE, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_XY_COLOR, + ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_COLOR_TEMP, ATTR_MIN_MIREDS, + ATTR_MAX_MIREDS, ATTR_EFFECT_LIST, ATTR_EFFECT, ATTR_FLASH, + ATTR_TRANSITION) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Group Light' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITIES): cv.entities_domain('light') +}) + +SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT + | SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION + | SUPPORT_XY_COLOR | SUPPORT_WHITE_VALUE) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None) -> None: + """Initialize light.group platform.""" + async_add_devices([GroupLight(config.get(CONF_NAME), + config[CONF_ENTITIES])]) + + +class GroupLight(light.Light): + """Representation of a group light.""" + + def __init__(self, name: str, entity_ids: List[str]) -> None: + """Initialize a group light.""" + self._name = name # type: str + self._entity_ids = entity_ids # type: List[str] + self._is_on = False # type: bool + self._available = False # type: bool + self._brightness = None # type: Optional[int] + self._xy_color = None # type: Optional[Tuple[float, float]] + self._rgb_color = None # type: Optional[Tuple[int, int, int]] + self._color_temp = None # type: Optional[int] + self._min_mireds = 154 # type: Optional[int] + self._max_mireds = 500 # type: Optional[int] + self._white_value = None # type: Optional[int] + self._effect_list = None # type: Optional[List[str]] + self._effect = None # type: Optional[str] + self._supported_features = 0 # type: int + self._async_unsub_state_changed = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + @callback + def async_state_changed_listener(entity_id: str, old_state: State, + new_state: State): + """Handle child updates.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_state_changed = async_track_state_change( + self.hass, self._entity_ids, async_state_changed_listener) + + async def async_will_remove_from_hass(self): + """Callback when removed from HASS.""" + if self._async_unsub_state_changed: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def is_on(self) -> bool: + """Return the on/off state of the light.""" + return self._is_on + + @property + def available(self) -> bool: + """Return whether the light is available.""" + return self._available + + @property + def brightness(self) -> Optional[int]: + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def xy_color(self) -> Optional[Tuple[float, float]]: + """Return the XY color value [float, float].""" + return self._xy_color + + @property + def rgb_color(self) -> Optional[Tuple[int, int, int]]: + """Return the RGB color value [int, int, int].""" + return self._rgb_color + + @property + def color_temp(self) -> Optional[int]: + """Return the CT color value in mireds.""" + return self._color_temp + + @property + def min_mireds(self) -> Optional[int]: + """Return the coldest color_temp that this light supports.""" + return self._min_mireds + + @property + def max_mireds(self) -> Optional[int]: + """Return the warmest color_temp that this light supports.""" + return self._max_mireds + + @property + def white_value(self) -> Optional[int]: + """Return the white value of this light between 0..255.""" + return self._white_value + + @property + def effect_list(self) -> Optional[List[str]]: + """Return the list of supported effects.""" + return self._effect_list + + @property + def effect(self) -> Optional[str]: + """Return the current effect.""" + return self._effect + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def should_poll(self) -> bool: + """No polling needed for a group light.""" + return False + + async def async_turn_on(self, **kwargs): + """Forward the turn_on command to all lights in the group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + + if ATTR_BRIGHTNESS in kwargs: + data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] + + if ATTR_XY_COLOR in kwargs: + data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR] + + if ATTR_RGB_COLOR in kwargs: + data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR] + + if ATTR_COLOR_TEMP in kwargs: + data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] + + if ATTR_WHITE_VALUE in kwargs: + data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] + + if ATTR_EFFECT in kwargs: + data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + if ATTR_FLASH in kwargs: + data[ATTR_FLASH] = kwargs[ATTR_FLASH] + + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True) + + async def async_turn_off(self, **kwargs): + """Forward the turn_off command to all lights in the group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True) + + async def async_update(self): + """Query all members and determine the group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + states = list(filter(None, all_states)) + on_states = [state for state in states if state.state == STATE_ON] + + self._is_on = len(on_states) > 0 + self._available = any(state.state != STATE_UNAVAILABLE + for state in states) + + self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) + + self._xy_color = _reduce_attribute( + on_states, ATTR_XY_COLOR, reduce=_mean_tuple) + + self._rgb_color = _reduce_attribute( + on_states, ATTR_RGB_COLOR, reduce=_mean_tuple) + if self._rgb_color is not None: + self._rgb_color = tuple(map(int, self._rgb_color)) + + self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) + + self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._min_mireds = _reduce_attribute( + states, ATTR_MIN_MIREDS, default=154, reduce=min) + self._max_mireds = _reduce_attribute( + states, ATTR_MAX_MIREDS, default=500, reduce=max) + + self._effect_list = None + all_effect_lists = list( + _find_state_attributes(states, ATTR_EFFECT_LIST)) + if all_effect_lists: + # Merge all effects from all effect_lists with a union merge. + self._effect_list = list(set().union(*all_effect_lists)) + + self._effect = None + all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) + if all_effects: + # Report the most common effect. + effects_count = Counter(itertools.chain(all_effects)) + self._effect = effects_count.most_common(1)[0][0] + + self._supported_features = 0 + for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + # Merge supported features by emulating support for every feature + # we find. + self._supported_features |= support + # Bitwise-and the supported features with the GroupedLight's features + # so that we don't break in the future when a new feature is added. + self._supported_features &= SUPPORT_GROUP_LIGHT + + +def _find_state_attributes(states: List[State], + key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + value = state.attributes.get(key) + if value is not None: + yield value + + +def _mean_int(*args): + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def _mean_tuple(*args): + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(l) / len(l) for l in zip(*args)) + + +# https://github.com/PyCQA/pylint/issues/1831 +# pylint: disable=bad-whitespace +def _reduce_attribute(states: List[State], + key: str, + default: Optional[Any] = None, + reduce: Callable[..., Any] = _mean_int) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(_find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py new file mode 100644 index 00000000000..ac19f407066 --- /dev/null +++ b/tests/components/light/test_group.py @@ -0,0 +1,417 @@ +"""The tests for the Group Light platform.""" +from unittest.mock import MagicMock + +import asynctest + +from homeassistant.components import light +from homeassistant.components.light import group +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass): + """Test light group default state.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': [], 'name': 'Bedroom Group' + }}) + await hass.async_block_till_done() + + state = hass.states.get('light.bedroom_group') + assert state is not None + assert state.state == 'unavailable' + assert state.attributes['supported_features'] == 0 + assert state.attributes.get('brightness') is None + assert state.attributes.get('rgb_color') is None + assert state.attributes.get('xy_color') is None + assert state.attributes.get('color_temp') is None + assert state.attributes.get('white_value') is None + assert state.attributes.get('effect_list') is None + assert state.attributes.get('effect') is None + + +async def test_state_reporting(hass): + """Test the state reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on') + hass.states.async_set('light.test2', 'unavailable') + await hass.async_block_till_done() + assert hass.states.get('light.group_light').state == 'on' + + hass.states.async_set('light.test1', 'on') + hass.states.async_set('light.test2', 'off') + await hass.async_block_till_done() + assert hass.states.get('light.group_light').state == 'on' + + hass.states.async_set('light.test1', 'off') + hass.states.async_set('light.test2', 'off') + await hass.async_block_till_done() + assert hass.states.get('light.group_light').state == 'off' + + hass.states.async_set('light.test1', 'unavailable') + hass.states.async_set('light.test2', 'unavailable') + await hass.async_block_till_done() + assert hass.states.get('light.group_light').state == 'unavailable' + + +async def test_brightness(hass): + """Test brightness reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'brightness': 255, 'supported_features': 1}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['supported_features'] == 1 + assert state.attributes['brightness'] == 255 + + hass.states.async_set('light.test2', 'on', + {'brightness': 100, 'supported_features': 1}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['brightness'] == 177 + + hass.states.async_set('light.test1', 'off', + {'brightness': 255, 'supported_features': 1}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['supported_features'] == 1 + assert state.attributes['brightness'] == 100 + + +async def test_xy_color(hass): + """Test XY reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'xy_color': (1.0, 1.0), 'supported_features': 64}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['supported_features'] == 64 + assert state.attributes['xy_color'] == (1.0, 1.0) + + hass.states.async_set('light.test2', 'on', + {'xy_color': (0.5, 0.5), 'supported_features': 64}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['xy_color'] == (0.75, 0.75) + + hass.states.async_set('light.test1', 'off', + {'xy_color': (1.0, 1.0), 'supported_features': 64}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['xy_color'] == (0.5, 0.5) + + +async def test_rgb_color(hass): + """Test RGB reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'rgb_color': (255, 0, 0), 'supported_features': 16}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.state == 'on' + assert state.attributes['supported_features'] == 16 + assert state.attributes['rgb_color'] == (255, 0, 0) + + hass.states.async_set('light.test2', 'on', + {'rgb_color': (255, 255, 255), + 'supported_features': 16}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['rgb_color'] == (255, 127, 127) + + hass.states.async_set('light.test1', 'off', + {'rgb_color': (255, 0, 0), 'supported_features': 16}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['rgb_color'] == (255, 255, 255) + + +async def test_white_value(hass): + """Test white value reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'white_value': 255, 'supported_features': 128}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['white_value'] == 255 + + hass.states.async_set('light.test2', 'on', + {'white_value': 100, 'supported_features': 128}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['white_value'] == 177 + + hass.states.async_set('light.test1', 'off', + {'white_value': 255, 'supported_features': 128}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['white_value'] == 100 + + +async def test_color_temp(hass): + """Test color temp reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'color_temp': 2, 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['color_temp'] == 2 + + hass.states.async_set('light.test2', 'on', + {'color_temp': 1000, 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['color_temp'] == 501 + + hass.states.async_set('light.test1', 'off', + {'color_temp': 2, 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['color_temp'] == 1000 + + +async def test_min_max_mireds(hass): + """Test min/max mireds reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'min_mireds': 2, 'max_mireds': 5, + 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['min_mireds'] == 2 + assert state.attributes['max_mireds'] == 5 + + hass.states.async_set('light.test2', 'on', + {'min_mireds': 7, 'max_mireds': 1234567890, + 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['min_mireds'] == 2 + assert state.attributes['max_mireds'] == 1234567890 + + hass.states.async_set('light.test1', 'off', + {'min_mireds': 1, 'max_mireds': 2, + 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['min_mireds'] == 1 + assert state.attributes['max_mireds'] == 1234567890 + + +async def test_effect_list(hass): + """Test effect_list reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'effect_list': ['None', 'Random', 'Colorloop']}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert set(state.attributes['effect_list']) == { + 'None', 'Random', 'Colorloop'} + + hass.states.async_set('light.test2', 'on', + {'effect_list': ['None', 'Random', 'Rainbow']}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert set(state.attributes['effect_list']) == { + 'None', 'Random', 'Colorloop', 'Rainbow'} + + hass.states.async_set('light.test1', 'off', + {'effect_list': ['None', 'Colorloop', 'Seven']}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert set(state.attributes['effect_list']) == { + 'None', 'Random', 'Colorloop', 'Seven', 'Rainbow'} + + +async def test_effect(hass): + """Test effect reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2', + 'light.test3'] + }}) + + hass.states.async_set('light.test1', 'on', + {'effect': 'None', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['effect'] == 'None' + + hass.states.async_set('light.test2', 'on', + {'effect': 'None', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['effect'] == 'None' + + hass.states.async_set('light.test3', 'on', + {'effect': 'Random', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['effect'] == 'None' + + hass.states.async_set('light.test1', 'off', + {'effect': 'None', 'supported_features': 2}) + hass.states.async_set('light.test2', 'off', + {'effect': 'None', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['effect'] == 'Random' + + +async def test_supported_features(hass): + """Test supported features reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'supported_features': 0}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['supported_features'] == 0 + + hass.states.async_set('light.test2', 'on', + {'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['supported_features'] == 2 + + hass.states.async_set('light.test1', 'off', + {'supported_features': 41}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['supported_features'] == 43 + + hass.states.async_set('light.test2', 'off', + {'supported_features': 256}) + await hass.async_block_till_done() + state = hass.states.get('light.group_light') + assert state.attributes['supported_features'] == 41 + + +async def test_service_calls(hass): + """Test service calls.""" + await async_setup_component(hass, 'light', {'light': [ + {'platform': 'demo'}, + {'platform': 'group', 'entities': ['light.bed_light', + 'light.ceiling_lights', + 'light.kitchen_lights']} + ]}) + await hass.async_block_till_done() + + assert hass.states.get('light.group_light').state == 'on' + light.async_toggle(hass, 'light.group_light') + await hass.async_block_till_done() + + assert hass.states.get('light.bed_light').state == 'off' + assert hass.states.get('light.ceiling_lights').state == 'off' + assert hass.states.get('light.kitchen_lights').state == 'off' + + light.async_turn_on(hass, 'light.group_light') + await hass.async_block_till_done() + + assert hass.states.get('light.bed_light').state == 'on' + assert hass.states.get('light.ceiling_lights').state == 'on' + assert hass.states.get('light.kitchen_lights').state == 'on' + + light.async_turn_off(hass, 'light.group_light') + await hass.async_block_till_done() + + assert hass.states.get('light.bed_light').state == 'off' + assert hass.states.get('light.ceiling_lights').state == 'off' + assert hass.states.get('light.kitchen_lights').state == 'off' + + light.async_turn_on(hass, 'light.group_light', brightness=128, + effect='Random', rgb_color=(42, 255, 255)) + await hass.async_block_till_done() + + state = hass.states.get('light.bed_light') + assert state.state == 'on' + assert state.attributes['brightness'] == 128 + assert state.attributes['effect'] == 'Random' + assert state.attributes['rgb_color'] == (42, 255, 255) + + state = hass.states.get('light.ceiling_lights') + assert state.state == 'on' + assert state.attributes['brightness'] == 128 + assert state.attributes['effect'] == 'Random' + assert state.attributes['rgb_color'] == (42, 255, 255) + + state = hass.states.get('light.kitchen_lights') + assert state.state == 'on' + assert state.attributes['brightness'] == 128 + assert state.attributes['effect'] == 'Random' + assert state.attributes['rgb_color'] == (42, 255, 255) + + +async def test_invalid_service_calls(hass): + """Test invalid service call arguments get discarded.""" + add_devices = MagicMock() + await group.async_setup_platform(hass, { + 'entities': ['light.test1', 'light.test2'] + }, add_devices) + + assert add_devices.call_count == 1 + grouped_light = add_devices.call_args[0][0][0] + grouped_light.hass = hass + + with asynctest.patch.object(hass.services, 'async_call') as mock_call: + await grouped_light.async_turn_on(brightness=150, four_oh_four='404') + data = { + 'entity_id': ['light.test1', 'light.test2'], + 'brightness': 150 + } + mock_call.assert_called_once_with('light', 'turn_on', data, + blocking=True) + mock_call.reset_mock() + + await grouped_light.async_turn_off(transition=4, four_oh_four='404') + data = { + 'entity_id': ['light.test1', 'light.test2'], + 'transition': 4 + } + mock_call.assert_called_once_with('light', 'turn_off', data, + blocking=True) + mock_call.reset_mock() + + data = { + 'brightness': 150, + 'xy_color': (0.5, 0.42), + 'rgb_color': (80, 120, 50), + 'color_temp': 1234, + 'white_value': 1, + 'effect': 'Sunshine', + 'transition': 4, + 'flash': 'long' + } + await grouped_light.async_turn_on(**data) + data['entity_id'] = ['light.test1', 'light.test2'] + mock_call.assert_called_once_with('light', 'turn_on', data, + blocking=True) From 25c4c9b63c94d790ea7b0a28bb2c5b49728a81f6 Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Fri, 2 Mar 2018 02:15:08 +0100 Subject: [PATCH 101/191] Add icons to Xiaomi Aqara sensors (#12814) * Update xiaomi_aqara.py * Update xiaomi_aqara.py --- .../components/sensor/xiaomi_aqara.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index c2498d88822..aed8c370a1c 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -7,6 +7,14 @@ from homeassistant.const import TEMP_CELSIUS _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = { + 'temperature': [TEMP_CELSIUS, 'mdi:thermometer'], + 'humidity': ['%', 'mdi:water-percent'], + 'illumination': ['lm'], + 'lux': ['lx'], + 'pressure': ['hPa', 'mdi:gauge'] +} + def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Xiaomi devices.""" @@ -42,19 +50,21 @@ class XiaomiSensor(XiaomiDevice): self._data_key = data_key XiaomiDevice.__init__(self, device, name, xiaomi_hub) + @property + def icon(self): + """Return the icon to use in the frontend.""" + try: + return SENSOR_TYPES.get(self._data_key)[1] + except TypeError: + return None + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._data_key == 'temperature': - return TEMP_CELSIUS - elif self._data_key == 'humidity': - return '%' - elif self._data_key == 'illumination': - return 'lm' - elif self._data_key == 'lux': - return 'lx' - elif self._data_key == 'pressure': - return 'hPa' + try: + return SENSOR_TYPES.get(self._data_key)[0] + except TypeError: + return None @property def state(self): From 7a979e9f720fb03af107bb89ad94247d76e150df Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Fri, 2 Mar 2018 12:50:00 +0100 Subject: [PATCH 102/191] Egardia redesign - generic component and sensor support (#11994) * Egardia redesign - generic component and sensor support * Updating .coveragerc and requirements_all * Fixing linting errors * Fixing linting errors (again) * Fixing linting errors * Responding to review * Responding to review. * Updating requirements_all.txt * Responding to review. * Responding to review * Removing unnessesary logging line. * Responding to review * Responding to review. * Fixing copying mistake. * Responding to review. * Improving validation. * Updating package requirement to .38 * Fixing syntax error. * Updating requirements_all.txt * Fixing bug handling alarm status. * Updating requirements_all.txt * Updating requirements_all.txt * Changing parsing of configuration. * Changing code lookup. * Fixing linting error. --- .coveragerc | 4 +- .../components/alarm_control_panel/egardia.py | 154 +++++------------- .../components/binary_sensor/egardia.py | 78 +++++++++ homeassistant/components/egardia.py | 123 ++++++++++++++ requirements_all.txt | 3 +- 5 files changed, 245 insertions(+), 117 deletions(-) create mode 100644 homeassistant/components/binary_sensor/egardia.py create mode 100644 homeassistant/components/egardia.py diff --git a/.coveragerc b/.coveragerc index cb57afd317e..ab9d40a1896 100644 --- a/.coveragerc +++ b/.coveragerc @@ -85,6 +85,9 @@ omit = homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py + homeassistant/components/egardia.py + homeassistant/components/*/egardia.py + homeassistant/components/enocean.py homeassistant/components/*/enocean.py @@ -305,7 +308,6 @@ omit = homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py - homeassistant/components/alarm_control_panel/egardia.py homeassistant/components/alarm_control_panel/ialarm.py homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index e9f08cd4fed..64e165f6b16 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -4,130 +4,65 @@ Interfaces with Egardia/Woonveilig alarm control panel. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.egardia/ """ +import asyncio import logging import requests -import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN) -import homeassistant.exceptions as exc -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pythonegardia==1.0.26'] + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) +from homeassistant.components.egardia import ( + EGARDIA_DEVICE, EGARDIA_SERVER, + REPORT_SERVER_CODES_IGNORE, CONF_REPORT_SERVER_CODES, + CONF_REPORT_SERVER_ENABLED, CONF_REPORT_SERVER_PORT + ) +REQUIREMENTS = ['pythonegardia==1.0.38'] _LOGGER = logging.getLogger(__name__) -CONF_REPORT_SERVER_CODES = 'report_server_codes' -CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' -CONF_REPORT_SERVER_PORT = 'report_server_port' -CONF_REPORT_SERVER_CODES_IGNORE = 'ignore' -CONF_VERSION = 'version' - -DEFAULT_NAME = 'Egardia' -DEFAULT_PORT = 80 -DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 52010 -DEFAULT_VERSION = 'GATE-01' -DOMAIN = 'egardia' -D_EGARDIASRV = 'egardiaserver' - -NOTIFICATION_ID = 'egardia_notification' -NOTIFICATION_TITLE = 'Egardia' - STATES = { 'ARM': STATE_ALARM_ARMED_AWAY, 'DAY HOME': STATE_ALARM_ARMED_HOME, 'DISARM': STATE_ALARM_DISARMED, - 'HOME': STATE_ALARM_ARMED_HOME, - 'TRIGGERED': STATE_ALARM_TRIGGERED, - 'UNKNOWN': STATE_UNKNOWN, + 'ARMHOME': STATE_ALARM_ARMED_HOME, + 'TRIGGERED': STATE_ALARM_TRIGGERED } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_REPORT_SERVER_CODES): vol.All(cv.ensure_list), - vol.Optional(CONF_REPORT_SERVER_ENABLED, - default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean, - vol.Optional(CONF_REPORT_SERVER_PORT, default=DEFAULT_REPORT_SERVER_PORT): - cv.port, -}) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Egardia platform.""" - from pythonegardia import egardiadevice - from pythonegardia import egardiaserver - - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - rs_enabled = config.get(CONF_REPORT_SERVER_ENABLED) - rs_port = config.get(CONF_REPORT_SERVER_PORT) - rs_codes = config.get(CONF_REPORT_SERVER_CODES) - version = config.get(CONF_VERSION) - - try: - egardiasystem = egardiadevice.EgardiaDevice( - host, port, username, password, '', version) - except requests.exceptions.RequestException: - raise exc.PlatformNotReady() - except egardiadevice.UnauthorizedError: - _LOGGER.error("Unable to authorize. Wrong password or username") - return - - eg_dev = EgardiaAlarm( - name, egardiasystem, rs_enabled, rs_codes) - - if rs_enabled: - # Set up the egardia server - _LOGGER.info("Setting up EgardiaServer") - try: - if D_EGARDIASRV not in hass.data: - server = egardiaserver.EgardiaServer('', rs_port) - bound = server.bind() - if not bound: - raise IOError( - "Binding error occurred while starting EgardiaServer") - hass.data[D_EGARDIASRV] = server - server.start() - except IOError: - return - hass.data[D_EGARDIASRV].register_callback(eg_dev.handle_status_event) - - def handle_stop_event(event): - """Call function for Home Assistant stop event.""" - hass.data[D_EGARDIASRV].stop() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) - - add_devices([eg_dev], True) + device = EgardiaAlarm( + discovery_info['name'], + hass.data[EGARDIA_DEVICE], + discovery_info[CONF_REPORT_SERVER_ENABLED], + discovery_info.get(CONF_REPORT_SERVER_CODES), + discovery_info[CONF_REPORT_SERVER_PORT]) + # add egardia alarm device + add_devices([device], True) class EgardiaAlarm(alarm.AlarmControlPanel): """Representation of a Egardia alarm.""" - def __init__(self, name, egardiasystem, rs_enabled=False, rs_codes=None): + def __init__(self, name, egardiasystem, + rs_enabled=False, rs_codes=None, rs_port=52010): """Initialize the Egardia alarm.""" self._name = name self._egardiasystem = egardiasystem self._status = None self._rs_enabled = rs_enabled - if rs_codes is not None: - self._rs_codes = rs_codes[0] - else: - self._rs_codes = rs_codes + self._rs_codes = rs_codes + self._rs_port = rs_port + + @asyncio.coroutine + def async_added_to_hass(self): + """Add Egardiaserver callback if enabled.""" + if self._rs_enabled: + _LOGGER.debug("Registering callback to Egardiaserver") + self.hass.data[EGARDIA_SERVER].register_callback( + self.handle_status_event) @property def name(self): @@ -156,31 +91,20 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def lookupstatusfromcode(self, statuscode): """Look at the rs_codes and returns the status from the code.""" - status = 'UNKNOWN' - if self._rs_codes is not None: - statuscode = str(statuscode).strip() - for i in self._rs_codes: - val = str(self._rs_codes[i]).strip() - if ',' in val: - splitted = val.split(',') - for code in splitted: - code = str(code).strip() - if statuscode == code: - status = i.upper() - break - elif statuscode == val: - status = i.upper() - break + status = next(( + status_group.upper() for status_group, codes + in self._rs_codes.items() for code in codes + if statuscode == code), 'UNKNOWN') return status def parsestatus(self, status): """Parse the status.""" _LOGGER.debug("Parsing status %s", status) # Ignore the statuscode if it is IGNORE - if status.lower().strip() != CONF_REPORT_SERVER_CODES_IGNORE: - _LOGGER.debug("Not ignoring status") - newstatus = ([v for k, v in STATES.items() - if status.upper() == k][0]) + if status.lower().strip() != REPORT_SERVER_CODES_IGNORE: + _LOGGER.debug("Not ignoring status %s", status) + newstatus = STATES.get(status.upper()) + _LOGGER.debug("newstatus %s", newstatus) self._status = newstatus else: _LOGGER.error("Ignoring status") diff --git a/homeassistant/components/binary_sensor/egardia.py b/homeassistant/components/binary_sensor/egardia.py new file mode 100644 index 00000000000..ab88de9d3c9 --- /dev/null +++ b/homeassistant/components/binary_sensor/egardia.py @@ -0,0 +1,78 @@ +""" +Interfaces with Egardia/Woonveilig alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.egardia/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.egardia import ( + EGARDIA_DEVICE, ATTR_DISCOVER_DEVICES) +_LOGGER = logging.getLogger(__name__) + +EGARDIA_TYPE_TO_DEVICE_CLASS = {'IR Sensor': 'motion', + 'Door Contact': 'opening', + 'IR': 'motion'} + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Initialize the platform.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + disc_info = discovery_info[ATTR_DISCOVER_DEVICES] + # multiple devices here! + async_add_devices( + ( + EgardiaBinarySensor( + sensor_id=disc_info[sensor]['id'], + name=disc_info[sensor]['name'], + egardia_system=hass.data[EGARDIA_DEVICE], + device_class=EGARDIA_TYPE_TO_DEVICE_CLASS.get( + disc_info[sensor]['type'], None) + ) + for sensor in disc_info + ), True) + + +class EgardiaBinarySensor(BinarySensorDevice): + """Represents a sensor based on an Egardia sensor (IR, Door Contact).""" + + def __init__(self, sensor_id, name, egardia_system, device_class): + """Initialize the sensor device.""" + self._id = sensor_id + self._name = name + self._state = None + self._device_class = device_class + self._egardia_system = egardia_system + + def update(self): + """Update the status.""" + egardia_input = self._egardia_system.getsensorstate(self._id) + self._state = STATE_ON if egardia_input else STATE_OFF + + @property + def name(self): + """The name of the device.""" + return self._name + + @property + def is_on(self): + """Whether the device is switched on.""" + return self._state == STATE_ON + + @property + def hidden(self): + """Whether the device is hidden by default.""" + # these type of sensors are probably mainly used for automations + return True + + @property + def device_class(self): + """The device class.""" + return self._device_class diff --git a/homeassistant/components/egardia.py b/homeassistant/components/egardia.py new file mode 100644 index 00000000000..2cfc44a407b --- /dev/null +++ b/homeassistant/components/egardia.py @@ -0,0 +1,123 @@ +""" +Interfaces with Egardia/Woonveilig alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/egardia/ +""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PORT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, + EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['pythonegardia==1.0.38'] + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORT_SERVER_CODES = 'report_server_codes' +CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' +CONF_REPORT_SERVER_PORT = 'report_server_port' +REPORT_SERVER_CODES_IGNORE = 'ignore' +CONF_VERSION = 'version' + +DEFAULT_NAME = 'Egardia' +DEFAULT_PORT = 80 +DEFAULT_REPORT_SERVER_ENABLED = False +DEFAULT_REPORT_SERVER_PORT = 52010 +DEFAULT_VERSION = 'GATE-01' +DOMAIN = 'egardia' +EGARDIA_SERVER = 'egardia_server' +EGARDIA_DEVICE = 'egardiadevice' +EGARDIA_NAME = 'egardianame' +EGARDIA_REPORT_SERVER_ENABLED = 'egardia_rs_enabled' +EGARDIA_REPORT_SERVER_CODES = 'egardia_rs_codes' +NOTIFICATION_ID = 'egardia_notification' +NOTIFICATION_TITLE = 'Egardia' +ATTR_DISCOVER_DEVICES = 'egardia_sensor' + +SERVER_CODE_SCHEMA = vol.Schema({ + vol.Optional('arm'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('disarm'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('armhome'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('triggered'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('ignore'): vol.All(cv.ensure_list_csv, [cv.string]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_REPORT_SERVER_CODES, default={}): SERVER_CODE_SCHEMA, + vol.Optional(CONF_REPORT_SERVER_ENABLED, + default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean, + vol.Optional(CONF_REPORT_SERVER_PORT, + default=DEFAULT_REPORT_SERVER_PORT): cv.port, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Egardia platform.""" + from pythonegardia import egardiadevice + from pythonegardia import egardiaserver + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + version = conf.get(CONF_VERSION) + rs_enabled = conf.get(CONF_REPORT_SERVER_ENABLED) + rs_port = conf.get(CONF_REPORT_SERVER_PORT) + try: + device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice( + host, port, username, password, '', version) + except requests.exceptions.RequestException: + _LOGGER.error("An error occurred accessing your Egardia device. " + + "Please check config.") + return False + except egardiadevice.UnauthorizedError: + _LOGGER.error("Unable to authorize. Wrong password or username.") + return False + # Set up the egardia server if enabled + if rs_enabled: + _LOGGER.debug("Setting up EgardiaServer") + try: + if EGARDIA_SERVER not in hass.data: + server = egardiaserver.EgardiaServer('', rs_port) + bound = server.bind() + if not bound: + raise IOError("Binding error occurred while " + + "starting EgardiaServer.") + hass.data[EGARDIA_SERVER] = server + server.start() + + def handle_stop_event(event): + """Callback function for HA stop event.""" + server.stop() + + # listen to home assistant stop event + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) + + except IOError: + _LOGGER.error("Binding error occurred while starting " + + "EgardiaServer.") + return False + + discovery.load_platform(hass, 'alarm_control_panel', DOMAIN, + discovered=conf, hass_config=config) + + # get the sensors from the device and add those + sensors = device.getsensors() + discovery.load_platform(hass, 'binary_sensor', DOMAIN, + {ATTR_DISCOVER_DEVICES: sensors}, config) + + return True diff --git a/requirements_all.txt b/requirements_all.txt index 8bbf005c0d7..50026ee72d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -988,8 +988,9 @@ python_opendata_transport==0.0.3 # homeassistant.components.zwave python_openzwave==0.4.3 +# homeassistant.components.egardia # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.26 +pythonegardia==1.0.38 # homeassistant.components.sensor.whois pythonwhois==2.4.3 From 228b030c821be8c887cc9c1e425e2243cb9076c8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Mar 2018 10:33:05 -0800 Subject: [PATCH 103/191] Cloud unauth (#12840) * Handle expired refresh token better * Retry less aggressive * Newline --- homeassistant/components/cloud/const.py | 6 ++++++ homeassistant/components/cloud/iot.py | 20 +++++++++++++++----- tests/components/cloud/test_iot.py | 18 ++++++++++++++++-- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index b13ec6d1e45..99075d3d02d 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -16,3 +16,9 @@ MESSAGE_EXPIRATION = """ It looks like your Home Assistant Cloud subscription has expired. Please check your [account page](/config/cloud/account) to continue using the service. """ + +MESSAGE_AUTH_FAIL = """ +You have been logged out of Home Assistant Cloud because we have been unable +to verify your credentials. Please [log in](/config/cloud) again to continue +using the service. +""" diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 3220fc372f7..91fbc85df6b 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -10,7 +10,7 @@ from homeassistant.components.google_assistant import smart_home as ga from homeassistant.util.decorator import Registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api -from .const import MESSAGE_EXPIRATION +from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -77,9 +77,9 @@ class CloudIoT: self.tries += 1 try: - # Sleep 0, 5, 10, 15 ... 30 seconds between retries + # Sleep 2^tries seconds between retries self.retry_task = hass.async_add_job(asyncio.sleep( - min(30, (self.tries - 1) * 5), loop=hass.loop)) + 2**min(9, self.tries), loop=hass.loop)) yield from self.retry_task self.retry_task = None except asyncio.CancelledError: @@ -97,13 +97,23 @@ class CloudIoT: try: yield from hass.async_add_job(auth_api.check_token, self.cloud) + except auth_api.Unauthenticated as err: + _LOGGER.error('Unable to refresh token: %s', err) + + hass.components.persistent_notification.async_create( + MESSAGE_AUTH_FAIL, 'Home Assistant Cloud', + 'cloud_subscription_expired') + + # Don't await it because it will cancel this task + hass.async_add_job(self.cloud.logout()) + return except auth_api.CloudError as err: - _LOGGER.warning("Unable to connect: %s", err) + _LOGGER.warning("Unable to refresh token: %s", err) return if self.cloud.subscription_expired: hass.components.persistent_notification.async_create( - MESSAGE_EXPIRATION, 'Subscription expired', + MESSAGE_EXPIRATION, 'Home Assistant Cloud', 'cloud_subscription_expired') self.close_requested = True return diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 3eec350b2cb..d6a26ee37e0 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -6,7 +6,7 @@ from aiohttp import WSMsgType, client_exceptions import pytest from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import iot, auth_api +from homeassistant.components.cloud import Cloud, iot, auth_api, MODE_DEV from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro @@ -202,7 +202,7 @@ def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): yield from conn.connect() - assert 'Unable to connect: BLA' in caplog.text + assert 'Unable to refresh token: BLA' in caplog.text @asyncio.coroutine @@ -348,3 +348,17 @@ def test_handler_google_actions(hass): assert device['name']['name'] == 'Config name' assert device['name']['nicknames'] == ['Config alias'] assert device['type'] == 'action.devices.types.LIGHT' + + +async def test_refresh_token_expired(hass): + """Test handling Unauthenticated error raised if refresh token expired.""" + cloud = Cloud(hass, MODE_DEV, None, None) + + with patch('homeassistant.components.cloud.auth_api.check_token', + side_effect=auth_api.Unauthenticated) as mock_check_token, \ + patch.object(hass.components.persistent_notification, + 'async_create') as mock_create: + await cloud.iot.connect() + + assert len(mock_check_token.mock_calls) == 1 + assert len(mock_create.mock_calls) == 1 From e20e0425b1ca8dece00247cab72f5cc8f9e71570 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 2 Mar 2018 21:16:48 +0200 Subject: [PATCH 104/191] Fix sensibo default IDs to be according to schema (#12837) --- homeassistant/components/climate/sensibo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index b49d379592f..7b4c8ed8b1b 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -29,7 +29,7 @@ REQUIREMENTS = ['pysensibo==1.0.2'] _LOGGER = logging.getLogger(__name__) -ALL = 'all' +ALL = ['all'] TIMEOUT = 10 SERVICE_ASSUME_STATE = 'sensibo_assume_state' From 4e03176634678e16efa956a2f153994a8c4a5d97 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Mar 2018 11:19:19 -0800 Subject: [PATCH 105/191] Skip flaky light.group test [skipci] (#12847) --- tests/components/light/test_group.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py index ac19f407066..c97a5ae9efe 100644 --- a/tests/components/light/test_group.py +++ b/tests/components/light/test_group.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock import asynctest +import pytest from homeassistant.components import light from homeassistant.components.light import group @@ -316,6 +317,7 @@ async def test_supported_features(hass): assert state.attributes['supported_features'] == 41 +@pytest.mark.skip async def test_service_calls(hass): """Test service calls.""" await async_setup_component(hass, 'light', {'light': [ From d333593aa65b51641ab0a4c8a5831e139e70ca18 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Mar 2018 11:21:30 -0800 Subject: [PATCH 106/191] Handle Hue errors better (#12845) * Handle Hue errors better * Lint --- homeassistant/components/hue.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 36d5a1a56a0..2d64306ca74 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -192,7 +192,7 @@ class HueBridge(object): self.bridge = phue.Bridge( self.host, config_file_path=self.hass.config.path(self.filename)) - except ConnectionRefusedError: # Wrong host was given + except (ConnectionRefusedError, OSError): # Wrong host was given _LOGGER.error("Error connecting to the Hue bridge at %s", self.host) return @@ -201,6 +201,9 @@ class HueBridge(object): self.host) self.request_configuration() return + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error connecting with Hue bridge at %s", + self.host) # If we came here and configuring this host, mark as done if self.config_request_id: From 0762c7caef0e039833a1aea7cc490db4fbf8293a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 2 Mar 2018 20:23:12 +0100 Subject: [PATCH 107/191] Update volvooncall.py (#12834) --- homeassistant/components/switch/volvooncall.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/volvooncall.py b/homeassistant/components/switch/volvooncall.py index 9e20ddb5e7e..c1b18a11795 100644 --- a/homeassistant/components/switch/volvooncall.py +++ b/homeassistant/components/switch/volvooncall.py @@ -1,7 +1,7 @@ """ Support for Volvo heater. -This platform uses the Telldus Live online service. +This platform uses the Volvo online service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.volvooncall/ From 7937064fb7993c21677d6994ec5b9d6d8c6517fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 2 Mar 2018 21:23:53 +0200 Subject: [PATCH 108/191] Address upcloud post-merge comments (#12011) (#12835) --- homeassistant/components/binary_sensor/upcloud.py | 4 ---- homeassistant/components/switch/upcloud.py | 4 ---- homeassistant/components/upcloud.py | 10 ++++++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/binary_sensor/upcloud.py b/homeassistant/components/binary_sensor/upcloud.py index dd76231ad75..868a2e8ddd0 100644 --- a/homeassistant/components/binary_sensor/upcloud.py +++ b/homeassistant/components/binary_sensor/upcloud.py @@ -36,7 +36,3 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice): """Representation of an UpCloud server sensor.""" - - def __init__(self, upcloud, uuid): - """Initialize a new UpCloud sensor.""" - UpCloudServerEntity.__init__(self, upcloud, uuid) diff --git a/homeassistant/components/switch/upcloud.py b/homeassistant/components/switch/upcloud.py index 32d47670429..5c3af45bede 100644 --- a/homeassistant/components/switch/upcloud.py +++ b/homeassistant/components/switch/upcloud.py @@ -37,10 +37,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class UpCloudSwitch(UpCloudServerEntity, SwitchDevice): """Representation of an UpCloud server switch.""" - def __init__(self, upcloud, uuid): - """Initialize a new UpCloud server switch.""" - UpCloudServerEntity.__init__(self, upcloud, uuid) - def turn_on(self, **kwargs): """Start the server.""" if self.state == STATE_OFF: diff --git a/homeassistant/components/upcloud.py b/homeassistant/components/upcloud.py index e653e55b93b..40e4ceffed8 100644 --- a/homeassistant/components/upcloud.py +++ b/homeassistant/components/upcloud.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, - STATE_ON, STATE_OFF, STATE_PROBLEM, STATE_UNKNOWN) + STATE_ON, STATE_OFF, STATE_PROBLEM) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, dispatcher_send) @@ -129,9 +130,10 @@ class UpCloudServerEntity(Entity): async_dispatcher_connect( self.hass, SIGNAL_UPDATE_UPCLOUD, self._update_callback) + @callback def _update_callback(self): """Call update method.""" - self.schedule_update_ha_state(True) + self.async_schedule_update_ha_state(True) @property def icon(self): @@ -142,9 +144,9 @@ class UpCloudServerEntity(Entity): def state(self): """Return state of the server.""" try: - return STATE_MAP.get(self.data.state, STATE_UNKNOWN) + return STATE_MAP.get(self.data.state) except AttributeError: - return STATE_UNKNOWN + return None @property def is_on(self): From dd67192057aabc5b081f9664cd712e52c003216a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 2 Mar 2018 20:29:49 +0100 Subject: [PATCH 109/191] Keep auto groups during group reload (#12841) * Keep auto groups during group reload * Make protected member public * Add test --- homeassistant/components/group/__init__.py | 10 +++++++--- tests/components/group/test_init.py | 15 +++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 5e4dfdb0bdc..3ece434f3c1 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -257,12 +257,16 @@ def async_setup(hass, config): @asyncio.coroutine def reload_service_handler(service): - """Remove all groups and load new ones from config.""" + """Remove all user-defined groups and load new ones from config.""" + auto = list(filter(lambda e: not e.user_defined, component.entities)) + conf = yield from component.async_prepare_reload() if conf is None: return yield from _async_process_config(hass, conf, component) + yield from component.async_add_entities(auto) + hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=RELOAD_SERVICE_SCHEMA) @@ -407,7 +411,7 @@ class Group(Entity): self.group_off = None self.visible = visible self.control = control - self._user_defined = user_defined + self.user_defined = user_defined self._order = order self._assumed_state = False self._async_unsub_state_changed = None @@ -497,7 +501,7 @@ class Group(Entity): ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order, } - if not self._user_defined: + if not self.user_defined: data[ATTR_AUTO] = True if self.view: data[ATTR_VIEW] = True diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 1dd848d3058..31ad70e8aba 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -348,9 +348,15 @@ class TestComponentsGroup(unittest.TestCase): 'empty_group': {'name': 'Empty Group', 'entities': None}, }}) + group.Group.create_group( + self.hass, 'all tests', + ['test.one', 'test.two'], + user_defined=False) + assert sorted(self.hass.states.entity_ids()) == \ - ['group.empty_group', 'group.second_group', 'group.test_group'] - assert self.hass.bus.listeners['state_changed'] == 2 + ['group.all_tests', 'group.empty_group', 'group.second_group', + 'group.test_group'] + assert self.hass.bus.listeners['state_changed'] == 3 with patch('homeassistant.config.load_yaml_config_file', return_value={ 'group': { @@ -362,8 +368,9 @@ class TestComponentsGroup(unittest.TestCase): group.reload(self.hass) self.hass.block_till_done() - assert self.hass.states.entity_ids() == ['group.hello'] - assert self.hass.bus.listeners['state_changed'] == 1 + assert sorted(self.hass.states.entity_ids()) == \ + ['group.all_tests', 'group.hello'] + assert self.hass.bus.listeners['state_changed'] == 2 def test_changing_group_visibility(self): """Test that a group can be hidden and shown.""" From 92b07ba8d1efbc4f6d89cf726d4fb36d10b96b4a Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 2 Mar 2018 21:00:17 +0100 Subject: [PATCH 110/191] PyXiaomiGateway version bumped. (#12828) --- homeassistant/components/xiaomi_aqara.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 385ce0e0ac9..96f10de68e4 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.8.2'] +REQUIREMENTS = ['PyXiaomiGateway==0.8.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 50026ee72d1..7c8b965c761 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.8.2 +PyXiaomiGateway==0.8.3 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From 99eeb0152536dfc43ee9e775605bb543456e56ad Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sat, 3 Mar 2018 00:04:32 +0100 Subject: [PATCH 111/191] Fix light group update before add (#12844) * Fix light group update before add. * Revert pytest skip --- homeassistant/components/light/group.py | 2 +- tests/components/light/test_group.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py index 15e874db8f4..768754ca1af 100644 --- a/homeassistant/components/light/group.py +++ b/homeassistant/components/light/group.py @@ -45,7 +45,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_devices, discovery_info=None) -> None: """Initialize light.group platform.""" async_add_devices([GroupLight(config.get(CONF_NAME), - config[CONF_ENTITIES])]) + config[CONF_ENTITIES])], True) class GroupLight(light.Light): diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py index c97a5ae9efe..ac19f407066 100644 --- a/tests/components/light/test_group.py +++ b/tests/components/light/test_group.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock import asynctest -import pytest from homeassistant.components import light from homeassistant.components.light import group @@ -317,7 +316,6 @@ async def test_supported_features(hass): assert state.attributes['supported_features'] == 41 -@pytest.mark.skip async def test_service_calls(hass): """Test service calls.""" await async_setup_component(hass, 'light', {'light': [ From 7d8a309017f9969b52cd46344f204c48689f9454 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 3 Mar 2018 09:15:19 +0100 Subject: [PATCH 112/191] IndexError (list index out of range) fixed. (#12858) --- homeassistant/components/sensor/xiaomi_aqara.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index aed8c370a1c..33bbdc32308 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -10,8 +10,8 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { 'temperature': [TEMP_CELSIUS, 'mdi:thermometer'], 'humidity': ['%', 'mdi:water-percent'], - 'illumination': ['lm'], - 'lux': ['lx'], + 'illumination': ['lm', 'mdi:weather-sunset'], + 'lux': ['lx', 'mdi:weather-sunset'], 'pressure': ['hPa', 'mdi:gauge'] } From d63cf94d6d0f3c03344cf37daa7dca1c073cd17c Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 3 Mar 2018 09:19:22 +0100 Subject: [PATCH 113/191] Fix dead Sonos web interface even more (#12851) --- homeassistant/components/media_player/sonos.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index b0a7776ec82..5928122dba4 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -129,7 +129,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Avoid SoCo 0.14 event thread dying from invalid xml.""" try: return orig_parse_event_xml(xml) - except soco.exceptions.SoCoException: + # pylint: disable=broad-except + except Exception as ex: + _LOGGER.debug("Dodged exception: %s %s", type(ex), str(ex)) return {} soco.events.parse_event_xml = safe_parse_event_xml From d8a11fd706922ed7a85fdc739deb656ae15b5a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Sat, 3 Mar 2018 12:48:58 +0100 Subject: [PATCH 114/191] Updated to use latest ihcsdk version (#12865) * Updated to used latest ihcsdk version * Updated requirements_all.txt --- homeassistant/components/ihc/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 04be7dd5ab0..031fa263e5a 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType -REQUIREMENTS = ['ihcsdk==2.1.1'] +REQUIREMENTS = ['ihcsdk==2.2.0'] DOMAIN = 'ihc' IHC_DATA = 'ihc' diff --git a/requirements_all.txt b/requirements_all.txt index 7c8b965c761..de949dc1b93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -403,7 +403,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 iglo==1.2.6 # homeassistant.components.ihc -ihcsdk==2.1.1 +ihcsdk==2.2.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 49581a4a2abc3f025998cb4daad3c934b43420d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 3 Mar 2018 13:18:45 +0100 Subject: [PATCH 115/191] Add unique id for Tibber sensor (#12864) Upgrade Tibber libary --- homeassistant/components/sensor/tibber.py | 8 +++++++- requirements_all.txt | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index e56d5595e32..2e82d98af29 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -REQUIREMENTS = ['pyTibber==0.3.0'] +REQUIREMENTS = ['pyTibber==0.3.2'] _LOGGER = logging.getLogger(__name__) @@ -103,3 +103,9 @@ class TibberSensor(Entity): def unit_of_measurement(self): """Return the unit of measurement of this entity.""" return self._unit_of_measurement + + @property + def unique_id(self): + """Return an unique ID.""" + home = self._tibber_home.info['viewer']['home'] + return home['meteringPointData']['consumptionEan'] diff --git a/requirements_all.txt b/requirements_all.txt index de949dc1b93..a5f5b8ef473 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ pyHS100==0.3.0 pyRFXtrx==0.21.1 # homeassistant.components.sensor.tibber -pyTibber==0.3.0 +pyTibber==0.3.2 # homeassistant.components.switch.dlink pyW215==0.6.0 From a9d242a213adce24380fb773b6d0fb7701f623f0 Mon Sep 17 00:00:00 2001 From: JC Connell Date: Sat, 3 Mar 2018 11:41:33 -0500 Subject: [PATCH 116/191] Add support for Zillow Zestimate sensor (#12597) * Added Zillow Zestimate sensor. * Add zestimate.py to .coveragerc * Fix line 167 81>80 * Incorporate tinloaf changes. * Saving work * Incorporate changes requested by MartinHjelmare * Remove unnecessary import * Add a blank line between standard library and 3rd party imports --- .coveragerc | 1 + homeassistant/components/sensor/zestimate.py | 134 +++++++++++++++++++ requirements_all.txt | 1 + 3 files changed, 136 insertions(+) create mode 100644 homeassistant/components/sensor/zestimate.py diff --git a/.coveragerc b/.coveragerc index ab9d40a1896..156f546fefb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -670,6 +670,7 @@ omit = homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/zamg.py + homeassistant/components/sensor/zestimate.py homeassistant/components/shiftr.py homeassistant/components/spc.py homeassistant/components/switch/acer_projector.py diff --git a/homeassistant/components/sensor/zestimate.py b/homeassistant/components/sensor/zestimate.py new file mode 100644 index 00000000000..d8c759f1727 --- /dev/null +++ b/homeassistant/components/sensor/zestimate.py @@ -0,0 +1,134 @@ +""" +Support for zestimate data from zillow.com. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.zestimate/ +""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, + CONF_NAME, ATTR_ATTRIBUTION) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['xmltodict==0.11.0'] + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'http://www.zillow.com/webservice/GetZestimate.htm' + +CONF_ZPID = 'zpid' +CONF_ATTRIBUTION = "Data provided by Zillow.com" + +DEFAULT_NAME = 'Zestimate' +NAME = 'zestimate' +ZESTIMATE = '{}:{}'.format(DEFAULT_NAME, NAME) + +ICON = 'mdi:home-variant' + +ATTR_AMOUNT = 'amount' +ATTR_CHANGE = 'amount_change_30_days' +ATTR_CURRENCY = 'amount_currency' +ATTR_LAST_UPDATED = 'amount_last_updated' +ATTR_VAL_HI = 'valuation_range_high' +ATTR_VAL_LOW = 'valuation_range_low' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZPID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Zestimate sensor.""" + name = config.get(CONF_NAME) + properties = config[CONF_ZPID] + params = {'zws-id': config[CONF_API_KEY]} + + sensors = [] + for zpid in properties: + params['zpid'] = zpid + sensors.append(ZestimateDataSensor(name, params)) + add_devices(sensors, True) + + +class ZestimateDataSensor(Entity): + """Implementation of a Zestimate sensor.""" + + def __init__(self, name, params): + """Initialize the sensor.""" + self._name = name + self.params = params + self.data = None + self.address = None + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + try: + return round(float(self._state), 1) + except ValueError: + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = {} + if self.data is not None: + attributes = self.data + attributes['address'] = self.address + attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + return attributes + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data and update the states.""" + import xmltodict + try: + response = requests.get(_RESOURCE, params=self.params, timeout=5) + data = response.content.decode('utf-8') + data_dict = xmltodict.parse(data).get(ZESTIMATE) + error_code = int(data_dict['message']['code']) + if error_code != 0: + _LOGGER.error('The API returned: %s', + data_dict['message']['text']) + return + except requests.exceptions.ConnectionError: + _LOGGER.error('Unable to retrieve data from %s', _RESOURCE) + return + data = data_dict['response'][NAME] + details = {} + details[ATTR_AMOUNT] = data['amount']['#text'] + details[ATTR_CURRENCY] = data['amount']['@currency'] + details[ATTR_LAST_UPDATED] = data['last-updated'] + details[ATTR_CHANGE] = int(data['valueChange']['#text']) + details[ATTR_VAL_HI] = int(data['valuationRange']['high']['#text']) + details[ATTR_VAL_LOW] = int(data['valuationRange']['low']['#text']) + self.address = data_dict['response']['address']['street'] + self.data = details + if self.data is not None: + self._state = self.data[ATTR_AMOUNT] + else: + self._state = None + _LOGGER.error('Unable to parase Zestimate data from response') diff --git a/requirements_all.txt b/requirements_all.txt index a5f5b8ef473..5db632bd102 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1274,6 +1274,7 @@ xknx==0.8.3 # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr +# homeassistant.components.sensor.zestimate xmltodict==0.11.0 # homeassistant.components.sensor.yahoo_finance From e2e10b91a7dba79b26e99a87fe71f3c49f52774a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 3 Mar 2018 19:23:55 +0100 Subject: [PATCH 117/191] Grammar fix 'an unique' (#12870) --- homeassistant/components/binary_sensor/hikvision.py | 2 +- homeassistant/components/juicenet.py | 2 +- homeassistant/components/media_player/apple_tv.py | 2 +- homeassistant/components/media_player/cast.py | 2 +- homeassistant/components/media_player/openhome.py | 2 +- homeassistant/components/media_player/songpal.py | 2 +- homeassistant/components/media_player/sonos.py | 2 +- homeassistant/components/media_player/squeezebox.py | 2 +- homeassistant/components/remote/apple_tv.py | 2 +- homeassistant/components/sensor/tibber.py | 2 +- homeassistant/components/xiaomi_aqara.py | 2 +- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zwave/__init__.py | 2 +- homeassistant/helpers/entity.py | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index e87fcb7980a..f9ff4ac0a7a 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -214,7 +214,7 @@ class HikvisionBinarySensor(BinarySensorDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._id @property diff --git a/homeassistant/components/juicenet.py b/homeassistant/components/juicenet.py index 728a4fccf85..55567d45879 100644 --- a/homeassistant/components/juicenet.py +++ b/homeassistant/components/juicenet.py @@ -70,5 +70,5 @@ class JuicenetDevice(Entity): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return "{}-{}".format(self.device.id(), self.type) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index c2c70984734..4c5145ce1a4 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -79,7 +79,7 @@ class AppleTvDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self.atv.metadata.device_id @property diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 635951590ad..dbcb53ec185 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -418,7 +418,7 @@ class CastDevice(MediaPlayerDevice): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" if self.cast.uuid is not None: return str(self.cast.uuid) return None diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index bca6f2ad770..5e30f9783c7 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -156,7 +156,7 @@ class OpenhomeDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._device.Uuid() @property diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py index 83637633ba1..b1dc7df3319 100644 --- a/homeassistant/components/media_player/songpal.py +++ b/homeassistant/components/media_player/songpal.py @@ -128,7 +128,7 @@ class SongpalDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._sysinfo.macAddr @property diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 5928122dba4..0ba0c890810 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -367,7 +367,7 @@ class SonosDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 1fd61b3ead1..86b4087ca81 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -230,7 +230,7 @@ class SqueezeBoxDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._id @property diff --git a/homeassistant/components/remote/apple_tv.py b/homeassistant/components/remote/apple_tv.py index 36eee4b284e..7d11c931a65 100644 --- a/homeassistant/components/remote/apple_tv.py +++ b/homeassistant/components/remote/apple_tv.py @@ -45,7 +45,7 @@ class AppleTVRemote(remote.RemoteDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._atv.metadata.device_id @property diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 2e82d98af29..5ce614b978d 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -106,6 +106,6 @@ class TibberSensor(Entity): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" home = self._tibber_home.info['viewer']['home'] return home['meteringPointData']['consumptionEan'] diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 96f10de68e4..b6e04d867fa 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -242,7 +242,7 @@ class XiaomiDevice(Entity): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 9a8c88e6f23..88ca29101ad 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -306,7 +306,7 @@ class Entity(entity.Entity): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 0149bb9287a..21db39d4e76 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -916,7 +916,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a3c6e7a944b..f23a49c1851 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -93,7 +93,7 @@ class Entity(object): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" return None @property From 339a839dbe8cae07a84f0c541454487e16acbdb8 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 3 Mar 2018 22:54:38 +0100 Subject: [PATCH 118/191] Add SQL index to states.event_id (#12825) --- homeassistant/components/recorder/migration.py | 3 +++ homeassistant/components/recorder/models.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 325267b857e..af70c9d998c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -143,6 +143,9 @@ def _apply_update(engine, new_version, old_version): _drop_index(engine, "states", "ix_states_entity_id_created") _create_index(engine, "states", "ix_states_entity_id_last_updated") + elif new_version == 5: + # Create supporting index for States.event_id foreign key + _create_index(engine, "states", "ix_states_event_id") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 7c29c8045ea..32d6291b90c 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -16,7 +16,7 @@ from homeassistant.remote import JSONEncoder # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class States(Base): # type: ignore entity_id = Column(String(255)) state = Column(String(255)) attributes = Column(Text) - event_id = Column(Integer, ForeignKey('events.event_id')) + event_id = Column(Integer, ForeignKey('events.event_id'), index=True) last_changed = Column(DateTime(timezone=True), default=datetime.utcnow) last_updated = Column(DateTime(timezone=True), default=datetime.utcnow, index=True) From 54f8f1223f61a74bafd1c69c90086695e0ad6873 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 3 Mar 2018 22:54:55 +0100 Subject: [PATCH 119/191] Optimize logbook SQL query (#12881) --- homeassistant/components/logbook.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index e6e447884cb..d0b944793c4 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -47,6 +47,11 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +] + GROUP_BY_MINUTES = 15 CONTINUOUS_DOMAINS = ['proximity', 'sensor'] @@ -266,15 +271,18 @@ def humanify(events): def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by( - Events.time_fired).filter( - (Events.time_fired > start_day) & - (Events.time_fired < end_day)) + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter((States.last_updated == States.last_changed) + | (States.state_id.is_(None))) events = execute(query) return humanify(_exclude_events(events, config)) From f0d9844dfb90f3a45ba8ed5ac110e10cee9272af Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sat, 3 Mar 2018 22:55:24 +0100 Subject: [PATCH 120/191] await syntax knx scene (#12879) --- homeassistant/components/scene/knx.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/scene/knx.py b/homeassistant/components/scene/knx.py index 1329b440e5f..901e25aea82 100644 --- a/homeassistant/components/scene/knx.py +++ b/homeassistant/components/scene/knx.py @@ -4,8 +4,6 @@ Support for KNX scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.knx/ """ -import asyncio - import voluptuous as vol from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX @@ -28,9 +26,8 @@ PLATFORM_SCHEMA = vol.Schema({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the scenes for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -73,7 +70,6 @@ class KNXScene(Scene): """Return the name of the scene.""" return self.scene.name - @asyncio.coroutine - def async_activate(self): + async def async_activate(self): """Activate the scene.""" - yield from self.scene.run() + await self.scene.run() From 95176b0666590a2b984a34879ff56b8f4dcdb215 Mon Sep 17 00:00:00 2001 From: Boris K Date: Sat, 3 Mar 2018 22:59:25 +0100 Subject: [PATCH 121/191] Fix 0 value when home-assistant restarts (#12874) --- homeassistant/components/sensor/history_stats.py | 10 ++++++++-- tests/components/sensor/test_history_stats.py | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index de7b7ebaf9e..7af858b9d94 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -106,8 +106,8 @@ class HistoryStatsSensor(Entity): self._unit_of_measurement = UNITS[sensor_type] self._period = (datetime.datetime.now(), datetime.datetime.now()) - self.value = 0 - self.count = 0 + self.value = None + self.count = None def force_refresh(*args): """Force the component to refresh.""" @@ -127,6 +127,9 @@ class HistoryStatsSensor(Entity): @property def state(self): """Return the state of the sensor.""" + if self.value is None or self.count is None: + return None + if self._type == CONF_TYPE_TIME: return round(self.value, 2) @@ -149,6 +152,9 @@ class HistoryStatsSensor(Entity): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" + if self.value is None: + return {} + hsh = HistoryStatsHelper return { ATTR_VALUE: hsh.pretty_duration(self.value), diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index 7fdf732825b..1a2ec086e77 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -4,6 +4,7 @@ from datetime import timedelta import unittest from unittest.mock import patch +from homeassistant.const import STATE_UNKNOWN from homeassistant.setup import setup_component from homeassistant.components.sensor.history_stats import HistoryStatsSensor import homeassistant.core as ha @@ -43,8 +44,8 @@ class TestHistoryStatsSensor(unittest.TestCase): self.assertTrue(setup_component(self.hass, 'sensor', config)) - state = self.hass.states.get('sensor.test').as_dict() - self.assertEqual(state['state'], '0') + state = self.hass.states.get('sensor.test') + self.assertEqual(state.state, STATE_UNKNOWN) def test_period_parsing(self): """Test the conversion from templates to period.""" @@ -132,7 +133,7 @@ class TestHistoryStatsSensor(unittest.TestCase): sensor4.update() self.assertEqual(sensor1.state, 0.5) - self.assertEqual(sensor2.state, 0) + self.assertEqual(sensor2.state, None) self.assertEqual(sensor3.state, 2) self.assertEqual(sensor4.state, 50) From cf8907ed0fd907d501d0f6677086db18527bb4ba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Mar 2018 14:03:06 -0800 Subject: [PATCH 122/191] Fix aggressive scan intervals (#12885) --- homeassistant/components/alarm_control_panel/concord232.py | 2 +- homeassistant/components/binary_sensor/concord232.py | 2 +- homeassistant/components/sensor/folder.py | 2 +- homeassistant/components/sensor/simulated.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index e6a840910b8..d48a107f33d 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -26,7 +26,7 @@ DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'CONCORD232' DEFAULT_PORT = 5007 -SCAN_INTERVAL = timedelta(seconds=1) +SCAN_INTERVAL = timedelta(seconds=10) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py index 3bac561700a..f2acef47e82 100644 --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -27,7 +27,7 @@ DEFAULT_NAME = 'Alarm' DEFAULT_PORT = '5007' DEFAULT_SSL = False -SCAN_INTERVAL = datetime.timedelta(seconds=1) +SCAN_INTERVAL = datetime.timedelta(seconds=10) ZONE_TYPES_SCHEMA = vol.Schema({ cv.positive_int: vol.In(DEVICE_CLASSES), diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py index bd3957a36ca..a185cd1e825 100644 --- a/homeassistant/components/sensor/folder.py +++ b/homeassistant/components/sensor/folder.py @@ -21,7 +21,7 @@ CONF_FOLDER_PATHS = 'folder' CONF_FILTER = 'filter' DEFAULT_FILTER = '*' -SCAN_INTERVAL = timedelta(seconds=1) +SCAN_INTERVAL = timedelta(minutes=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FOLDER_PATHS): cv.isdir, diff --git a/homeassistant/components/sensor/simulated.py b/homeassistant/components/sensor/simulated.py index 297f2db9fc0..7091146e3ac 100644 --- a/homeassistant/components/sensor/simulated.py +++ b/homeassistant/components/sensor/simulated.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_NAME from homeassistant.components.sensor import PLATFORM_SCHEMA _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(seconds=1) +SCAN_INTERVAL = datetime.timedelta(seconds=30) ICON = 'mdi:chart-line' CONF_UNIT = 'unit' From ba20ffdde71b5645301a5293054b273528027f15 Mon Sep 17 00:00:00 2001 From: a-andre Date: Sun, 4 Mar 2018 02:04:32 +0100 Subject: [PATCH 123/191] Fix interaction with hyperion on NodeMCU (#12872) * Hyperion on NodeMCU has no 'activeEffects' entry * Hyperion on NodeMCU has non-empty 'activeLedColor' when light is turned off --- homeassistant/components/light/hyperion.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 4701866cd9a..2057192299e 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -213,9 +213,10 @@ class Hyperion(Light): except (KeyError, IndexError): pass - if not response['info']['activeLedColor']: + led_color = response['info']['activeLedColor'] + if not led_color or led_color[0]['RGB value'] == [0, 0, 0]: # Get the active effect - if response['info']['activeEffects']: + if response['info'].get('activeEffects'): self._rgb_color = [175, 0, 255] self._icon = 'mdi:lava-lamp' try: From 2321603eb706745e20e70d156a3894a7f3ac38eb Mon Sep 17 00:00:00 2001 From: Kevin Tuhumury Date: Sun, 4 Mar 2018 02:38:51 +0100 Subject: [PATCH 124/191] Add the Gamerscore and Tier of the account (#12867) * Added the Gamerscore and Tier of the account. The tier is the subscription type of the Xbox Live account, so Silver (which is now called Free) or Gold (paid). * Don't add the gamerscore and tier inside of the loop, since they're always the same. --- homeassistant/components/sensor/xbox_live.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py index 2fd1a66e790..0c7b8b48f62 100644 --- a/homeassistant/components/sensor/xbox_live.py +++ b/homeassistant/components/sensor/xbox_live.py @@ -69,7 +69,9 @@ class XboxSensor(Entity): if profile.get('success', True) and profile.get('code') is None: self.success_init = True self._gamertag = profile.get('gamertag') + self._gamerscore = profile.get('gamerscore') self._picture = profile.get('gamerpicSmallSslImagePath') + self._tier = profile.get('tier') else: _LOGGER.error("Can't get user profile %s. " "Error Code: %s Description: %s", @@ -92,6 +94,9 @@ class XboxSensor(Entity): def device_state_attributes(self): """Return the state attributes.""" attributes = {} + attributes['gamerscore'] = self._gamerscore + attributes['tier'] = self._tier + for device in self._presence: for title in device.get('titles'): attributes[ From d06807c6347f8df514e3fd564f73d3a291e03363 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 4 Mar 2018 06:22:31 +0100 Subject: [PATCH 125/191] Improve influxdb throughput (#12882) * Batch influxdb events for writing * Name constants --- homeassistant/components/influxdb.py | 161 ++++++++++++++++----------- tests/components/test_influxdb.py | 3 + 2 files changed, 101 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 526b8057ce1..08c94d54361 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -43,7 +43,10 @@ DOMAIN = 'influxdb' TIMEOUT = 5 RETRY_DELAY = 20 -QUEUE_BACKLOG_SECONDS = 10 +QUEUE_BACKLOG_SECONDS = 30 + +BATCH_TIMEOUT = 1 +BATCH_BUFFER_SIZE = 100 COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, @@ -143,18 +146,18 @@ def setup(hass, config): "READ/WRITE", exc) return False - def influx_handle_event(event): - """Send an event to Influx.""" + def event_to_json(event): + """Add an event to the outgoing Influx list.""" state = event.data.get('new_state') if state is None or state.state in ( STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \ state.entity_id in blacklist_e or state.domain in blacklist_d: - return True + return try: if (whitelist_e and state.entity_id not in whitelist_e) or \ (whitelist_d and state.domain not in whitelist_d): - return True + return _include_state = _include_value = False @@ -183,55 +186,48 @@ def setup(hass, config): else: include_uom = False - json_body = [ - { - 'measurement': measurement, - 'tags': { - 'domain': state.domain, - 'entity_id': state.object_id, - }, - 'time': event.time_fired, - 'fields': { - } - } - ] + json = { + 'measurement': measurement, + 'tags': { + 'domain': state.domain, + 'entity_id': state.object_id, + }, + 'time': event.time_fired, + 'fields': {} + } if _include_state: - json_body[0]['fields']['state'] = state.state + json['fields']['state'] = state.state if _include_value: - json_body[0]['fields']['value'] = _state_as_value + json['fields']['value'] = _state_as_value for key, value in state.attributes.items(): if key in tags_attributes: - json_body[0]['tags'][key] = value + json['tags'][key] = value elif key != 'unit_of_measurement' or include_uom: # If the key is already in fields - if key in json_body[0]['fields']: + if key in json['fields']: key = key + "_" # Prevent column data errors in influxDB. # For each value we try to cast it as float # But if we can not do it we store the value # as string add "_str" postfix to the field key try: - json_body[0]['fields'][key] = float(value) + json['fields'][key] = float(value) except (ValueError, TypeError): new_key = "{}_str".format(key) new_value = str(value) - json_body[0]['fields'][new_key] = new_value + json['fields'][new_key] = new_value if RE_DIGIT_TAIL.match(new_value): - json_body[0]['fields'][key] = float( + json['fields'][key] = float( RE_DECIMAL.sub('', new_value)) - json_body[0]['tags'].update(tags) + json['tags'].update(tags) - try: - influx.write_points(json_body) - return True - except (exceptions.InfluxDBClientError, IOError): - return False + return json instance = hass.data[DOMAIN] = InfluxThread( - hass, influx_handle_event, max_tries) + hass, influx, event_to_json, max_tries) instance.start() def shutdown(event): @@ -247,12 +243,15 @@ def setup(hass, config): class InfluxThread(threading.Thread): """A threaded event handler class.""" - def __init__(self, hass, event_handler, max_tries): + def __init__(self, hass, influx, event_to_json, max_tries): """Initialize the listener.""" threading.Thread.__init__(self, name='InfluxDB') self.queue = queue.Queue() - self.event_handler = event_handler + self.influx = influx + self.event_to_json = event_to_json self.max_tries = max_tries + self.write_errors = 0 + self.shutdown = False hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) def _event_listener(self, event): @@ -260,41 +259,77 @@ class InfluxThread(threading.Thread): item = (time.monotonic(), event) self.queue.put(item) - def run(self): - """Process incoming events.""" + @staticmethod + def batch_timeout(): + """Return number of seconds to wait for more events.""" + return BATCH_TIMEOUT + + def get_events_json(self): + """Return a batch of events formatted for writing.""" queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries*RETRY_DELAY - write_error = False - dropped = False + count = 0 + json = [] - while True: - item = self.queue.get() + dropped = 0 - if item is None: + try: + while len(json) < BATCH_BUFFER_SIZE and not self.shutdown: + timeout = None if count == 0 else self.batch_timeout() + item = self.queue.get(timeout=timeout) + count += 1 + + if item is None: + self.shutdown = True + else: + timestamp, event = item + age = time.monotonic() - timestamp + + if age < queue_seconds: + event_json = self.event_to_json(event) + if event_json: + json.append(event_json) + else: + dropped += 1 + + except queue.Empty: + pass + + if dropped: + _LOGGER.warning("Catching up, dropped %d old events", dropped) + + return count, json + + def write_to_influxdb(self, json): + """Write preprocessed events to influxdb, with retry.""" + from influxdb import exceptions + + for retry in range(self.max_tries+1): + try: + self.influx.write_points(json) + + if self.write_errors: + _LOGGER.error("Resumed, lost %d events", self.write_errors) + self.write_errors = 0 + + _LOGGER.debug("Wrote %d events", len(json)) + break + except (exceptions.InfluxDBClientError, IOError): + if retry < self.max_tries: + time.sleep(RETRY_DELAY) + else: + if not self.write_errors: + _LOGGER.exception("Write error") + self.write_errors += len(json) + + def run(self): + """Process incoming events.""" + while not self.shutdown: + count, json = self.get_events_json() + if json: + self.write_to_influxdb(json) + for _ in range(count): self.queue.task_done() - return - - timestamp, event = item - age = time.monotonic() - timestamp - - if age < queue_seconds: - for retry in range(self.max_tries+1): - if self.event_handler(event): - if write_error: - _LOGGER.error("Resumed writing to InfluxDB") - write_error = False - dropped = False - break - elif retry < self.max_tries: - time.sleep(RETRY_DELAY) - elif not write_error: - _LOGGER.error("Error writing to InfluxDB") - write_error = True - elif not dropped: - _LOGGER.warning("Dropping old events to catch up") - dropped = True - - self.queue.task_done() def block_till_done(self): """Block till all events processed.""" diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index 4d12e436c02..feaa0f35622 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -14,6 +14,9 @@ from tests.common import get_test_home_assistant @mock.patch('influxdb.InfluxDBClient') +@mock.patch( + 'homeassistant.components.influxdb.InfluxThread.batch_timeout', + mock.Mock(return_value=0)) class TestInfluxDB(unittest.TestCase): """Test the InfluxDB component.""" From 67c49a76628638f6f10dace2ec3f94fa1eb1a41c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Mar 2018 21:28:04 -0800 Subject: [PATCH 126/191] Add config flow for Hue (#12830) * Add config flow for Hue * Upgrade to aiohue 0.2 * Fix tests * Add tests * Add aiohue to test requirements * Bump aiohue dependency * Lint * Lint * Fix aiohttp mock * Lint * Fix tests --- .../components/config/config_entries.py | 2 +- homeassistant/components/hue.py | 138 +++++++++++- homeassistant/components/spc.py | 3 +- homeassistant/config_entries.py | 3 +- homeassistant/helpers/aiohttp_client.py | 9 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/test_hue.py | 201 +++++++++++++++++- tests/test_util/__init__.py | 1 + tests/test_util/aiohttp.py | 68 +++--- tests/test_util/test_aiohttp.py | 22 ++ 12 files changed, 389 insertions(+), 65 deletions(-) create mode 100644 tests/test_util/__init__.py create mode 100644 tests/test_util/test_aiohttp.py diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 7c4dcbd1602..aa42325b75b 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -163,7 +163,7 @@ class ConfigManagerFlowResourceView(HomeAssistantView): hass = request.app['hass'] try: - hass.config_entries.async_abort(flow_id) + hass.config_entries.flow.async_abort(flow_id) except config_entries.UnknownFlow: return self.json_message('Invalid flow specified', 404) diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 2d64306ca74..d3870f0a3a1 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -4,20 +4,24 @@ This component provides basic support for the Philips Hue system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/hue/ """ +import asyncio import json +from functools import partial import logging import os import socket +import async_timeout import requests import voluptuous as vol from homeassistant.components.discovery import SERVICE_HUE from homeassistant.const import CONF_FILENAME, CONF_HOST import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery +from homeassistant.helpers import discovery, aiohttp_client +from homeassistant import config_entries -REQUIREMENTS = ['phue==1.0'] +REQUIREMENTS = ['phue==1.0', 'aiohue==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -133,13 +137,14 @@ def bridge_discovered(hass, service, discovery_info): def setup_bridge(host, hass, filename=None, allow_unreachable=False, - allow_in_emulated_hue=True, allow_hue_groups=True): + allow_in_emulated_hue=True, allow_hue_groups=True, + username=None): """Set up a given Hue bridge.""" # Only register a device once if socket.gethostbyname(host) in hass.data[DOMAIN]: return - bridge = HueBridge(host, hass, filename, allow_unreachable, + bridge = HueBridge(host, hass, filename, username, allow_unreachable, allow_in_emulated_hue, allow_hue_groups) bridge.setup() @@ -164,13 +169,14 @@ def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): class HueBridge(object): """Manages a single Hue bridge.""" - def __init__(self, host, hass, filename, allow_unreachable=False, + def __init__(self, host, hass, filename, username, allow_unreachable=False, allow_in_emulated_hue=True, allow_hue_groups=True): """Initialize the system.""" self.host = host self.bridge_id = socket.gethostbyname(host) self.hass = hass self.filename = filename + self.username = username self.allow_unreachable = allow_unreachable self.allow_in_emulated_hue = allow_in_emulated_hue self.allow_hue_groups = allow_hue_groups @@ -189,10 +195,14 @@ class HueBridge(object): import phue try: - self.bridge = phue.Bridge( - self.host, - config_file_path=self.hass.config.path(self.filename)) - except (ConnectionRefusedError, OSError): # Wrong host was given + kwargs = {} + if self.username is not None: + kwargs['username'] = self.username + if self.filename is not None: + kwargs['config_file_path'] = \ + self.hass.config.path(self.filename) + self.bridge = phue.Bridge(self.host, **kwargs) + except OSError: # Wrong host was given _LOGGER.error("Error connecting to the Hue bridge at %s", self.host) return @@ -204,6 +214,7 @@ class HueBridge(object): except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error connecting with Hue bridge at %s", self.host) + return # If we came here and configuring this host, mark as done if self.config_request_id: @@ -260,3 +271,112 @@ class HueBridge(object): def set_group(self, light_id, command): """Change light settings for a group. See phue for detail.""" return self.bridge.set_group(light_id, command) + + +@config_entries.HANDLERS.register(DOMAIN) +class HueFlowHandler(config_entries.ConfigFlowHandler): + """Handle a Hue config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the Hue flow.""" + self.host = None + + @property + def _websession(self): + """Return a websession. + + Cannot assign in init because hass variable is not set yet. + """ + return aiohttp_client.async_get_clientsession(self.hass) + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + from aiohue.discovery import discover_nupnp + + if user_input is not None: + self.host = user_input['host'] + return await self.async_step_link() + + try: + with async_timeout.timeout(5): + bridges = await discover_nupnp(websession=self._websession) + except asyncio.TimeoutError: + return self.async_abort( + reason='Unable to discover Hue bridges.' + ) + + if not bridges: + return self.async_abort( + reason='No Philips Hue bridges discovered.' + ) + + # Find already configured hosts + configured_hosts = set( + entry.data['host'] for entry + in self.hass.config_entries.async_entries(DOMAIN)) + + hosts = [bridge.host for bridge in bridges + if bridge.host not in configured_hosts] + + if not hosts: + return self.async_abort( + reason='All Philips Hue bridges are already configured.' + ) + + elif len(hosts) == 1: + self.host = hosts[0] + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + title='Pick Hue Bridge', + data_schema=vol.Schema({ + vol.Required('host'): vol.In(hosts) + }) + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the Hue bridge.""" + import aiohue + errors = {} + + if user_input is not None: + bridge = aiohue.Bridge(self.host, websession=self._websession) + try: + with async_timeout.timeout(5): + # Create auth token + await bridge.create_user('home-assistant') + # Fetches name and id + await bridge.initialize() + except (asyncio.TimeoutError, aiohue.RequestError, + aiohue.LinkButtonNotPressed): + errors['base'] = 'Failed to register, please try again.' + except aiohue.AiohueException: + errors['base'] = 'Unknown linking error occurred.' + _LOGGER.exception('Uknown Hue linking error occurred') + else: + return self.async_create_entry( + title=bridge.config.name, + data={ + 'host': bridge.host, + 'bridge_id': bridge.config.bridgeid, + 'username': bridge.username, + } + ) + + return self.async_show_form( + step_id='link', + title='Link Hub', + description=CONFIG_INSTRUCTIONS, + errors=errors, + ) + + +async def async_setup_entry(hass, entry): + """Set up a bridge for a config entry.""" + await hass.async_add_job(partial( + setup_bridge, entry.data['host'], hass, + username=entry.data['username'])) + return True diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index 72477a5a65f..10544b3ef53 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -219,7 +219,8 @@ class SpcWebGateway: url = self._build_url(resource) try: _LOGGER.debug("Attempting to retrieve SPC data from %s", url) - session = aiohttp.ClientSession() + session = \ + self._hass.helpers.aiohttp_client.async_get_clientsession() with async_timeout.timeout(10, loop=self._hass.loop): action = session.get if use_get else session.put response = yield from action(url) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 63eff4e1f77..230e48f0cec 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -126,7 +126,8 @@ _LOGGER = logging.getLogger(__name__) HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ - 'config_entry_example' + 'config_entry_example', + 'hue', ] SOURCE_USER = 'user' diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 5a6e0eae1e3..ecce51a57b8 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -35,14 +35,7 @@ def async_get_clientsession(hass, verify_ssl=True): key = DATA_CLIENTSESSION_NOTVERIFY if key not in hass.data: - connector = _async_get_connector(hass, verify_ssl) - clientsession = aiohttp.ClientSession( - loop=hass.loop, - connector=connector, - headers={USER_AGENT: SERVER_SOFTWARE} - ) - _async_register_clientsession_shutdown(hass, clientsession) - hass.data[key] = clientsession + hass.data[key] = async_create_clientsession(hass, verify_ssl) return hass.data[key] diff --git a/requirements_all.txt b/requirements_all.txt index 5db632bd102..234b697709e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -75,6 +75,9 @@ aiodns==1.1.1 # homeassistant.components.http aiohttp_cors==0.6.0 +# homeassistant.components.hue +aiohue==0.3.0 + # homeassistant.components.sensor.imap aioimaplib==0.7.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da1a598a08a..4e5245c8fc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -34,6 +34,9 @@ aioautomatic==0.6.5 # homeassistant.components.http aiohttp_cors==0.6.0 +# homeassistant.components.hue +aiohue==0.3.0 + # homeassistant.components.notify.apns apns2==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3d8a7d1d8e6..087f722eed1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -37,6 +37,7 @@ COMMENT_REQUIREMENTS = ( TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', + 'aiohue', 'apns2', 'caldav', 'coinmarketcap', diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py index 30129ec7998..fa61cb2b69e 100644 --- a/tests/components/test_hue.py +++ b/tests/components/test_hue.py @@ -4,13 +4,17 @@ import logging import unittest from unittest.mock import call, MagicMock, patch +import aiohue +import pytest +import voluptuous as vol + from homeassistant.components import configurator, hue from homeassistant.const import CONF_FILENAME, CONF_HOST from homeassistant.setup import setup_component, async_setup_component from tests.common import ( assert_setup_component, get_test_home_assistant, get_test_config_dir, - MockDependency + MockDependency, MockConfigEntry, mock_coro ) _LOGGER = logging.getLogger(__name__) @@ -212,7 +216,8 @@ class TestHueBridge(unittest.TestCase): mock_bridge = mock_phue.Bridge mock_bridge.side_effect = ConnectionRefusedError() - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertTrue(bridge.config_request_id is None) @@ -228,7 +233,8 @@ class TestHueBridge(unittest.TestCase): mock_phue.PhueRegistrationException = Exception mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -250,7 +256,8 @@ class TestHueBridge(unittest.TestCase): None, ] - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -291,7 +298,8 @@ class TestHueBridge(unittest.TestCase): ConnectionRefusedError(), ] - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -332,7 +340,8 @@ class TestHueBridge(unittest.TestCase): mock_phue.PhueRegistrationException(1, 2), ] - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -364,7 +373,7 @@ class TestHueBridge(unittest.TestCase): """Test the hue_activate_scene service.""" with patch('homeassistant.helpers.discovery.load_platform'): bridge = hue.HueBridge('localhost', self.hass, - hue.PHUE_CONFIG_FILE) + hue.PHUE_CONFIG_FILE, None) bridge.setup() # No args @@ -393,15 +402,187 @@ class TestHueBridge(unittest.TestCase): bridge.bridge.run_scene.assert_called_once_with('group', 'scene') -@asyncio.coroutine -def test_setup_no_host(hass, requests_mock): +async def test_setup_no_host(hass, requests_mock): """No host specified in any way.""" requests_mock.get(hue.API_NUPNP, json=[]) with MockDependency('phue') as mock_phue: - result = yield from async_setup_component( + result = await async_setup_component( hass, hue.DOMAIN, {hue.DOMAIN: {}}) assert result mock_phue.Bridge.assert_not_called() assert hass.data[hue.DOMAIN] == {} + + +async def test_flow_works(hass, aioclient_mock): + """Test config flow .""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + + flow = hue.HueFlowHandler() + flow.hass = hass + await flow.async_step_init() + + with patch('aiohue.Bridge') as mock_bridge: + def mock_constructor(host, websession): + mock_bridge.host = host + return mock_bridge + + mock_bridge.side_effect = mock_constructor + mock_bridge.username = 'username-abc' + mock_bridge.config.name = 'Mock Bridge' + mock_bridge.config.bridgeid = 'bridge-id-1234' + mock_bridge.create_user.return_value = mock_coro() + mock_bridge.initialize.return_value = mock_coro() + + result = await flow.async_step_link(user_input={}) + + assert mock_bridge.host == '1.2.3.4' + assert len(mock_bridge.create_user.mock_calls) == 1 + assert len(mock_bridge.initialize.mock_calls) == 1 + + assert result['type'] == 'create_entry' + assert result['title'] == 'Mock Bridge' + assert result['data'] == { + 'host': '1.2.3.4', + 'bridge_id': 'bridge-id-1234', + 'username': 'username-abc' + } + + +async def test_flow_no_discovered_bridges(hass, aioclient_mock): + """Test config flow discovers no bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): + """Test config flow discovers only already configured bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_one_bridge_discovered(hass, aioclient_mock): + """Test config flow discovers one bridge.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_flow_two_bridges_discovered(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + with pytest.raises(vol.Invalid): + assert result['data_schema']({'host': '0.0.0.0'}) + + result['data_schema']({'host': '1.2.3.4'}) + result['data_schema']({'host': '5.6.7.8'}) + + +async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert flow.host == '5.6.7.8' + + +async def test_flow_timeout_discovery(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.discovery.discover_nupnp', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_init() + + assert result['type'] == 'abort' + + +async def test_flow_link_timeout(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'Failed to register, please try again.' + } + + +async def test_flow_link_button_not_pressed(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.LinkButtonNotPressed): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'Failed to register, please try again.' + } + + +async def test_flow_link_unknown_host(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.RequestError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'Failed to register, please try again.' + } diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py new file mode 100644 index 00000000000..b8499675ea2 --- /dev/null +++ b/tests/test_util/__init__.py @@ -0,0 +1 @@ +"""Tests for the test utilities.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d7033775a14..d661ffba477 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -1,11 +1,13 @@ """Aiohttp test utils.""" import asyncio from contextlib import contextmanager -import functools import json as _json +import re from unittest import mock -from urllib.parse import urlparse, parse_qs -import yarl +from urllib.parse import parse_qs + +from aiohttp import ClientSession +from yarl import URL from aiohttp.client_exceptions import ClientResponseError @@ -31,14 +33,17 @@ class AiohttpClientMocker: exc=None, cookies=None): """Mock a request.""" - if json: + if json is not None: text = _json.dumps(json) - if text: + if text is not None: content = text.encode('utf-8') if content is None: content = b'' + + if not isinstance(url, re._pattern_type): + url = URL(url) if params: - url = str(yarl.URL(url).with_query(params)) + url = url.with_query(params) self._mocks.append(AiohttpClientMockResponse( method, url, status, content, cookies, exc, headers)) @@ -74,13 +79,21 @@ class AiohttpClientMocker: self._cookies.clear() self.mock_calls.clear() - @asyncio.coroutine - # pylint: disable=unused-variable - def match_request(self, method, url, *, data=None, auth=None, params=None, - headers=None, allow_redirects=None, timeout=None, - json=None): + def create_session(self, loop): + """Create a ClientSession that is bound to this mocker.""" + session = ClientSession(loop=loop) + session._request = self.match_request + return session + + async def match_request(self, method, url, *, data=None, auth=None, + params=None, headers=None, allow_redirects=None, + timeout=None, json=None): """Match a request against pre-registered requests.""" data = data or json + url = URL(url) + if params: + url = url.with_query(params) + for response in self._mocks: if response.match_request(method, url, params): self.mock_calls.append((method, url, data, headers)) @@ -101,8 +114,6 @@ class AiohttpClientMockResponse: """Initialize a fake response.""" self.method = method self._url = url - self._url_parts = (None if hasattr(url, 'search') - else urlparse(url.lower())) self.status = status self.response = response self.exc = exc @@ -133,25 +144,17 @@ class AiohttpClientMockResponse: if method.lower() != self.method.lower(): return False - if params: - url = str(yarl.URL(url).with_query(params)) - # regular expression matching - if self._url_parts is None: - return self._url.search(url) is not None + if isinstance(self._url, re._pattern_type): + return self._url.search(str(url)) is not None - req = urlparse(url.lower()) - - if self._url_parts.scheme and req.scheme != self._url_parts.scheme: - return False - if self._url_parts.netloc and req.netloc != self._url_parts.netloc: - return False - if (req.path or '/') != (self._url_parts.path or '/'): + if (self._url.scheme != url.scheme or self._url.host != url.host or + self._url.path != url.path): return False # Ensure all query components in matcher are present in the request - request_qs = parse_qs(req.query) - matcher_qs = parse_qs(self._url_parts.query) + request_qs = parse_qs(url.query_string) + matcher_qs = parse_qs(self._url.query_string) for key, vals in matcher_qs.items(): for val in vals: try: @@ -207,12 +210,7 @@ def mock_aiohttp_client(): """Context manager to mock aiohttp client.""" mocker = AiohttpClientMocker() - with mock.patch('aiohttp.ClientSession') as mock_session: - instance = mock_session() - instance.request = mocker.match_request - - for method in ('get', 'post', 'put', 'options', 'delete'): - setattr(instance, method, - functools.partial(mocker.match_request, method)) - + with mock.patch( + 'homeassistant.helpers.aiohttp_client.async_create_clientsession', + side_effect=lambda hass, *args: mocker.create_session(hass.loop)): yield mocker diff --git a/tests/test_util/test_aiohttp.py b/tests/test_util/test_aiohttp.py new file mode 100644 index 00000000000..7f430e94beb --- /dev/null +++ b/tests/test_util/test_aiohttp.py @@ -0,0 +1,22 @@ +"""Tests for our aiohttp mocker.""" +from .aiohttp import AiohttpClientMocker + +import pytest + + +async def test_matching_url(): + """Test we can match urls.""" + mocker = AiohttpClientMocker() + mocker.get('http://example.com') + await mocker.match_request('get', 'http://example.com/') + + mocker.clear_requests() + + with pytest.raises(AssertionError): + await mocker.match_request('get', 'http://example.com/') + + mocker.clear_requests() + + mocker.get('http://example.com?a=1') + await mocker.match_request('get', 'http://example.com/', + params={'a': 1, 'b': 2}) From 2e5b4946e1566820aaf33e0d0387129a7b7e9f82 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Sun, 4 Mar 2018 01:14:47 -0800 Subject: [PATCH 127/191] Fix issue with guest August lock being included (#12893) --- homeassistant/components/august.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index c12e18ef09c..2a7da86c6cf 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) _CONFIGURING = {} -REQUIREMENTS = ['py-august==0.3.0'] +REQUIREMENTS = ['py-august==0.4.0'] DEFAULT_TIMEOUT = 10 ACTIVITY_FETCH_LIMIT = 10 @@ -159,7 +159,7 @@ class AugustData: self._api = api self._access_token = access_token self._doorbells = self._api.get_doorbells(self._access_token) or [] - self._locks = self._api.get_locks(self._access_token) or [] + self._locks = self._api.get_operable_locks(self._access_token) or [] self._house_ids = [d.house_id for d in self._doorbells + self._locks] self._doorbell_detail_by_id = {} diff --git a/requirements_all.txt b/requirements_all.txt index 234b697709e..b61502add4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -622,7 +622,7 @@ pushetta==1.0.15 pwmled==1.2.1 # homeassistant.components.august -py-august==0.3.0 +py-august==0.4.0 # homeassistant.components.canary py-canary==0.4.0 From 53cc3262bd6e9f466293bcf10f17198968f9e68a Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Sun, 4 Mar 2018 01:19:12 -0800 Subject: [PATCH 128/191] Upgrade to py-canary 0.4.1 (#12894) --- homeassistant/components/canary.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py index dfef4976eb8..03825bf48a9 100644 --- a/homeassistant/components/canary.py +++ b/homeassistant/components/canary.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT from homeassistant.helpers import discovery from homeassistant.util import Throttle -REQUIREMENTS = ['py-canary==0.4.0'] +REQUIREMENTS = ['py-canary==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b61502add4a..0491c4705f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ pwmled==1.2.1 py-august==0.4.0 # homeassistant.components.canary -py-canary==0.4.0 +py-canary==0.4.1 # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e5245c8fc3..2ab0a5c468d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -124,7 +124,7 @@ prometheus_client==0.1.0 pushbullet.py==0.11.0 # homeassistant.components.canary -py-canary==0.4.0 +py-canary==0.4.1 # homeassistant.components.zwave pydispatcher==2.0.5 From ae257651bf975d26ff7e5fea82c8713612d79dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Sun, 4 Mar 2018 11:20:03 +0100 Subject: [PATCH 129/191] update html5 to async/await (#12895) --- homeassistant/components/notify/html5.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 45439dbfbfe..4269e577edf 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -4,7 +4,6 @@ HTML5 Push Messaging notification service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.html5/ """ -import asyncio import datetime import json import logging @@ -155,11 +154,10 @@ class HTML5PushRegistrationView(HomeAssistantView): self.registrations = registrations self.json_path = json_path - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Accept the POST request for push registrations from a browser.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -177,8 +175,8 @@ class HTML5PushRegistrationView(HomeAssistantView): try: hass = request.app['hass'] - yield from hass.async_add_job(save_json, self.json_path, - self.registrations) + await hass.async_add_job(save_json, self.json_path, + self.registrations) return self.json_message( 'Push notification subscriber registered.') except HomeAssistantError: @@ -199,11 +197,10 @@ class HTML5PushRegistrationView(HomeAssistantView): return key return ensure_unique_string('unnamed device', self.registrations) - @asyncio.coroutine - def delete(self, request): + async def delete(self, request): """Delete a registration.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -225,8 +222,8 @@ class HTML5PushRegistrationView(HomeAssistantView): try: hass = request.app['hass'] - yield from hass.async_add_job(save_json, self.json_path, - self.registrations) + await hass.async_add_job(save_json, self.json_path, + self.registrations) except HomeAssistantError: self.registrations[found] = reg return self.json_message( @@ -296,15 +293,14 @@ class HTML5PushCallbackView(HomeAssistantView): status_code=HTTP_UNAUTHORIZED) return payload - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Accept the POST request for push registrations event callback.""" auth_check = self.check_authorization_header(request) if not isinstance(auth_check, dict): return auth_check try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) From 36e9f523d1b22dbad6b01d76760eb2c7cd48eb87 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 4 Mar 2018 02:35:38 -0800 Subject: [PATCH 130/191] Adding additional switches and sensors for Tesla (#12241) * Adding switch for maxrange charging and sensors for odometer and range * Fixing style errors --- .../components/device_tracker/tesla.py | 23 +++++----- homeassistant/components/sensor/tesla.py | 8 ++++ homeassistant/components/switch/tesla.py | 43 +++++++++++++++++-- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py index 4945e98a94d..ba9bc8c2631 100644 --- a/homeassistant/components/device_tracker/tesla.py +++ b/homeassistant/components/device_tracker/tesla.py @@ -44,14 +44,15 @@ class TeslaDeviceTracker(object): _LOGGER.debug("Updating device position: %s", name) dev_id = slugify(device.uniq_name) location = device.get_location() - lat = location['latitude'] - lon = location['longitude'] - attrs = { - 'trackr_id': dev_id, - 'id': dev_id, - 'name': name - } - self.see( - dev_id=dev_id, host_name=name, - gps=(lat, lon), attributes=attrs - ) + if location: + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py index 74e74262710..1ffc97bb137 100644 --- a/homeassistant/components/sensor/tesla.py +++ b/homeassistant/components/sensor/tesla.py @@ -78,6 +78,14 @@ class TeslaSensor(TeslaDevice, Entity): self._unit = TEMP_FAHRENHEIT else: self._unit = TEMP_CELSIUS + elif (self.tesla_device.bin_type == 0xA or + self.tesla_device.bin_type == 0xB): + self.current_value = self.tesla_device.get_value() + tesla_dist_unit = self.tesla_device.measurement + if tesla_dist_unit == 'LENGTH_MILES': + self._unit = LENGTH_MILES + else: + self._unit = LENGTH_KILOMETERS else: self.current_value = self.tesla_device.get_value() if self.tesla_device.bin_type == 0x5: diff --git a/homeassistant/components/switch/tesla.py b/homeassistant/components/switch/tesla.py index 2f105a709ad..0e1b7e819f7 100644 --- a/homeassistant/components/switch/tesla.py +++ b/homeassistant/components/switch/tesla.py @@ -17,8 +17,13 @@ DEPENDENCIES = ['tesla'] def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tesla switch platform.""" - devices = [ChargerSwitch(device, hass.data[TESLA_DOMAIN]['controller']) - for device in hass.data[TESLA_DOMAIN]['devices']['switch']] + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + for device in hass.data[TESLA_DOMAIN]['devices']['switch']: + if device.bin_type == 0x8: + devices.append(ChargerSwitch(device, controller)) + elif device.bin_type == 0x9: + devices.append(RangeSwitch(device, controller)) add_devices(devices, True) @@ -38,7 +43,7 @@ class ChargerSwitch(TeslaDevice, SwitchDevice): def turn_off(self, **kwargs): """Send the off command.""" - _LOGGER.debug("Disable charging for: %s", self._name) + _LOGGER.debug("Disable charging for: %s", self._name) self.tesla_device.stop_charge() @property @@ -52,3 +57,35 @@ class ChargerSwitch(TeslaDevice, SwitchDevice): self.tesla_device.update() self._state = STATE_ON if self.tesla_device.is_charging() \ else STATE_OFF + + +class RangeSwitch(TeslaDevice, SwitchDevice): + """Representation of a Tesla max range charging switch.""" + + def __init__(self, tesla_device, controller): + """Initialise of the switch.""" + self._state = None + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def turn_on(self, **kwargs): + """Send the on command.""" + _LOGGER.debug("Enable max range charging: %s", self._name) + self.tesla_device.set_max() + + def turn_off(self, **kwargs): + """Send the off command.""" + _LOGGER.debug("Disable max range charging: %s", self._name) + self.tesla_device.set_standard() + + @property + def is_on(self): + """Get whether the switch is in on state.""" + return self._state == STATE_ON + + def update(self): + """Update the state of the switch.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_ON if self.tesla_device.is_maxrange() \ + else STATE_OFF From a147401034401289b840c2287b45cce8a2e6244a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 4 Mar 2018 14:02:31 +0100 Subject: [PATCH 131/191] Additional radio schemes for sonos (#12886) --- homeassistant/components/media_player/sonos.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 0ba0c890810..c09fe06a0af 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -323,8 +323,10 @@ def _timespan_secs(timespan): def _is_radio_uri(uri): """Return whether the URI is a radio stream.""" - return uri.startswith('x-rincon-mp3radio:') or \ - uri.startswith('x-sonosapi-stream:') + radio_schemes = ( + 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:', + 'hls-radio:') + return uri.startswith(radio_schemes) class SonosDevice(MediaPlayerDevice): From 81ba666db791328acacad2047aeeab92f92cb918 Mon Sep 17 00:00:00 2001 From: Andrei Pop Date: Sun, 4 Mar 2018 21:36:38 +0200 Subject: [PATCH 132/191] Fix Edimax new firmware auth error and move to pyedimax fork (#12873) * Fixed Edimax switch authentication error for newer firmware. * pyedimax moved to pypi * Added pyedimax to gen_requirements_all.py * Cleanup --- homeassistant/components/switch/edimax.py | 3 +-- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index d4b02749c1b..50b5ba93b85 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/rkabadi/pyedimax/archive/' - '365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1'] +REQUIREMENTS = ['pyedimax==0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 0491c4705f5..2fce3ab0c24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -385,9 +385,6 @@ https://github.com/jabesq/pybotvac/archive/v0.0.5.zip#pybotvac==0.0.5 # homeassistant.components.switch.anel_pwrctrl https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 -# homeassistant.components.switch.edimax -https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 - # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 @@ -714,6 +711,9 @@ pyebox==0.1.0 # homeassistant.components.climate.econet pyeconet==0.0.5 +# homeassistant.components.switch.edimax +pyedimax==0.1 + # homeassistant.components.eight_sleep pyeight==0.0.7 From d418355d4d24e2592920847ac61e1cbaf3d6501e Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 4 Mar 2018 21:01:16 +0100 Subject: [PATCH 133/191] InfluxDB cleanups (#12903) * Close influxdb on shutdown * Ignore inf as an influxdb value * Remove deprecated CONF_RETRY_QUEUE --- homeassistant/components/influxdb.py | 9 +++++--- tests/components/test_influxdb.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 08c94d54361..1f7f9f6262f 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -35,7 +35,6 @@ CONF_COMPONENT_CONFIG = 'component_config' CONF_COMPONENT_CONFIG_GLOB = 'component_config_glob' CONF_COMPONENT_CONFIG_DOMAIN = 'component_config_domain' CONF_RETRY_COUNT = 'max_retries' -CONF_RETRY_QUEUE = 'retry_queue_limit' DEFAULT_DATABASE = 'home_assistant' DEFAULT_VERIFY_SSL = True @@ -53,7 +52,7 @@ COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.deprecated(CONF_RETRY_QUEUE), vol.Schema({ + DOMAIN: vol.All(vol.Schema({ vol.Optional(CONF_HOST): cv.string, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, @@ -71,7 +70,6 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_SSL): cv.boolean, vol.Optional(CONF_RETRY_COUNT, default=0): cv.positive_int, - vol.Optional(CONF_RETRY_QUEUE, default=20): cv.positive_int, vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string, vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, vol.Optional(CONF_TAGS, default={}): @@ -222,6 +220,10 @@ def setup(hass, config): json['fields'][key] = float( RE_DECIMAL.sub('', new_value)) + # Infinity is not a valid float in InfluxDB + if (key, float("inf")) in json['fields'].items(): + del json['fields'][key] + json['tags'].update(tags) return json @@ -234,6 +236,7 @@ def setup(hass, config): """Shut down the thread.""" instance.queue.put(None) instance.join() + influx.close() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index feaa0f35622..c909a8488be 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -213,6 +213,37 @@ class TestInfluxDB(unittest.TestCase): ) mock_client.return_value.write_points.reset_mock() + def test_event_listener_inf(self, mock_client): + """Test the event listener for missing units.""" + self._setup() + + attrs = {'bignumstring': "9" * 999} + state = mock.MagicMock( + state=8, domain='fake', entity_id='fake.entity-id', + object_id='entity', attributes=attrs) + event = mock.MagicMock(data={'new_state': state}, time_fired=12345) + body = [{ + 'measurement': 'fake.entity-id', + 'tags': { + 'domain': 'fake', + 'entity_id': 'entity', + }, + 'time': 12345, + 'fields': { + 'value': 8, + }, + }] + self.handler_method(event) + self.hass.data[influxdb.DOMAIN].block_till_done() + self.assertEqual( + mock_client.return_value.write_points.call_count, 1 + ) + self.assertEqual( + mock_client.return_value.write_points.call_args, + mock.call(body) + ) + mock_client.return_value.write_points.reset_mock() + def test_event_listener_states(self, mock_client): """Test the event listener against ignored states.""" self._setup() From 70760b5d3b2e4b66eb6aed4adea191093c554210 Mon Sep 17 00:00:00 2001 From: Jason Albert Date: Sun, 4 Mar 2018 15:59:54 -0600 Subject: [PATCH 134/191] Fix for moisture sensors in isy994 (#12734) * Fix for moisture sensors - previously never triggered * Change quotes to be consistent --- .../components/binary_sensor/isy994.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index 4dddb9bdbef..fb86244acf3 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -56,24 +56,17 @@ def setup_platform(hass, config: ConfigType, else: device_type = _detect_device_type(node) subnode_id = int(node.nid[-1]) - if device_type == 'opening': - # Door/window sensors use an optional "negative" node - if subnode_id == 4: + if (device_type == 'opening' or device_type == 'moisture'): + # These sensors use an optional "negative" subnode 2 to snag + # all state changes + if subnode_id == 2: + parent_device.add_negative_node(node) + elif subnode_id == 4: # Subnode 4 is the heartbeat node, which we will represent # as a separate binary_sensor device = ISYBinarySensorHeartbeat(node, parent_device) parent_device.add_heartbeat_device(device) devices.append(device) - elif subnode_id == 2: - parent_device.add_negative_node(node) - elif device_type == 'moisture': - # Moisture nodes have a subnode 2, but we ignore it because - # it's just the inverse of the primary node. - if subnode_id == 4: - # Heartbeat node - device = ISYBinarySensorHeartbeat(node, parent_device) - parent_device.add_heartbeat_device(device) - devices.append(device) else: # We don't yet have any special logic for other sensor types, # so add the nodes as individual devices From 7145afe7294bd8fbc5dfc996f574ebdd8a3a8456 Mon Sep 17 00:00:00 2001 From: Richard Lucas Date: Sun, 4 Mar 2018 15:04:11 -0800 Subject: [PATCH 135/191] Apple TV should return all supported features (#12167) --- homeassistant/components/media_player/apple_tv.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 4c5145ce1a4..37a50b39e95 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -25,6 +25,10 @@ DEPENDENCIES = ['apple_tv'] _LOGGER = logging.getLogger(__name__) +SUPPORT_APPLE_TV = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \ + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ + SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -196,14 +200,7 @@ class AppleTvDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - features = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA - if self._playing is None or self.state == STATE_IDLE: - return features - - features |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ - SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK - - return features + return SUPPORT_APPLE_TV @asyncio.coroutine def async_turn_on(self): From fd409a16a1001d6dccf24d838fef222b0b4bb13a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 5 Mar 2018 03:30:15 +0100 Subject: [PATCH 136/191] Remove dynamic controls from sonos (#12908) --- .../components/media_player/sonos.py | 56 +++++-------------- 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index c09fe06a0af..a03a2e1db97 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -36,8 +36,9 @@ logging.getLogger('soco.data_structures_entry').setLevel(logging.ERROR) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ - SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\ + SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -69,7 +70,7 @@ ATTR_SPEECH_ENHANCE = 'speech_enhance' ATTR_IS_COORDINATOR = 'is_coordinator' -UPNP_ERRORS_TO_IGNORE = ['701'] +UPNP_ERRORS_TO_IGNORE = ['701', '711'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ADVERTISE_ADDR): cv.string, @@ -344,7 +345,6 @@ class SonosDevice(MediaPlayerDevice): self._name = None self._coordinator = None self._status = None - self._extra_features = 0 self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -464,7 +464,6 @@ class SonosDevice(MediaPlayerDevice): self._media_artist = None self._media_album_name = None self._media_title = None - self._extra_features = 0 self._source_name = None def process_avtransport_event(self, event): @@ -485,15 +484,11 @@ class SonosDevice(MediaPlayerDevice): else: track_info = self.soco.get_current_track_info() - media_info = self.soco.avTransport.GetMediaInfo( - [('InstanceID', 0)] - ) - if _is_radio_uri(track_info['uri']): - self._refresh_radio(event.variables, media_info, track_info) + self._refresh_radio(event.variables, track_info) else: update_position = (new_status != self._status) - self._refresh_music(update_position, media_info, track_info) + self._refresh_music(update_position, track_info) self._status = new_status @@ -565,8 +560,6 @@ class SonosDevice(MediaPlayerDevice): def _refresh_linein(self, source): """Update state when playing from line-in/tv.""" - self._extra_features = 0 - self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -579,14 +572,13 @@ class SonosDevice(MediaPlayerDevice): self._source_name = source - def _refresh_radio(self, variables, media_info, track_info): + def _refresh_radio(self, variables, track_info): """Update state when streaming radio.""" - self._extra_features = 0 - self._media_duration = None self._media_position = None self._media_position_updated_at = None + media_info = self.soco.avTransport.GetMediaInfo([('InstanceID', 0)]) self._media_image_url = self._radio_artwork(media_info['CurrentURI']) self._media_artist = track_info.get('artist') @@ -641,30 +633,8 @@ class SonosDevice(MediaPlayerDevice): if fav.reference.get_uri() == media_info['CurrentURI']: self._source_name = fav.title - def _refresh_music(self, update_media_position, media_info, track_info): + def _refresh_music(self, update_media_position, track_info): """Update state when playing music tracks.""" - self._extra_features = SUPPORT_PAUSE | SUPPORT_SHUFFLE_SET |\ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK - - playlist_position = track_info.get('playlist_position') - if playlist_position in ('', 'NOT_IMPLEMENTED', None): - playlist_position = None - else: - playlist_position = int(playlist_position) - - playlist_size = media_info.get('NrTracks') - if playlist_size in ('', 'NOT_IMPLEMENTED', None): - playlist_size = None - else: - playlist_size = int(playlist_size) - - if playlist_position is not None and playlist_size is not None: - if playlist_position <= 1: - self._extra_features &= ~SUPPORT_PREVIOUS_TRACK - - if playlist_position == playlist_size: - self._extra_features &= ~SUPPORT_NEXT_TRACK - self._media_duration = _timespan_secs(track_info.get('duration')) position_info = self.soco.avTransport.GetPositionInfo( @@ -775,7 +745,7 @@ class SonosDevice(MediaPlayerDevice): @soco_coordinator def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_SONOS | self._extra_features + return SUPPORT_SONOS @soco_error() def volume_up(self): @@ -865,19 +835,19 @@ class SonosDevice(MediaPlayerDevice): """Send pause command.""" self.soco.pause() - @soco_error() + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_next_track(self): """Send next track command.""" self.soco.next() - @soco_error() + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_previous_track(self): """Send next track command.""" self.soco.previous() - @soco_error() + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_seek(self, position): """Send seek command.""" From 3920de7119bec088f72d4612ebde95563fc6b805 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 4 Mar 2018 18:31:29 -0800 Subject: [PATCH 137/191] Fix async method call in sync context (#12890) --- homeassistant/components/device_tracker/icloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 7ca8a5cb232..781e3674550 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -330,7 +330,7 @@ class Icloud(DeviceScanner): return zones = (self.hass.states.get(entity_id) for entity_id - in sorted(self.hass.states.async_entity_ids('zone'))) + in sorted(self.hass.states.entity_ids('zone'))) distances = [] for zone_state in zones: From f00d5cb8caef033d65096021a0c441a907f38646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Mon, 5 Mar 2018 03:35:07 +0100 Subject: [PATCH 138/191] update html5 to async/await tests (#12896) * update html5 to async/await tests * removed paranthesis --- tests/components/notify/test_html5.py | 105 +++++++++++--------------- 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index d6c06f77d93..344051b6c39 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -1,5 +1,4 @@ """Test HTML5 notify platform.""" -import asyncio import json from unittest.mock import patch, MagicMock, mock_open from aiohttp.hdrs import AUTHORIZATION @@ -50,21 +49,20 @@ REGISTER_URL = '/api/notify.html5' PUBLISH_URL = '/api/notify.html5/callback' -@asyncio.coroutine -def mock_client(hass, test_client, registrations=None): +async def mock_client(hass, test_client, registrations=None): """Create a test client for HTML5 views.""" if registrations is None: registrations = {} with patch('homeassistant.components.notify.html5._load_config', return_value=registrations): - yield from async_setup_component(hass, 'notify', { + await async_setup_component(hass, 'notify', { 'notify': { 'platform': 'html5' } }) - return (yield from test_client(hass.http.app)) + return await test_client(hass.http.app) class TestHtml5Notify(object): @@ -118,14 +116,12 @@ class TestHtml5Notify(object): assert payload['icon'] == 'beer.png' -@asyncio.coroutine -def test_registering_new_device_view(hass, test_client): +async def test_registering_new_device_view(hass, test_client): """Test that the HTML view works.""" - client = yield from mock_client(hass, test_client) + client = await mock_client(hass, test_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) assert resp.status == 200 assert len(mock_save.mock_calls) == 1 @@ -134,14 +130,12 @@ def test_registering_new_device_view(hass, test_client): } -@asyncio.coroutine -def test_registering_new_device_expiration_view(hass, test_client): +async def test_registering_new_device_expiration_view(hass, test_client): """Test that the HTML view works.""" - client = yield from mock_client(hass, test_client) + client = await mock_client(hass, test_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 200 assert mock_save.mock_calls[0][1][1] == { @@ -149,32 +143,27 @@ def test_registering_new_device_expiration_view(hass, test_client): } -@asyncio.coroutine -def test_registering_new_device_fails_view(hass, test_client): +async def test_registering_new_device_fails_view(hass, test_client): """Test subs. are not altered when registering a new device fails.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 500 assert registrations == {} -@asyncio.coroutine -def test_registering_existing_device_view(hass, test_client): +async def test_registering_existing_device_view(hass, test_client): """Test subscription is updated when registering existing device.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 200 assert mock_save.mock_calls[0][1][1] == { @@ -185,18 +174,15 @@ def test_registering_existing_device_view(hass, test_client): } -@asyncio.coroutine -def test_registering_existing_device_fails_view(hass, test_client): +async def test_registering_existing_device_fails_view(hass, test_client): """Test sub. is not updated when registering existing device fails.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) mock_save.side_effect = HomeAssistantError - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 500 assert registrations == { @@ -204,42 +190,40 @@ def test_registering_existing_device_fails_view(hass, test_client): } -@asyncio.coroutine -def test_registering_new_device_validation(hass, test_client): +async def test_registering_new_device_validation(hass, test_client): """Test various errors when registering a new device.""" - client = yield from mock_client(hass, test_client) + client = await mock_client(hass, test_client) - resp = yield from client.post(REGISTER_URL, data=json.dumps({ + resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'invalid browser', 'subscription': 'sub info', })) assert resp.status == 400 - resp = yield from client.post(REGISTER_URL, data=json.dumps({ + resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'chrome', })) assert resp.status == 400 with patch('homeassistant.components.notify.html5.save_json', return_value=False): - resp = yield from client.post(REGISTER_URL, data=json.dumps({ + resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'chrome', 'subscription': 'sub info', })) assert resp.status == 400 -@asyncio.coroutine -def test_unregistering_device_view(hass, test_client): +async def test_unregistering_device_view(hass, test_client): """Test that the HTML unregister view works.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + resp = await client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], })) @@ -250,14 +234,14 @@ def test_unregistering_device_view(hass, test_client): } -@asyncio.coroutine -def test_unregister_device_view_handle_unknown_subscription(hass, test_client): +async def test_unregister_device_view_handle_unknown_subscription(hass, + test_client): """Test that the HTML unregister view handles unknown subscriptions.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + resp = await client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_3['subscription'] })) @@ -266,18 +250,17 @@ def test_unregister_device_view_handle_unknown_subscription(hass, test_client): assert len(mock_save.mock_calls) == 0 -@asyncio.coroutine -def test_unregistering_device_view_handles_save_error(hass, test_client): +async def test_unregistering_device_view_handles_save_error(hass, test_client): """Test that the HTML unregister view handles save errors.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + resp = await client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], })) @@ -288,11 +271,10 @@ def test_unregistering_device_view_handles_save_error(hass, test_client): } -@asyncio.coroutine -def test_callback_view_no_jwt(hass, test_client): +async def test_callback_view_no_jwt(hass, test_client): """Test that the notification callback view works without JWT.""" - client = yield from mock_client(hass, test_client) - resp = yield from client.post(PUBLISH_URL, data=json.dumps({ + client = await mock_client(hass, test_client) + resp = await client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' })) @@ -300,16 +282,15 @@ def test_callback_view_no_jwt(hass, test_client): assert resp.status == 401, resp.response -@asyncio.coroutine -def test_callback_view_with_jwt(hass, test_client): +async def test_callback_view_with_jwt(hass, test_client): """Test that the notification callback view works with JWT.""" registrations = { 'device': SUBSCRIPTION_1 } - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('pywebpush.WebPusher') as mock_wp: - yield from hass.services.async_call('notify', 'notify', { + await hass.services.async_call('notify', 'notify', { 'message': 'Hello', 'target': ['device'], 'data': {'icon': 'beer.png'} @@ -331,10 +312,10 @@ def test_callback_view_with_jwt(hass, test_client): bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - resp = yield from client.post(PUBLISH_URL, json={ + resp = await client.post(PUBLISH_URL, json={ 'type': 'push', }, headers={AUTHORIZATION: bearer_token}) assert resp.status == 200 - body = yield from resp.json() + body = await resp.json() assert body == {"event": "push", "status": "ok"} From cf3f1c3081405034dcd96cf5d2ae6f070c5bbfa8 Mon Sep 17 00:00:00 2001 From: Eduardo Fonseca Date: Sun, 4 Mar 2018 18:37:54 -0800 Subject: [PATCH 139/191] Fixing small naming bug (#12911) --- homeassistant/components/sensor/serial_pm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/serial_pm.py b/homeassistant/components/sensor/serial_pm.py index a031f9cbd56..d2157066625 100644 --- a/homeassistant/components/sensor/serial_pm.py +++ b/homeassistant/components/sensor/serial_pm.py @@ -48,7 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for pmname in coll.supported_values(): - if config.get(CONF_NAME) is None: + if config.get(CONF_NAME) is not None: name = '{} PM{}'.format(config.get(CONF_NAME), pmname) else: name = 'PM{}'.format(pmname) From 7c7da9df055c5d046a576536144b6a10a045acae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 5 Mar 2018 03:38:21 +0100 Subject: [PATCH 140/191] =?UTF-8?q?Tibber:=20Check=20if=20the=20current=20?= =?UTF-8?q?electricity=20price=20is=20available=20before=20we=E2=80=A6=20(?= =?UTF-8?q?#12905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tibber: Check if the current electricity price is available before we ask for new prices. await syntax * tibber --- homeassistant/components/sensor/tibber.py | 50 +++++++++++++++-------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 5ce614b978d..a5f490c8d51 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -32,21 +32,21 @@ ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=1) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Tibber sensor.""" import tibber tibber_connection = tibber.Tibber(config[CONF_ACCESS_TOKEN], websession=async_get_clientsession(hass)) try: - yield from tibber_connection.update_info() + await tibber_connection.update_info() dev = [] for home in tibber_connection.get_homes(): - yield from home.update_info() + await home.update_info() dev.append(TibberSensor(home)) except (asyncio.TimeoutError, aiohttp.ClientError): - raise PlatformNotReady() from None + raise PlatformNotReady() async_add_devices(dev, True) @@ -59,25 +59,41 @@ class TibberSensor(Entity): self._tibber_home = tibber_home self._last_updated = None self._state = None - self._device_state_attributes = None - self._unit_of_measurement = None - self._name = 'Electricity price {}'.format(self._tibber_home.address1) + self._device_state_attributes = {} + self._unit_of_measurement = self._tibber_home.price_unit + self._name = 'Electricity price {}'.format(tibber_home.address1) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the latest data and updates the states.""" + now = dt_util.utcnow() if self._tibber_home.current_price_total and self._last_updated and \ dt_util.as_utc(dt_util.parse_datetime(self._last_updated)).hour\ - == dt_util.utcnow().hour: + == now.hour: return - yield from self._tibber_home.update_current_price_info() + def _find_current_price(): + for key, price_total in self._tibber_home.price_total.items(): + price_time = dt_util.as_utc(dt_util.parse_datetime(key)) + time_diff = (now - price_time).total_seconds()/60 + if time_diff >= 0 and time_diff < 60: + self._state = round(price_total, 2) + self._last_updated = key + return True + return False - self._state = self._tibber_home.current_price_total - self._last_updated = self._tibber_home.current_price_info.\ - get('startsAt') - self._device_state_attributes = self._tibber_home.current_price_info - self._unit_of_measurement = self._tibber_home.price_unit + if _find_current_price(): + return + + _LOGGER.debug("No cached data found, so asking for new data") + await self._tibber_home.update_info() + await self._tibber_home.update_price_info() + data = self._tibber_home.info['viewer']['home'] + self._device_state_attributes['app_nickname'] = data['appNickname'] + self._device_state_attributes['grid_company'] =\ + data['meteringPointData']['gridCompany'] + self._device_state_attributes['estimated_annual_consumption'] =\ + data['meteringPointData']['estimatedAnnualConsumption'] + _find_current_price() @property def device_state_attributes(self): From 326241d9d896f0e4b8ecafe20220aa0ffd2e0ccf Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Mon, 5 Mar 2018 02:41:27 +0000 Subject: [PATCH 141/191] Add empty unit to systemmonitor load averages (#12900) Adds an empty unit to load averages reported by the systemmonitor component in order to correctly identify the state as numeric. A similar workaroud to this is already used for packet counts in the same component. Re #11022 --- homeassistant/components/sensor/systemmonitor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 3aed9d5a21b..79d5c261b88 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -29,9 +29,9 @@ SENSOR_TYPES = { 'ipv4_address': ['IPv4 address', '', 'mdi:server-network'], 'ipv6_address': ['IPv6 address', '', 'mdi:server-network'], 'last_boot': ['Last boot', '', 'mdi:clock'], - 'load_15m': ['Load (15m)', '', 'mdi:memory'], - 'load_1m': ['Load (1m)', '', 'mdi:memory'], - 'load_5m': ['Load (5m)', '', 'mdi:memory'], + 'load_15m': ['Load (15m)', ' ', 'mdi:memory'], + 'load_1m': ['Load (1m)', ' ', 'mdi:memory'], + 'load_5m': ['Load (5m)', ' ', 'mdi:memory'], 'memory_free': ['Memory free', 'MiB', 'mdi:memory'], 'memory_use': ['Memory use', 'MiB', 'mdi:memory'], 'memory_use_percent': ['Memory use (percent)', '%', 'mdi:memory'], From 259121c7a7bbcb68cd54845c81cd8bc631887f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Mon, 5 Mar 2018 03:46:09 +0100 Subject: [PATCH 142/191] update notify html5 dependencies (#12898) --- homeassistant/components/notify/html5.py | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 4269e577edf..a67c20c7078 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -26,7 +26,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.5.0', 'PyJWT==1.5.3'] +REQUIREMENTS = ['pywebpush==1.6.0', 'PyJWT==1.6.0'] DEPENDENCIES = ['frontend'] diff --git a/requirements_all.txt b/requirements_all.txt index 2fce3ab0c24..7adbe026c82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -30,7 +30,7 @@ HAP-python==1.1.7 PyISY==1.1.0 # homeassistant.components.notify.html5 -PyJWT==1.5.3 +PyJWT==1.6.0 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -1026,7 +1026,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.5.0 +pywebpush==1.6.0 # homeassistant.components.wemo pywemo==0.4.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ab0a5c468d..53f33cd85ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ asynctest>=0.11.1 HAP-python==1.1.7 # homeassistant.components.notify.html5 -PyJWT==1.5.3 +PyJWT==1.6.0 # homeassistant.components.media_player.sonos SoCo==0.14 @@ -150,7 +150,7 @@ pythonwhois==2.4.3 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.5.0 +pywebpush==1.6.0 # homeassistant.components.python_script restrictedpython==4.0b2 From b0e062b2f8192ad43b2fa7c138bed2dcde865b72 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 5 Mar 2018 07:06:00 +0100 Subject: [PATCH 143/191] Xiaomi MiIO Remote: Lazy discover disabled (#12710) * Lazy discovery disabled: The Chuang Mi IR Remote Controller wants to be re-discovered every 5 minutes. As long as polling is disabled the device should be re-discovered in front of every command. * Use a unique data key per domain. * Named argument used and comment added. --- homeassistant/components/remote/xiaomi_miio.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index a44934d0a39..924556a039d 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -26,7 +26,7 @@ REQUIREMENTS = ['python-miio==0.3.7'] _LOGGER = logging.getLogger(__name__) SERVICE_LEARN = 'xiaomi_miio_learn_command' -PLATFORM = 'xiaomi_miio' +DATA_KEY = 'remote.xiaomi_miio' CONF_SLOT = 'slot' CONF_COMMANDS = 'commands' @@ -70,7 +70,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # Create handler _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - device = ChuangmiIr(host, token) + + # The Chuang Mi IR Remote Controller wants to be re-discovered every + # 5 minutes. As long as polling is disabled the device should be + # re-discovered (lazy_discover=False) in front of every command. + device = ChuangmiIr(host, token, lazy_discover=False) # Check that we can communicate with device. try: @@ -79,8 +83,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("Token not accepted by device : %s", ex) return - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} friendly_name = config.get(CONF_NAME, "xiaomi_miio_" + host.replace('.', '_')) @@ -93,7 +97,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): friendly_name, device, slot, timeout, hidden, config.get(CONF_COMMANDS)) - hass.data[PLATFORM][host] = xiaomi_miio_remote + hass.data[DATA_KEY][host] = xiaomi_miio_remote async_add_devices([xiaomi_miio_remote]) @@ -106,7 +110,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = service.data.get(ATTR_ENTITY_ID) entity = None - for remote in hass.data[PLATFORM].values(): + for remote in hass.data[DATA_KEY].values(): if remote.entity_id == entity_id: entity = remote From e7b84432f94ffdd0d1e05550e8f2d506f23ae526 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 5 Mar 2018 08:25:12 +0100 Subject: [PATCH 144/191] Xiaomi MiIO Switch: Allow unavailable devices at startup by model setting (#12626) * Unavailable state introduced if the device isn't reachable. * Redundancy removed. * Pylint fixed. * Missing space added. * Pylint fixed. * Use format instead of concatenation. --- .../components/switch/xiaomi_miio.py | 102 ++++++++++-------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 7defc3d3b2b..ae4329a42a1 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -19,10 +19,18 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Miio Switch' +CONF_MODEL = 'model' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODEL): vol.In( + ['chuangmi.plug.v1', + 'qmi.powerstrip.v1', + 'zimi.powerstrip.v2', + 'chuangmi.plug.m1', + 'chuangmi.plug.v2']), }) REQUIREMENTS = ['python-miio==0.3.7'] @@ -43,48 +51,53 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): host = config.get(CONF_HOST) name = config.get(CONF_NAME) token = config.get(CONF_TOKEN) + model = config.get(CONF_MODEL) _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) devices = [] - try: - plug = Device(host, token) - device_info = plug.info() - _LOGGER.info("%s %s %s initialized", - device_info.model, - device_info.firmware_version, - device_info.hardware_version) - if device_info.model in ['chuangmi.plug.v1']: - from miio import PlugV1 - plug = PlugV1(host, token) + if model is None: + try: + miio_device = Device(host, token) + device_info = miio_device.info() + model = device_info.model + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) + except DeviceException: + raise PlatformNotReady - # The device has two switchable channels (mains and a USB port). - # A switch device per channel will be created. - for channel_usb in [True, False]: - device = ChuangMiPlugV1Switch( - name, plug, device_info, channel_usb) - devices.append(device) + if model in ['chuangmi.plug.v1']: + from miio import PlugV1 + plug = PlugV1(host, token) - elif device_info.model in ['qmi.powerstrip.v1', - 'zimi.powerstrip.v2']: - from miio import PowerStrip - plug = PowerStrip(host, token) - device = XiaomiPowerStripSwitch(name, plug, device_info) + # The device has two switchable channels (mains and a USB port). + # A switch device per channel will be created. + for channel_usb in [True, False]: + device = ChuangMiPlugV1Switch( + name, plug, model, channel_usb) devices.append(device) - elif device_info.model in ['chuangmi.plug.m1', - 'chuangmi.plug.v2']: - from miio import Plug - plug = Plug(host, token) - device = XiaomiPlugGenericSwitch(name, plug, device_info) - devices.append(device) - else: - _LOGGER.error( - 'Unsupported device found! Please create an issue at ' - 'https://github.com/rytilahti/python-miio/issues ' - 'and provide the following data: %s', device_info.model) - except DeviceException: - raise PlatformNotReady + + elif model in ['qmi.powerstrip.v1', + 'zimi.powerstrip.v2']: + from miio import PowerStrip + plug = PowerStrip(host, token) + device = XiaomiPowerStripSwitch(name, plug, model) + devices.append(device) + elif model in ['chuangmi.plug.m1', + 'chuangmi.plug.v2']: + from miio import Plug + plug = Plug(host, token) + device = XiaomiPlugGenericSwitch(name, plug, model) + devices.append(device) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/rytilahti/python-miio/issues ' + 'and provide the following data: %s', model) + return False async_add_devices(devices, update_before_add=True) @@ -92,17 +105,17 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiPlugGenericSwitch(SwitchDevice): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, plug, device_info): + def __init__(self, name, plug, model): """Initialize the plug switch.""" self._name = name self._icon = 'mdi:power-socket' - self._device_info = device_info + self._model = model self._plug = plug self._state = None self._state_attrs = { ATTR_TEMPERATURE: None, - ATTR_MODEL: self._device_info.model, + ATTR_MODEL: self._model, } self._skip_update = False @@ -191,20 +204,21 @@ class XiaomiPlugGenericSwitch(SwitchDevice): }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): """Representation of a Xiaomi Power Strip.""" - def __init__(self, name, plug, device_info): + def __init__(self, name, plug, model): """Initialize the plug switch.""" - XiaomiPlugGenericSwitch.__init__(self, name, plug, device_info) + XiaomiPlugGenericSwitch.__init__(self, name, plug, model) self._state_attrs = { ATTR_TEMPERATURE: None, ATTR_LOAD_POWER: None, - ATTR_MODEL: self._device_info.model, + ATTR_MODEL: self._model, } @asyncio.coroutine @@ -228,17 +242,18 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): """Representation of a Chuang Mi Plug V1.""" - def __init__(self, name, plug, device_info, channel_usb): + def __init__(self, name, plug, model, channel_usb): """Initialize the plug switch.""" - name = name + ' USB' if channel_usb else name + name = '{} USB'.format(name) if channel_usb else name - XiaomiPlugGenericSwitch.__init__(self, name, plug, device_info) + XiaomiPlugGenericSwitch.__init__(self, name, plug, model) self._channel_usb = channel_usb @asyncio.coroutine @@ -293,4 +308,5 @@ class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) From e07fb24987bd311ed4e4cf6caa39761b5244e97f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Mar 2018 13:26:37 -0800 Subject: [PATCH 145/191] Update python-coinbase to 2.1.0 (#12925) --- homeassistant/components/coinbase.py | 4 +--- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py index 515da3e4f54..c40bd99b542 100644 --- a/homeassistant/components/coinbase.py +++ b/homeassistant/components/coinbase.py @@ -14,9 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = [ - 'https://github.com/balloob/coinbase-python/archive/' - '3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1'] +REQUIREMENTS = ['coinbase==2.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7adbe026c82..a19f7b4bce2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -178,6 +178,9 @@ caldav==0.5.0 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 +# homeassistant.components.coinbase +coinbase==2.1.0 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.2.1 @@ -366,9 +369,6 @@ httplib2==0.10.3 # homeassistant.components.media_player.braviatv https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 -# homeassistant.components.coinbase -https://github.com/balloob/coinbase-python/archive/3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1 - # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink https://github.com/balloob/python-broadlink/archive/3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1 From e5c4bba9063bb6c00e30b660ecad91a29a497a00 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Mar 2018 13:28:15 -0800 Subject: [PATCH 146/191] Remove unused cloud APIs (#12913) --- homeassistant/components/cloud/auth_api.py | 33 ------- homeassistant/components/cloud/http_api.py | 56 ----------- tests/components/cloud/test_auth_api.py | 39 -------- tests/components/cloud/test_http_api.py | 105 --------------------- 4 files changed, 233 deletions(-) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 118a9857158..dcf7567482a 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -17,14 +17,6 @@ class UserNotConfirmed(CloudError): """Raised when a user has not confirmed email yet.""" -class ExpiredCode(CloudError): - """Raised when an expired code is encountered.""" - - -class InvalidCode(CloudError): - """Raised when an invalid code is submitted.""" - - class PasswordChangeRequired(CloudError): """Raised when a password change is required.""" @@ -42,10 +34,8 @@ class UnknownError(CloudError): AWS_EXCEPTIONS = { 'UserNotFoundException': UserNotFound, 'NotAuthorizedException': Unauthenticated, - 'ExpiredCodeException': ExpiredCode, 'UserNotConfirmedException': UserNotConfirmed, 'PasswordResetRequiredException': PasswordChangeRequired, - 'CodeMismatchException': InvalidCode, } @@ -69,17 +59,6 @@ def register(cloud, email, password): raise _map_aws_exception(err) -def confirm_register(cloud, confirmation_code, email): - """Confirm confirmation code after registration.""" - from botocore.exceptions import ClientError - - cognito = _cognito(cloud) - try: - cognito.confirm_sign_up(confirmation_code, email) - except ClientError as err: - raise _map_aws_exception(err) - - def resend_email_confirm(cloud, email): """Resend email confirmation.""" from botocore.exceptions import ClientError @@ -107,18 +86,6 @@ def forgot_password(cloud, email): raise _map_aws_exception(err) -def confirm_forgot_password(cloud, confirmation_code, email, new_password): - """Confirm forgotten password code and change password.""" - from botocore.exceptions import ClientError - - cognito = _cognito(cloud, username=email) - - try: - cognito.confirm_forgot_password(confirmation_code, new_password) - except ClientError as err: - raise _map_aws_exception(err) - - def login(cloud, email, password): """Log user in and fetch certificate.""" cognito = _authenticate(cloud, email, password) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index f7f327f2f2c..3065de24180 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -23,10 +23,8 @@ def async_setup(hass): hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) hass.http.register_view(CloudRegisterView) - hass.http.register_view(CloudConfirmRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) - hass.http.register_view(CloudConfirmForgotPasswordView) _CLOUD_ERRORS = { @@ -34,8 +32,6 @@ _CLOUD_ERRORS = { auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), auth_api.Unauthenticated: (401, 'Authentication failed.'), auth_api.PasswordChangeRequired: (400, 'Password change required.'), - auth_api.ExpiredCode: (400, 'Confirmation code has expired.'), - auth_api.InvalidCode: (400, 'Invalid confirmation code.'), asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') } @@ -149,31 +145,6 @@ class CloudRegisterView(HomeAssistantView): return self.json_message('ok') -class CloudConfirmRegisterView(HomeAssistantView): - """Confirm registration on the Home Assistant cloud.""" - - url = '/api/cloud/confirm_register' - name = 'api:cloud:confirm_register' - - @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('confirmation_code'): str, - vol.Required('email'): str, - })) - @asyncio.coroutine - def post(self, request, data): - """Handle registration confirmation request.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( - auth_api.confirm_register, cloud, data['confirmation_code'], - data['email']) - - return self.json_message('ok') - - class CloudResendConfirmView(HomeAssistantView): """Resend email confirmation code.""" @@ -220,33 +191,6 @@ class CloudForgotPasswordView(HomeAssistantView): return self.json_message('ok') -class CloudConfirmForgotPasswordView(HomeAssistantView): - """View to finish Forgot Password flow..""" - - url = '/api/cloud/confirm_forgot_password' - name = 'api:cloud:confirm_forgot_password' - - @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('confirmation_code'): str, - vol.Required('email'): str, - vol.Required('new_password'): vol.All(str, vol.Length(min=6)) - })) - @asyncio.coroutine - def post(self, request, data): - """Handle forgot password confirm request.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( - auth_api.confirm_forgot_password, cloud, - data['confirmation_code'], data['email'], - data['new_password']) - - return self.json_message('ok') - - def _account_data(cloud): """Generate the auth data JSON response.""" claims = cloud.claims diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index 70cd5d83f41..a50a4d796aa 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -94,24 +94,6 @@ def test_register_fails(mock_cognito): auth_api.register(cloud, 'email@home-assistant.io', 'password') -def test_confirm_register(mock_cognito): - """Test confirming a registration of an account.""" - cloud = MagicMock() - auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') - assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 - result_code, result_user = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_user == 'email@home-assistant.io' - assert result_code == '123456' - - -def test_confirm_register_fails(mock_cognito): - """Test an error during confirmation of an account.""" - cloud = MagicMock() - mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') - - def test_resend_email_confirm(mock_cognito): """Test starting forgot password flow.""" cloud = MagicMock() @@ -143,27 +125,6 @@ def test_forgot_password_fails(mock_cognito): auth_api.forgot_password(cloud, 'email@home-assistant.io') -def test_confirm_forgot_password(mock_cognito): - """Test confirming forgot password.""" - cloud = MagicMock() - auth_api.confirm_forgot_password( - cloud, '123456', 'email@home-assistant.io', 'new password') - assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 - result_code, result_password = \ - mock_cognito.confirm_forgot_password.mock_calls[0][1] - assert result_code == '123456' - assert result_password == 'new password' - - -def test_confirm_forgot_password_fails(mock_cognito): - """Test failure when confirming forgot password.""" - cloud = MagicMock() - mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.confirm_forgot_password( - cloud, '123456', 'email@home-assistant.io', 'new password') - - def test_check_token_writes_new_token_on_refresh(mock_cognito): """Test check_token writes new token if refreshed.""" cloud = MagicMock() diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 69cd540e7d5..98ddebb5db3 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -231,53 +231,6 @@ def test_register_view_unknown_error(mock_cognito, cloud_client): assert req.status == 502 -@asyncio.coroutine -def test_confirm_register_view(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456' - }) - assert req.status == 200 - assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 - result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_email == 'hello@bla.com' - assert result_code == '123456' - - -@asyncio.coroutine -def test_confirm_register_view_bad_data(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'not_confirmation_code': '123456' - }) - assert req.status == 400 - assert len(mock_cognito.confirm_sign_up.mock_calls) == 0 - - -@asyncio.coroutine -def test_confirm_register_view_request_timeout(mock_cognito, cloud_client): - """Test timeout while logging out.""" - mock_cognito.confirm_sign_up.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456' - }) - assert req.status == 502 - - -@asyncio.coroutine -def test_confirm_register_view_unknown_error(mock_cognito, cloud_client): - """Test unknown error while logging out.""" - mock_cognito.confirm_sign_up.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456' - }) - assert req.status == 502 - - @asyncio.coroutine def test_forgot_password_view(mock_cognito, cloud_client): """Test logging out.""" @@ -358,61 +311,3 @@ def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): 'email': 'hello@bla.com', }) assert req.status == 502 - - -@asyncio.coroutine -def test_confirm_forgot_password_view(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 200 - assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 - result_code, result_new_password = \ - mock_cognito.confirm_forgot_password.mock_calls[0][1] - assert result_code == '123456' - assert result_new_password == 'hello2' - - -@asyncio.coroutine -def test_confirm_forgot_password_view_bad_data(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'not_confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 400 - assert len(mock_cognito.confirm_forgot_password.mock_calls) == 0 - - -@asyncio.coroutine -def test_confirm_forgot_password_view_request_timeout(mock_cognito, - cloud_client): - """Test timeout while logging out.""" - mock_cognito.confirm_forgot_password.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 502 - - -@asyncio.coroutine -def test_confirm_forgot_password_view_unknown_error(mock_cognito, - cloud_client): - """Test unknown error while logging out.""" - mock_cognito.confirm_forgot_password.side_effect = auth_api.UnknownError - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 502 From 6a5c7ef43f0b242fcae408413e1881e26c5249b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Mar 2018 13:28:41 -0800 Subject: [PATCH 147/191] Upgrade to aiohttp 3 (#12921) * Upgrade aiohttp to 3.0.6 * Fix tests * Fix aiohttp client stream test * Lint * Remove drain --- homeassistant/components/api.py | 3 +- homeassistant/components/camera/__init__.py | 9 ++-- homeassistant/components/http/__init__.py | 14 +++--- homeassistant/components/http/static.py | 1 + homeassistant/components/shopping_list.py | 5 +- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/package_constraints.txt | 4 +- requirements_all.txt | 4 +- setup.py | 4 +- tests/components/test_websocket_api.py | 29 ++++++----- tests/helpers/test_aiohttp_client.py | 55 ++++++++++++--------- 11 files changed, 65 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index f25b0cc130c..d272ebcb1c0 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -131,8 +131,7 @@ class APIEventStream(HomeAssistantView): msg = "data: {}\n\n".format(payload) _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), msg.strip()) - response.write(msg.encode("UTF-8")) - yield from response.drain() + yield from response.write(msg.encode("UTF-8")) except asyncio.TimeoutError: yield from to_write.put(STREAM_PING_PAYLOAD) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index a531d25841b..5321ec3d860 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -264,9 +264,9 @@ class Camera(Entity): 'boundary=--frameboundary') yield from response.prepare(request) - def write(img_bytes): + async def write(img_bytes): """Write image to stream.""" - response.write(bytes( + await response.write(bytes( '--frameboundary\r\n' 'Content-Type: {}\r\n' 'Content-Length: {}\r\n\r\n'.format( @@ -282,15 +282,14 @@ class Camera(Entity): break if img_bytes and img_bytes != last_image: - write(img_bytes) + yield from write(img_bytes) # Chrome seems to always ignore first picture, # print it twice. if last_image is None: - write(img_bytes) + yield from write(img_bytes) last_image = img_bytes - yield from response.drain() yield from asyncio.sleep(.5) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 450d802e408..1d4306565b1 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -279,6 +279,10 @@ class HomeAssistantHTTP(object): @asyncio.coroutine def start(self): """Start the WSGI server.""" + # We misunderstood the startup signal. You're not allowed to change + # anything during startup. Temp workaround. + # pylint: disable=protected-access + self.app._on_startup.freeze() yield from self.app.startup() if self.ssl_certificate: @@ -298,10 +302,8 @@ class HomeAssistantHTTP(object): # Aiohttp freezes apps after start so that no changes can be made. # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. - # To work around this we now fake that we are frozen. - # A more appropriate fix would be to create a new app and - # re-register all redirects, views, static paths. - self.app._frozen = True # pylint: disable=protected-access + # To work around this we now prevent the router from getting frozen + self.app._router.freeze = lambda: None self._handler = self.app.make_handler(loop=self.hass.loop) @@ -312,10 +314,6 @@ class HomeAssistantHTTP(object): _LOGGER.error("Failed to create HTTP server at port %d: %s", self.server_port, error) - # pylint: disable=protected-access - self.app._middlewares = tuple(self.app._prepare_middleware()) - self.app._frozen = False - @asyncio.coroutine def stop(self): """Stop the WSGI server.""" diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index b34df1897f0..f444e4b3180 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -39,6 +39,7 @@ class CachingStaticResource(StaticResource): raise HTTPNotFound +# pylint: disable=too-many-ancestors class CachingFileResponse(FileResponse): """FileSender class that caches output if not in dev mode.""" diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index da3e2e7147d..0ca0fef6e06 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -173,10 +173,9 @@ class UpdateShoppingListItemView(http.HomeAssistantView): url = '/api/shopping_list/item/{item_id}' name = "api:shopping_list:item:id" - @callback - def post(self, request, item_id): + async def post(self, request, item_id): """Update a shopping list item.""" - data = yield from request.json() + data = await request.json() try: item = request.app['hass'].data[DOMAIN].async_update(item_id, data) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index ecce51a57b8..72f2214b5e7 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -116,7 +116,7 @@ async def async_aiohttp_proxy_stream(hass, request, stream, content_type, await response.write_eof() break - response.write(data) + await response.write(data) except (asyncio.TimeoutError, aiohttp.ClientError): # Something went wrong fetching data, close connection gracefully diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b6a5f09330..16b8815e5cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,10 +5,8 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==2.3.10 -yarl==1.1.0 +aiohttp==3.0.6 async_timeout==2.0.0 -chardet==3.0.4 astral==1.5 certifi>=2017.4.17 attrs==17.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index a19f7b4bce2..e8bf58a61a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,10 +6,8 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==2.3.10 -yarl==1.1.0 +aiohttp==3.0.6 async_timeout==2.0.0 -chardet==3.0.4 astral==1.5 certifi>=2017.4.17 attrs==17.4.0 diff --git a/setup.py b/setup.py index c3cf07223bc..024b2df3b38 100755 --- a/setup.py +++ b/setup.py @@ -50,10 +50,8 @@ REQUIRES = [ 'jinja2>=2.10', 'voluptuous==0.11.1', 'typing>=3,<4', - 'aiohttp==2.3.10', # If updated, check if yarl also needs an update! - 'yarl==1.1.0', + 'aiohttp==3.0.6', 'async_timeout==2.0.0', - 'chardet==3.0.4', 'astral==1.5', 'certifi>=2017.4.17', 'attrs==17.4.0', diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index f85030a6892..d0c129e512e 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -23,7 +23,6 @@ def websocket_client(loop, hass, test_client): client = loop.run_until_complete(test_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) - auth_ok = loop.run_until_complete(ws.receive_json()) assert auth_ok['type'] == wapi.TYPE_AUTH_OK @@ -65,7 +64,7 @@ def mock_low_queue(): @asyncio.coroutine def test_auth_via_msg(no_auth_websocket_client): """Test authenticating.""" - no_auth_websocket_client.send_json({ + yield from no_auth_websocket_client.send_json({ 'type': wapi.TYPE_AUTH, 'api_password': API_PASSWORD }) @@ -80,7 +79,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): """Test authenticating.""" with patch('homeassistant.components.websocket_api.process_wrong_login', return_value=mock_coro()) as mock_process_wrong_login: - no_auth_websocket_client.send_json({ + yield from no_auth_websocket_client.send_json({ 'type': wapi.TYPE_AUTH, 'api_password': API_PASSWORD + 'wrong' }) @@ -95,7 +94,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): @asyncio.coroutine def test_pre_auth_only_auth_allowed(no_auth_websocket_client): """Verify that before authentication, only auth messages are allowed.""" - no_auth_websocket_client.send_json({ + yield from no_auth_websocket_client.send_json({ 'type': wapi.TYPE_CALL_SERVICE, 'domain': 'domain_test', 'service': 'test_service', @@ -113,7 +112,7 @@ def test_pre_auth_only_auth_allowed(no_auth_websocket_client): @asyncio.coroutine def test_invalid_message_format(websocket_client): """Test sending invalid JSON.""" - websocket_client.send_json({'type': 5}) + yield from websocket_client.send_json({'type': 5}) msg = yield from websocket_client.receive_json() @@ -126,7 +125,7 @@ def test_invalid_message_format(websocket_client): @asyncio.coroutine def test_invalid_json(websocket_client): """Test sending invalid JSON.""" - websocket_client.send_str('this is not JSON') + yield from websocket_client.send_str('this is not JSON') msg = yield from websocket_client.receive() @@ -155,7 +154,7 @@ def test_call_service(hass, websocket_client): hass.services.async_register('domain_test', 'test_service', service_call) - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_CALL_SERVICE, 'domain': 'domain_test', @@ -183,7 +182,7 @@ def test_subscribe_unsubscribe_events(hass, websocket_client): """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_SUBSCRIBE_EVENTS, 'event_type': 'test_event' @@ -212,7 +211,7 @@ def test_subscribe_unsubscribe_events(hass, websocket_client): assert event['data'] == {'hello': 'world'} assert event['origin'] == 'LOCAL' - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 6, 'type': wapi.TYPE_UNSUBSCRIBE_EVENTS, 'subscription': 5 @@ -233,7 +232,7 @@ def test_get_states(hass, websocket_client): hass.states.async_set('greeting.hello', 'world') hass.states.async_set('greeting.bye', 'universe') - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_STATES, }) @@ -256,7 +255,7 @@ def test_get_states(hass, websocket_client): @asyncio.coroutine def test_get_services(hass, websocket_client): """Test get_services command.""" - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_SERVICES, }) @@ -271,7 +270,7 @@ def test_get_services(hass, websocket_client): @asyncio.coroutine def test_get_config(hass, websocket_client): """Test get_config command.""" - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_CONFIG, }) @@ -296,7 +295,7 @@ def test_get_panels(hass, websocket_client): yield from hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:account-location') hass.data[frontend.DATA_JS_VERSION] = 'es5' - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_PANELS, }) @@ -318,7 +317,7 @@ def test_get_panels(hass, websocket_client): @asyncio.coroutine def test_ping(websocket_client): """Test get_panels command.""" - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_PING, }) @@ -332,7 +331,7 @@ def test_ping(websocket_client): def test_pending_msg_overflow(hass, mock_low_queue, websocket_client): """Test get_panels command.""" for idx in range(10): - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': idx + 1, 'type': wapi.TYPE_PING, }) diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 7aa7f6fa4d1..f5415ffe212 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -3,6 +3,7 @@ import asyncio import unittest import aiohttp +import pytest from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component @@ -12,6 +13,19 @@ from homeassistant.util.async import run_callback_threadsafe from tests.common import get_test_home_assistant +@pytest.fixture +def camera_client(hass, test_client): + """Fixture to fetch camera streams.""" + assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'mjpeg', + 'mjpeg_url': 'http://example.com/mjpeg_stream', + }})) + + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + class TestHelpersAiohttpClient(unittest.TestCase): """Test homeassistant.helpers.aiohttp_client module.""" @@ -119,41 +133,38 @@ class TestHelpersAiohttpClient(unittest.TestCase): @asyncio.coroutine -def test_async_aiohttp_proxy_stream(aioclient_mock, hass, test_client): +def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com/mjpeg_stream', content=[ b'Frame1', b'Frame2', b'Frame3' ]) - result = yield from async_setup_component(hass, 'camera', { - 'camera': { - 'name': 'config_test', - 'platform': 'mjpeg', - 'mjpeg_url': 'http://example.com/mjpeg_stream', - }}) - assert result, 'Failed to setup camera' - - client = yield from test_client(hass.http.app) - - resp = yield from client.get('/api/camera_proxy_stream/camera.config_test') + resp = yield from camera_client.get( + '/api/camera_proxy_stream/camera.config_test') assert resp.status == 200 assert aioclient_mock.call_count == 1 body = yield from resp.text() assert body == 'Frame3Frame2Frame1' - aioclient_mock.clear_requests() - aioclient_mock.get( - 'http://example.com/mjpeg_stream', exc=asyncio.TimeoutError(), - content=[b'Frame1', b'Frame2', b'Frame3']) - resp = yield from client.get('/api/camera_proxy_stream/camera.config_test') +@asyncio.coroutine +def test_async_aiohttp_proxy_stream_timeout(aioclient_mock, camera_client): + """Test that it fetches the given url.""" + aioclient_mock.get( + 'http://example.com/mjpeg_stream', exc=asyncio.TimeoutError()) + + resp = yield from camera_client.get( + '/api/camera_proxy_stream/camera.config_test') assert resp.status == 504 - aioclient_mock.clear_requests() - aioclient_mock.get( - 'http://example.com/mjpeg_stream', exc=aiohttp.ClientError(), - content=[b'Frame1', b'Frame2', b'Frame3']) - resp = yield from client.get('/api/camera_proxy_stream/camera.config_test') +@asyncio.coroutine +def test_async_aiohttp_proxy_stream_client_err(aioclient_mock, camera_client): + """Test that it fetches the given url.""" + aioclient_mock.get( + 'http://example.com/mjpeg_stream', exc=aiohttp.ClientError()) + + resp = yield from camera_client.get( + '/api/camera_proxy_stream/camera.config_test') assert resp.status == 502 From 60d7e32f811478c8f022db3453c91c53a7765e01 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Mar 2018 14:13:18 -0800 Subject: [PATCH 148/191] Flaky tests (#12931) * Skip flaky DDWRT tests * Import APNS before running tests --- .coveragerc | 1 + tests/components/device_tracker/test_ddwrt.py | 3 +++ tests/components/notify/test_apns.py | 3 +-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.coveragerc b/.coveragerc index 156f546fefb..83d143f83cb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -367,6 +367,7 @@ omit = homeassistant/components/device_tracker/bluetooth_tracker.py homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/cisco_ios.py + homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/hitron_coda.py diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py index 57aeba5b9a5..416b7be4a8a 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -7,6 +7,8 @@ import re import requests import requests_mock +import pytest + from homeassistant import config from homeassistant.setup import setup_component from homeassistant.components import device_tracker @@ -25,6 +27,7 @@ TEST_HOST = '127.0.0.1' _LOGGER = logging.getLogger(__name__) +@pytest.mark.skip class TestDdwrt(unittest.TestCase): """Tests for the Ddwrt device tracker platform.""" diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 7715ff168be..0bd0333a6fb 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -3,6 +3,7 @@ import io import unittest from unittest.mock import Mock, patch, mock_open +from apns2.errors import Unregistered import yaml import homeassistant.components.notify as notify @@ -358,8 +359,6 @@ class TestApns(unittest.TestCase): @patch('homeassistant.components.notify.apns._write_device') def test_disable_when_unregistered(self, mock_write, mock_client): """Test disabling a device when it is unregistered.""" - from apns2.errors import Unregistered - send = mock_client.return_value.send_notification send.side_effect = Unregistered() From 18b288dcfef39aefd89146a48ac43c3acf761e2e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 5 Mar 2018 15:15:54 -0700 Subject: [PATCH 149/191] Addresses issues with Pollen.com API troubles (#12930) * Addresses issues with Pollen.com API troubles (#12916) * Reverted some unnecessary style changes --- homeassistant/components/sensor/pollen.py | 84 ++++++++++++++--------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 98252eb6f06..d299a74b450 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -105,7 +105,6 @@ RATING_MAPPING = [{ 'maximum': 12 }] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ZIP_CODE): cv.string, vol.Required(CONF_MONITORED_CONDITIONS): @@ -211,21 +210,23 @@ class AllergyAverageSensor(BaseSensor): try: data_attr = getattr(self.data, self._data_params['data_attr']) - indices = [ - p['Index'] - for p in data_attr['Location']['periods'] - ] + indices = [p['Index'] for p in data_attr['Location']['periods']] + self._attrs[ATTR_TREND] = calculate_trend(indices) except KeyError: _LOGGER.error("Pollen.com API didn't return any data") return + try: + self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() + self._attrs[ATTR_STATE] = data_attr['Location']['State'] + self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] + except KeyError: + _LOGGER.debug('Location data not included in API response') + self._attrs[ATTR_CITY] = None + self._attrs[ATTR_STATE] = None + self._attrs[ATTR_ZIP_CODE] = None + average = round(mean(indices), 1) - - self._attrs[ATTR_TREND] = calculate_trend(indices) - self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() - self._attrs[ATTR_STATE] = data_attr['Location']['State'] - self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] - [rating] = [ i['label'] for i in RATING_MAPPING if i['minimum'] <= average <= i['maximum'] @@ -245,31 +246,51 @@ class AllergyIndexSensor(BaseSensor): try: location_data = self.data.current_data['Location'] + [period] = [ + p for p in location_data['periods'] + if p['Type'] == self._data_params['key'] + ] + [rating] = [ + i['label'] for i in RATING_MAPPING + if i['minimum'] <= period['Index'] <= i['maximum'] + ] + self._attrs[ATTR_ALLERGEN_GENUS] = period['Triggers'][0]['Genus'] + self._attrs[ATTR_ALLERGEN_NAME] = period['Triggers'][0]['Name'] + self._attrs[ATTR_ALLERGEN_TYPE] = period['Triggers'][0][ + 'PlantType'] + self._attrs[ATTR_RATING] = rating + except KeyError: _LOGGER.error("Pollen.com API didn't return any data") return - [period] = [ - p for p in location_data['periods'] - if p['Type'] == self._data_params['key'] - ] + try: + self._attrs[ATTR_CITY] = location_data['City'].title() + self._attrs[ATTR_STATE] = location_data['State'] + self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] + except KeyError: + _LOGGER.debug('Location data not included in API response') + self._attrs[ATTR_CITY] = None + self._attrs[ATTR_STATE] = None + self._attrs[ATTR_ZIP_CODE] = None - self._attrs[ATTR_ALLERGEN_GENUS] = period['Triggers'][0]['Genus'] - self._attrs[ATTR_ALLERGEN_NAME] = period['Triggers'][0]['Name'] - self._attrs[ATTR_ALLERGEN_TYPE] = period['Triggers'][0]['PlantType'] - self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] - self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] - self._attrs[ATTR_TREND] = self.data.outlook_data[ - 'Trend'].title() - self._attrs[ATTR_CITY] = location_data['City'].title() - self._attrs[ATTR_STATE] = location_data['State'] - self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] + try: + self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] + except KeyError: + _LOGGER.debug('Outlook data not included in API response') + self._attrs[ATTR_OUTLOOK] = None - [rating] = [ - i['label'] for i in RATING_MAPPING - if i['minimum'] <= period['Index'] <= i['maximum'] - ] - self._attrs[ATTR_RATING] = rating + try: + self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] + except KeyError: + _LOGGER.debug('Season data not included in API response') + self._attrs[ATTR_SEASON] = None + + try: + self._attrs[ATTR_TREND] = self.data.outlook_data['Trend'].title() + except KeyError: + _LOGGER.debug('Trend data not included in API response') + self._attrs[ATTR_TREND] = None self._state = period['Index'] self._unit = 'index' @@ -289,8 +310,7 @@ class DataBase(object): data = {} try: data = getattr(getattr(self._client, module), operation)() - _LOGGER.debug('Received "%s_%s" data: %s', module, - operation, data) + _LOGGER.debug('Received "%s_%s" data: %s', module, operation, data) except HTTPError as exc: _LOGGER.error('An error occurred while retrieving data') _LOGGER.debug(exc) From 05204a982ebb27a5dcbc95bbdb8b4afab759f316 Mon Sep 17 00:00:00 2001 From: maxclaey Date: Mon, 5 Mar 2018 23:57:52 +0100 Subject: [PATCH 150/191] Set supported features based on capabilities of device (#12922) Catch nest APIErrors when setting temperature --- homeassistant/components/climate/nest.py | 28 +++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 0427514a7b5..e5c21158acb 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -29,10 +29,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ NEST_MODE_HEAT_COOL = 'heat-cool' -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE | SUPPORT_FAN_MODE) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Nest thermostat.""" @@ -58,6 +54,10 @@ class NestThermostat(ClimateDevice): self.device = device self._fan_list = [STATE_ON, STATE_AUTO] + # Set the default supported features + self._support_flags = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE) + # Not all nest devices support cooling and heating remove unused self._operation_list = [STATE_OFF] @@ -70,11 +70,16 @@ class NestThermostat(ClimateDevice): if self.device.can_heat and self.device.can_cool: self._operation_list.append(STATE_AUTO) + self._support_flags = (self._support_flags | + SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW) self._operation_list.append(STATE_ECO) # feature of device self._has_fan = self.device.has_fan + if self._has_fan: + self._support_flags = (self._support_flags | SUPPORT_FAN_MODE) # data attributes self._away = None @@ -95,7 +100,7 @@ class NestThermostat(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._support_flags @property def unique_id(self): @@ -162,6 +167,7 @@ class NestThermostat(ClimateDevice): def set_temperature(self, **kwargs): """Set new target temperature.""" + import nest target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if self._mode == NEST_MODE_HEAT_COOL: @@ -170,7 +176,10 @@ class NestThermostat(ClimateDevice): else: temp = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - self.device.target = temp + try: + self.device.target = temp + except nest.nest.APIError: + _LOGGER.error("An error occured while setting the temperature") def set_operation_mode(self, operation_mode): """Set operation mode.""" @@ -205,11 +214,14 @@ class NestThermostat(ClimateDevice): @property def fan_list(self): """List of available fan modes.""" - return self._fan_list + if self._has_fan: + return self._fan_list + return None def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.device.fan = fan_mode.lower() + if self._has_fan: + self.device.fan = fan_mode.lower() @property def min_temp(self): From 13cb9cb07bc6d51698408bdcd05934b9ce81d205 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Tue, 6 Mar 2018 00:10:45 +0100 Subject: [PATCH 151/191] Bumped (minor) version of xknx within knx-component. This fixes a bug with inverted percentage within sensors. (#12929) --- homeassistant/components/knx.py | 2 +- homeassistant/components/light/knx.py | 16 +++++++++------- requirements_all.txt | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index d7630fee7a5..f6f41619ca8 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.8.3'] +REQUIREMENTS = ['xknx==0.8.4'] DOMAIN = "knx" DATA_KNX = "data_knx" diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 1b14ff75ecc..83083e34bad 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -109,8 +109,8 @@ class KNXLight(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self.device.brightness \ - if self.device.supports_dimming else \ + return self.device.current_brightness \ + if self.device.supports_brightness else \ None @property @@ -122,7 +122,7 @@ class KNXLight(Light): def rgb_color(self): """Return the RBG color value.""" if self.device.supports_color: - return self.device.current_color() + return self.device.current_color return None @property @@ -154,7 +154,7 @@ class KNXLight(Light): def supported_features(self): """Flag supported features.""" flags = 0 - if self.device.supports_dimming: + if self.device.supports_brightness: flags |= SUPPORT_BRIGHTNESS if self.device.supports_color: flags |= SUPPORT_RGB_COLOR @@ -162,10 +162,12 @@ class KNXLight(Light): async def async_turn_on(self, **kwargs): """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: - await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + if ATTR_BRIGHTNESS in kwargs: + if self.device.supports_brightness: + await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) elif ATTR_RGB_COLOR in kwargs: - await self.device.set_color(kwargs[ATTR_RGB_COLOR]) + if self.device.supports_color: + await self.device.set_color(kwargs[ATTR_RGB_COLOR]) else: await self.device.set_on() diff --git a/requirements_all.txt b/requirements_all.txt index e8bf58a61a9..09ada578e5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1268,7 +1268,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.8.3 +xknx==0.8.4 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca From 3682080da255af958a5dc7382778e3c2b3befe09 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Mar 2018 15:19:40 -0800 Subject: [PATCH 152/191] Bump frontend to 20180305.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9fbf936cccc..6ba2bc73442 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180228.1'] +REQUIREMENTS = ['home-assistant-frontend==20180305.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 09ada578e5e..8b7e6c8c16d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180228.1 +home-assistant-frontend==20180305.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53f33cd85ac..3c347ccee88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180228.1 +home-assistant-frontend==20180305.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 03225cf20f26354edbe557f225296a7402b6dfff Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Mon, 5 Mar 2018 16:30:28 -0700 Subject: [PATCH 153/191] Added checks for empty replies from REST calls and supporting tests (#12904) --- homeassistant/components/sensor/rest.py | 26 +++++++++++++------------ tests/components/sensor/test_rest.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index c295dcf16dc..74bfaa38f02 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -130,18 +130,20 @@ class RestSensor(Entity): if self._json_attrs: self._attributes = {} - try: - json_dict = json.loads(value) - if isinstance(json_dict, dict): - attrs = {k: json_dict[k] for k in self._json_attrs - if k in json_dict} - self._attributes = attrs - else: - _LOGGER.warning("JSON result was not a dictionary") - except ValueError: - _LOGGER.warning("REST result could not be parsed as JSON") - _LOGGER.debug("Erroneous JSON: %s", value) - + if value: + try: + json_dict = json.loads(value) + if isinstance(json_dict, dict): + attrs = {k: json_dict[k] for k in self._json_attrs + if k in json_dict} + self._attributes = attrs + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning("REST result could not be parsed as JSON") + _LOGGER.debug("Erroneous JSON: %s", value) + else: + _LOGGER.warning("Empty reply found when expecting JSON data") if value is None: value = STATE_UNKNOWN elif self._value_template is not None: diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index eddab8caf4d..f2362867979 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -207,6 +207,18 @@ class TestRestSensor(unittest.TestCase): self.assertEqual('some_json_value', self.sensor.device_state_attributes['key']) + @patch('homeassistant.components.sensor.rest._LOGGER') + def test_update_with_json_attrs_no_data(self, mock_logger): + """Test attributes when no JSON result fetched.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect(None)) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key'], + self.force_update) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + @patch('homeassistant.components.sensor.rest._LOGGER') def test_update_with_json_attrs_not_dict(self, mock_logger): """Test attributes get extracted from a JSON result.""" From 38af04c6ce137e544342002931153d1ada5abbb8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Mar 2018 15:51:37 -0800 Subject: [PATCH 154/191] Reinstate our old virtual env check in favor of pip (#12932) --- homeassistant/requirements.py | 2 +- homeassistant/util/package.py | 10 ++++++++-- tests/test_requirements.py | 4 ++-- tests/util/test_package.py | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index df5a098f901..753947a2c12 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -39,6 +39,6 @@ def pip_kwargs(config_dir): kwargs = { 'constraints': os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE) } - if not pkg_util.running_under_virtualenv(): + if not pkg_util.is_virtual_env(): kwargs['target'] = os.path.join(config_dir, 'deps') return kwargs diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 75e12f41a90..a2f707c54f5 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -7,7 +7,6 @@ import sys import threading from urllib.parse import urlparse -from pip.locations import running_under_virtualenv from typing import Optional import pkg_resources @@ -17,6 +16,13 @@ _LOGGER = logging.getLogger(__name__) INSTALL_LOCK = threading.Lock() +def is_virtual_env(): + """Return if we run in a virtual environtment.""" + # Check supports venv && virtualenv + return (getattr(sys, 'base_prefix', sys.prefix) != sys.prefix or + hasattr(sys, 'real_prefix')) + + def install_package(package: str, upgrade: bool = True, target: Optional[str] = None, constraints: Optional[str] = None) -> bool: @@ -37,7 +43,7 @@ def install_package(package: str, upgrade: bool = True, if constraints is not None: args += ['--constraint', constraints] if target: - assert not running_under_virtualenv() + assert not is_virtual_env() # This only works if not running in venv args += ['--user'] env['PYTHONUSERBASE'] = os.path.abspath(target) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 946e64af847..5f09e0bd83e 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -24,7 +24,7 @@ class TestRequirements: self.hass.stop() @mock.patch('os.path.dirname') - @mock.patch('homeassistant.util.package.running_under_virtualenv', + @mock.patch('homeassistant.util.package.is_virtual_env', return_value=True) @mock.patch('homeassistant.util.package.install_package', return_value=True) @@ -43,7 +43,7 @@ class TestRequirements: constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) @mock.patch('os.path.dirname') - @mock.patch('homeassistant.util.package.running_under_virtualenv', + @mock.patch('homeassistant.util.package.is_virtual_env', return_value=False) @mock.patch('homeassistant.util.package.install_package', return_value=True) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index ade374dad33..33db052f45a 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -68,8 +68,8 @@ def mock_env_copy(): @pytest.fixture def mock_venv(): - """Mock homeassistant.util.package.running_under_virtualenv.""" - with patch('homeassistant.util.package.running_under_virtualenv') as mock: + """Mock homeassistant.util.package.is_virtual_env.""" + with patch('homeassistant.util.package.is_virtual_env') as mock: mock.return_value = True yield mock From 5063464d5eafad2cf946281cff73d6550614c7b7 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 6 Mar 2018 03:44:04 +0000 Subject: [PATCH 155/191] Support for queries with no results (fix for #12856) (#12888) * Addresses issue #12856 * error -> warning * added edge case and test * uff uff * Added SELECT validation * Improved tests --- homeassistant/components/sensor/sql.py | 26 ++++++++++++++++++-------- tests/components/sensor/test_sql.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 395c082f9d2..4edb13e0416 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -24,9 +24,17 @@ CONF_QUERIES = 'queries' CONF_QUERY = 'query' CONF_COLUMN_NAME = 'column' + +def validate_sql_select(value): + """Validate that value is a SQL SELECT query.""" + if not value.lstrip().lower().startswith('select'): + raise vol.Invalid('Only SELECT queries allowed') + return value + + _QUERY_SCHEME = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_QUERY): cv.string, + vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), vol.Required(CONF_COLUMN_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -129,15 +137,17 @@ class SQLSensor(Entity): finally: sess.close() - for res in result: - _LOGGER.debug(res.items()) - data = res[self._column_name] - self._attributes = {k: str(v) for k, v in res.items()} - - if data is None: - _LOGGER.error("%s returned no results", self._query) + if not result.returns_rows or result.rowcount == 0: + _LOGGER.warning("%s returned no results", self._query) + self._state = None + self._attributes = {} return + for res in result: + _LOGGER.debug("result = %s", res.items()) + data = res[self._column_name] + self._attributes = {k: v for k, v in res.items()} + if self._template is not None: self._state = self._template.async_render_with_possible_json_value( data, None) diff --git a/tests/components/sensor/test_sql.py b/tests/components/sensor/test_sql.py index ebf2d749e67..5e639b9f338 100644 --- a/tests/components/sensor/test_sql.py +++ b/tests/components/sensor/test_sql.py @@ -1,7 +1,11 @@ """The test for the sql sensor platform.""" import unittest +import pytest +import voluptuous as vol +from homeassistant.components.sensor.sql import validate_sql_select from homeassistant.setup import setup_component +from homeassistant.const import STATE_UNKNOWN from tests.common import get_test_home_assistant @@ -35,3 +39,25 @@ class TestSQLSensor(unittest.TestCase): state = self.hass.states.get('sensor.count_tables') self.assertEqual(state.state, '0') + + def test_invalid_query(self): + """Test the SQL sensor for invalid queries.""" + with pytest.raises(vol.Invalid): + validate_sql_select("DROP TABLE *") + + config = { + 'sensor': { + 'platform': 'sql', + 'db_url': 'sqlite://', + 'queries': [{ + 'name': 'count_tables', + 'query': 'SELECT * value FROM sqlite_master;', + 'column': 'value', + }] + } + } + + assert setup_component(self.hass, 'sensor', config) + + state = self.hass.states.get('sensor.count_tables') + self.assertEqual(state.state, STATE_UNKNOWN) From 205e83a6d51bab5d6f7252823c09b5a1af08c8b4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 6 Mar 2018 04:45:40 +0100 Subject: [PATCH 156/191] Fix netatmo sensor warning from invalid Voluptuous default (#12933) --- homeassistant/components/sensor/netatmo.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 8ec6de60fb9..4dddaf45aa4 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -18,8 +18,6 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_MODULE = 'modules' - CONF_MODULES = 'modules' CONF_STATION = 'station' @@ -54,7 +52,7 @@ SENSOR_TYPES = { } MODULE_SCHEMA = vol.Schema({ - vol.Required(cv.string, default=[]): + vol.Required(cv.string): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) From f054e9ee540493812f77f8545bd4417a43ae49e7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 5 Mar 2018 20:47:45 -0700 Subject: [PATCH 157/191] Updated to enforce quoted ZIP codes for Pollen (#12934) --- homeassistant/components/sensor/pollen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index d299a74b450..640e13e437a 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -106,7 +106,7 @@ RATING_MAPPING = [{ }] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ZIP_CODE): cv.string, + vol.Required(CONF_ZIP_CODE): str, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), }) From 78c27b99bd3be0d66fa9ffaac2dc8968db1b45bd Mon Sep 17 00:00:00 2001 From: karlkar Date: Tue, 6 Mar 2018 04:56:15 +0100 Subject: [PATCH 158/191] Added support for multiple onvif profiles (#11651) * Added support for multiple profiles * Removed attributes, setup made synchronous, as it performs I/O --- homeassistant/components/camera/onvif.py | 25 +++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 1340c52459d..d48f06539f4 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -33,6 +33,9 @@ DEFAULT_PORT = 5000 DEFAULT_USERNAME = 'admin' DEFAULT_PASSWORD = '888888' DEFAULT_ARGUMENTS = '-q:v 2' +DEFAULT_PROFILE = 0 + +CONF_PROFILE = "profile" ATTR_PAN = "pan" ATTR_TILT = "tilt" @@ -57,6 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, + vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): + vol.All(vol.Coerce(int), vol.Range(min=0)), }) SERVICE_PTZ_SCHEMA = vol.Schema({ @@ -67,8 +72,7 @@ SERVICE_PTZ_SCHEMA = vol.Schema({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a ONVIF camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): return @@ -91,7 +95,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass.services.async_register(DOMAIN, SERVICE_PTZ, handle_ptz, schema=SERVICE_PTZ_SCHEMA) - async_add_devices([ONVIFHassCamera(hass, config)]) + add_devices([ONVIFHassCamera(hass, config)]) class ONVIFHassCamera(Camera): @@ -114,10 +118,17 @@ class ONVIFHassCamera(Camera): config.get(CONF_USERNAME), config.get(CONF_PASSWORD) ) media_service = camera.create_media_service() - stream_uri = media_service.GetStreamUri( - {'StreamSetup': {'Stream': 'RTP-Unicast', 'Transport': 'RTSP'}} - ) - self._input = stream_uri.Uri.replace( + self._profiles = media_service.GetProfiles() + self._profile_index = config.get(CONF_PROFILE) + if self._profile_index >= len(self._profiles): + _LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d." + " Using the last profile.", + self._name, self._profile_index) + self._profile_index = -1 + req = media_service.create_type('GetStreamUri') + # pylint: disable=protected-access + req.ProfileToken = self._profiles[self._profile_index]._token + self._input = media_service.GetStreamUri(req).Uri.replace( 'rtsp://', 'rtsp://{}:{}@'.format( config.get(CONF_USERNAME), config.get(CONF_PASSWORD)), 1) From dc94079d749553e97b24a031ce138ea5d18004d9 Mon Sep 17 00:00:00 2001 From: Ryan Mounce Date: Wed, 7 Mar 2018 04:46:18 +1030 Subject: [PATCH 159/191] Make ubus dhcp name resolution optional (#12658) For the case that a separate DHCP server is used or the device is a dumb WiFi access point, allow device name resolution to be disabled. --- homeassistant/components/device_tracker/ubus.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 946aae5fe56..c75529655f4 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -23,7 +23,8 @@ CONF_DHCP_SOFTWARE = 'dhcp_software' DEFAULT_DHCP_SOFTWARE = 'dnsmasq' DHCP_SOFTWARES = [ 'dnsmasq', - 'odhcpd' + 'odhcpd', + 'none' ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -40,8 +41,10 @@ def get_scanner(hass, config): dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE] if dhcp_sw == 'dnsmasq': scanner = DnsmasqUbusDeviceScanner(config[DOMAIN]) - else: + elif dhcp_sw == 'odhcpd': scanner = OdhcpdUbusDeviceScanner(config[DOMAIN]) + else: + scanner = UbusDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -92,8 +95,8 @@ class UbusDeviceScanner(DeviceScanner): return self.last_results def _generate_mac2name(self): - """Must be implemented depending on the software.""" - raise NotImplementedError + """Return empty MAC to name dict. Overriden if DHCP server is set.""" + self.mac2name = dict() @_refresh_on_access_denied def get_device_name(self, device): From 9086119082761c26761df54de13a6f0a50bcdad5 Mon Sep 17 00:00:00 2001 From: Michael Pusterhofer Date: Tue, 6 Mar 2018 20:44:37 +0100 Subject: [PATCH 160/191] Add add_devices back to rpi_camera (#12947) * Add add_devices back to rpi_camera --- homeassistant/components/camera/rpi_camera.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py index f1f110d7c6a..91edf7d1053 100644 --- a/homeassistant/components/camera/rpi_camera.py +++ b/homeassistant/components/camera/rpi_camera.py @@ -106,6 +106,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("'%s' is not a whitelisted directory", file_path) return False + add_devices([RaspberryCamera(setup_config)]) + class RaspberryCamera(Camera): """Representation of a Raspberry Pi camera.""" From 36b9c0a9462d28d8fd9aa81d5cf5fed45a38005a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Mar 2018 11:53:02 -0800 Subject: [PATCH 161/191] Remove weird tests (#12936) * Remove mediaroom test * Fix meraki test doing mac lookups * Fix flaky unknown device config * Move more device tracker I/O testing into memory --- tests/components/device_tracker/test_init.py | 140 ++++++++---------- .../components/device_tracker/test_meraki.py | 11 +- .../components/media_player/test_mediaroom.py | 32 ---- tests/conftest.py | 25 +++- 4 files changed, 93 insertions(+), 115 deletions(-) delete mode 100644 tests/components/media_player/test_mediaroom.py diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 84cca1bb843..ebf568309ad 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -10,7 +10,7 @@ import os from homeassistant.components import zone from homeassistant.core import callback, State -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.helpers import discovery from homeassistant.loader import get_component from homeassistant.util.async import run_coroutine_threadsafe @@ -152,26 +152,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert setup_component(self.hass, device_tracker.DOMAIN, TEST_PLATFORM) - # pylint: disable=invalid-name - def test_adding_unknown_device_to_config(self): - """Test the adding of unknown devices to configuration file.""" - scanner = get_component('device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('DEV1') - - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}}) - - # wait for async calls (macvendor) to finish - self.hass.block_till_done() - - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - assert config[0].dev_id == 'dev1' - assert config[0].track - def test_gravatar(self): """Test the Gravatar generation.""" dev_id = 'test' @@ -646,61 +626,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert len(config) == 4 - def test_config_failure(self): - """Test that the device tracker see failures.""" - with assert_setup_component(0, device_tracker.DOMAIN): - setup_component(self.hass, device_tracker.DOMAIN, - {device_tracker.DOMAIN: { - device_tracker.CONF_CONSIDER_HOME: -1}}) - - def test_picture_and_icon_on_see_discovery(self): - """Test that picture and icon are set in initial see.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, {}, []) - tracker.see(dev_id=11, picture='pic_url', icon='mdi:icon') - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - assert config[0].icon == 'mdi:icon' - assert config[0].entity_picture == 'pic_url' - - def test_default_hide_if_away_is_used(self): - """Test that default track_new is used.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, - {device_tracker.CONF_AWAY_HIDE: True}, []) - tracker.see(dev_id=12) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - self.assertTrue(config[0].hidden) - - def test_backward_compatibility_for_track_new(self): - """Test backward compatibility for track new.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, - {device_tracker.CONF_TRACK_NEW: True}, []) - tracker.see(dev_id=13) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - self.assertFalse(config[0].track) - - def test_old_style_track_new_is_skipped(self): - """Test old style config is skipped.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), None, - {device_tracker.CONF_TRACK_NEW: False}, []) - tracker.see(dev_id=14) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - self.assertFalse(config[0].track) - @asyncio.coroutine def test_async_added_to_hass(hass): @@ -742,3 +667,66 @@ def test_bad_platform(hass): } with assert_setup_component(0, device_tracker.DOMAIN): assert (yield from device_tracker.async_setup(hass, config)) + + +async def test_adding_unknown_device_to_config(mock_device_tracker_conf, hass): + """Test the adding of unknown devices to configuration file.""" + scanner = get_component('device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('DEV1') + + await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}}) + + await hass.async_block_till_done() + + assert len(mock_device_tracker_conf) == 1 + device = mock_device_tracker_conf[0] + assert device.dev_id == 'dev1' + assert device.track + + +async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf, + hass): + """Test that picture and icon are set in initial see.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), False, {}, []) + await tracker.async_see(dev_id=11, picture='pic_url', icon='mdi:icon') + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].icon == 'mdi:icon' + assert mock_device_tracker_conf[0].entity_picture == 'pic_url' + + +async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass): + """Test that default track_new is used.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), False, + {device_tracker.CONF_AWAY_HIDE: True}, []) + await tracker.async_see(dev_id=12) + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].away_hide + + +async def test_backward_compatibility_for_track_new(mock_device_tracker_conf, + hass): + """Test backward compatibility for track new.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), False, + {device_tracker.CONF_TRACK_NEW: True}, []) + await tracker.async_see(dev_id=13) + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].track is False + + +async def test_old_style_track_new_is_skipped(mock_device_tracker_conf, hass): + """Test old style config is skipped.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), None, + {device_tracker.CONF_TRACK_NEW: False}, []) + await tracker.async_see(dev_id=14) + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].track is False diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py index a739df804fd..74fc577bca8 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -1,8 +1,9 @@ """The tests the for Meraki device tracker.""" import asyncio import json -from unittest.mock import patch + import pytest + from homeassistant.components.device_tracker.meraki import ( CONF_VALIDATOR, CONF_SECRET) from homeassistant.setup import async_setup_component @@ -24,12 +25,11 @@ def meraki_client(loop, hass, test_client): } })) - with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(test_client(hass.http.app)) + yield loop.run_until_complete(test_client(hass.http.app)) @asyncio.coroutine -def test_invalid_or_missing_data(meraki_client): +def test_invalid_or_missing_data(mock_device_tracker_conf, meraki_client): """Test validator with invalid or missing data.""" req = yield from meraki_client.get(URL) text = yield from req.text() @@ -87,7 +87,7 @@ def test_invalid_or_missing_data(meraki_client): @asyncio.coroutine -def test_data_will_be_saved(hass, meraki_client): +def test_data_will_be_saved(mock_device_tracker_conf, hass, meraki_client): """Test with valid data.""" data = { "version": "2.0", @@ -130,6 +130,7 @@ def test_data_will_be_saved(hass, meraki_client): } req = yield from meraki_client.post(URL, data=json.dumps(data)) assert req.status == 200 + yield from hass.async_block_till_done() state_name = hass.states.get('{}.{}'.format('device_tracker', '0026abb8a9a4')).state assert 'home' == state_name diff --git a/tests/components/media_player/test_mediaroom.py b/tests/components/media_player/test_mediaroom.py deleted file mode 100644 index 7c7922b87be..00000000000 --- a/tests/components/media_player/test_mediaroom.py +++ /dev/null @@ -1,32 +0,0 @@ -"""The tests for the mediaroom media_player.""" - -import unittest - -from homeassistant.setup import setup_component -import homeassistant.components.media_player as media_player -from tests.common import ( - assert_setup_component, get_test_home_assistant) - - -class TestMediaroom(unittest.TestCase): - """Tests the Mediaroom Component.""" - - def setUp(self): - """Initialize values for this test case class.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that we started.""" - self.hass.stop() - - def test_mediaroom_config(self): - """Test set up the platform with basic configuration.""" - config = { - media_player.DOMAIN: { - 'platform': 'mediaroom', - 'name': 'Living Room' - } - } - with assert_setup_component(1, media_player.DOMAIN) as result_config: - assert setup_component(self.hass, media_player.DOMAIN, config) - assert result_config[media_player.DOMAIN] diff --git a/tests/conftest.py b/tests/conftest.py index 989785e72d5..8f0ca787721 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,8 @@ import requests_mock as _requests_mock from homeassistant import util from homeassistant.util import location -from tests.common import async_test_home_assistant, INSTANCES, \ - async_mock_mqtt_component +from tests.common import ( + async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro) from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -106,3 +106,24 @@ def mock_openzwave(): 'openzwave.group': base_mock.group, }): yield base_mock + + +@pytest.fixture +def mock_device_tracker_conf(): + """Prevent device tracker from reading/writing data.""" + devices = [] + + async def mock_update_config(path, id, entity): + devices.append(entity) + + with patch( + 'homeassistant.components.device_tracker' + '.DeviceTracker.async_update_config', + side_effect=mock_update_config + ), patch( + 'homeassistant.components.device_tracker.async_load_config', + side_effect=lambda *args: mock_coro(devices) + ), patch('homeassistant.components.device_tracker' + '.Device.set_vendor_for_mac'): + + yield devices From e6364b4ff6161c8c0a66c5f23a2b9ccb103c87fd Mon Sep 17 00:00:00 2001 From: Christoph Gerneth Date: Tue, 6 Mar 2018 22:53:51 +0100 Subject: [PATCH 162/191] optional displaying the sensors location on the map (#12375) * Changed sensor attributes for GPS the sensor is now using the correct attributes (latitued and longitued) which enables them to show up on the map. * added option to display the sensor on the map the configuration will be extended by the optional 'show_on_map' flag. Default is not display the sensor on the map. * making the change non-breaking: old default behaviour * removed doubled attributes for lat and lon --- homeassistant/components/sensor/luftdaten.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index ac977e52fce..72ee8a7ce93 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_NAME, CONF_SHOW_ON_MAP, TEMP_CELSIUS) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -54,6 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, }) @@ -63,6 +65,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): from luftdaten import Luftdaten name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) sensor_id = config.get(CONF_SENSORID) session = async_get_clientsession(hass) @@ -79,7 +82,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if luftdaten.data.values[variable] is None: _LOGGER.warning("It might be that sensor %s is not providing " "measurements for %s", sensor_id, variable) - devices.append(LuftdatenSensor(luftdaten, name, variable, sensor_id)) + devices.append( + LuftdatenSensor(luftdaten, name, variable, sensor_id, show_on_map)) async_add_devices(devices) @@ -87,7 +91,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class LuftdatenSensor(Entity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, name, sensor_type, sensor_id): + def __init__(self, luftdaten, name, sensor_type, sensor_id, show): """Initialize the Luftdaten sensor.""" self.luftdaten = luftdaten self._name = name @@ -95,6 +99,7 @@ class LuftdatenSensor(Entity): self._sensor_id = sensor_id self.sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._show_on_map = show @property def name(self): @@ -114,12 +119,15 @@ class LuftdatenSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" + onmap = ATTR_LATITUDE, ATTR_LONGITUDE + nomap = 'lat', 'long' + lat_format, lon_format = onmap if self._show_on_map else nomap try: attr = { ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_SENSOR_ID: self._sensor_id, - 'lat': self.luftdaten.data.meta['latitude'], - 'long': self.luftdaten.data.meta['longitude'], + lat_format: self.luftdaten.data.meta['latitude'], + lon_format: self.luftdaten.data.meta['longitude'], } return attr except KeyError: From b04e7bba9f6fc847f42e447ecb4f2fea5d62f395 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 7 Mar 2018 00:34:24 +0000 Subject: [PATCH 163/191] [SQL Sensor] partial revert of #12452 (#12956) * partial revert of #12452 * return missing --- homeassistant/components/sensor/sql.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 4edb13e0416..50d60bfc426 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -131,23 +131,23 @@ class SQLSensor(Entity): try: sess = self.sessionmaker() result = sess.execute(self._query) + + if not result.returns_rows or result.rowcount == 0: + _LOGGER.warning("%s returned no results", self._query) + self._state = None + self._attributes = {} + return + + for res in result: + _LOGGER.debug("result = %s", res.items()) + data = res[self._column_name] + self._attributes = {k: v for k, v in res.items()} except sqlalchemy.exc.SQLAlchemyError as err: _LOGGER.error("Error executing query %s: %s", self._query, err) return finally: sess.close() - if not result.returns_rows or result.rowcount == 0: - _LOGGER.warning("%s returned no results", self._query) - self._state = None - self._attributes = {} - return - - for res in result: - _LOGGER.debug("result = %s", res.items()) - data = res[self._column_name] - self._attributes = {k: v for k, v in res.items()} - if self._template is not None: self._state = self._template.async_render_with_possible_json_value( data, None) From c462292e4d3017401a7def24ca8051b34ad605f1 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 7 Mar 2018 05:55:04 +0100 Subject: [PATCH 164/191] Fix LIFX color conversions (#12957) --- homeassistant/components/light/lifx.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 2b76a01dbc9..5e347742681 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -169,15 +169,15 @@ def find_hsbk(**kwargs): if ATTR_RGB_COLOR in kwargs: hue, saturation, brightness = \ color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR]) - hue = hue / 360 * 65535 - saturation = saturation / 100 * 65535 - brightness = brightness / 100 * 65535 + hue = int(hue / 360 * 65535) + saturation = int(saturation / 100 * 65535) + brightness = int(brightness / 100 * 65535) kelvin = 3500 if ATTR_XY_COLOR in kwargs: hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - hue = hue / 360 * 65535 - saturation = saturation / 100 * 65535 + hue = int(hue / 360 * 65535) + saturation = int(saturation / 100 * 65535) kelvin = 3500 if ATTR_COLOR_TEMP in kwargs: From 4aed41cbe81872cfca57f638c5fd69d8b861efb0 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Wed, 7 Mar 2018 09:29:24 +0100 Subject: [PATCH 165/191] BugFix Popp strike lock not discovered in homeassistant. (#12951) * Add Secure lockbox to lock discovery * Add Entry_control devices to binary sensors --- homeassistant/components/zwave/discovery_schemas.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave/discovery_schemas.py b/homeassistant/components/zwave/discovery_schemas.py index 19f428484f8..d38fbc7079c 100644 --- a/homeassistant/components/zwave/discovery_schemas.py +++ b/homeassistant/components/zwave/discovery_schemas.py @@ -16,6 +16,7 @@ DEFAULT_VALUES_SCHEMA = { DISCOVERY_SCHEMAS = [ {const.DISC_COMPONENT: 'binary_sensor', const.DISC_GENERIC_DEVICE_CLASS: [ + const.GENERIC_TYPE_ENTRY_CONTROL, const.GENERIC_TYPE_SENSOR_ALARM, const.GENERIC_TYPE_SENSOR_BINARY, const.GENERIC_TYPE_SWITCH_BINARY, @@ -173,7 +174,8 @@ DISCOVERY_SCHEMAS = [ const.DISC_GENERIC_DEVICE_CLASS: [const.GENERIC_TYPE_ENTRY_CONTROL], const.DISC_SPECIFIC_DEVICE_CLASS: [ const.SPECIFIC_TYPE_ADVANCED_DOOR_LOCK, - const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK], + const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK, + const.SPECIFIC_TYPE_SECURE_LOCKBOX], const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{ const.DISC_PRIMARY: { const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_DOOR_LOCK], From d119610cf1b4289094e2aaf0df15b492c4013f0f Mon Sep 17 00:00:00 2001 From: Jon Maddox Date: Wed, 7 Mar 2018 03:33:13 -0500 Subject: [PATCH 166/191] Add a Media Player Component for Channels (#12937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add Channels media player * add Channels' services * style :lipstick: * more :lipstick: * make up your mind robot * :lipstick: :lipstick: :lipstick: * dump client and pull it in via a package * ChannelsApp -> ChannelsPlayer * load the lib * add pychannels in requirements * not using requests anymore * extra line :lipstick: * move this here * move this up * :fire: * use constants for these * add a platform schema * force update here * get defaults to None * break out after finding it * use None for state if offline or errored * pull in CONF_NAME * fix syntax * update requirements_all.txt * :lipstick::lipstick::lipstick: * :lipstick: * docs * like this? ¯\(°_o)/¯ --- .../components/media_player/channels.py | 303 ++++++++++++++++++ .../components/media_player/services.yaml | 26 +- requirements_all.txt | 3 + 3 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/media_player/channels.py diff --git a/homeassistant/components/media_player/channels.py b/homeassistant/components/media_player/channels.py new file mode 100644 index 00000000000..eda47237b44 --- /dev/null +++ b/homeassistant/components/media_player/channels.py @@ -0,0 +1,303 @@ +""" +Support for interfacing with an instance of Channels (https://getchannels.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.channels/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_EPISODE, + MEDIA_TYPE_VIDEO, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, + SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOURCE, DOMAIN, PLATFORM_SCHEMA, + MediaPlayerDevice) + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, + ATTR_ENTITY_ID) + +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DATA_CHANNELS = 'channels' +DEFAULT_NAME = 'Channels' +DEFAULT_PORT = 57000 + +FEATURE_SUPPORT = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + +SERVICE_SEEK_FORWARD = 'channels_seek_forward' +SERVICE_SEEK_BACKWARD = 'channels_seek_backward' +SERVICE_SEEK_BY = 'channels_seek_by' + +# Service call validation schemas +ATTR_SECONDS = 'seconds' + +CHANNELS_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CHANNELS_SEEK_BY_SCHEMA = CHANNELS_SCHEMA.extend({ + vol.Required(ATTR_SECONDS): vol.Coerce(int), +}) + +REQUIREMENTS = ['pychannels==1.0.0'] + + +# pylint: disable=unused-argument, abstract-method +# pylint: disable=too-many-instance-attributes +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Channels platform.""" + device = ChannelsPlayer( + config.get('name', DEFAULT_NAME), + config.get(CONF_HOST), + config.get(CONF_PORT, DEFAULT_PORT) + ) + + if DATA_CHANNELS not in hass.data: + hass.data[DATA_CHANNELS] = [] + + add_devices([device], True) + hass.data[DATA_CHANNELS].append(device) + + def service_handler(service): + """Handler for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if entity_ids: + devices = [device for device in hass.data[DATA_CHANNELS] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_CHANNELS] + + for device in devices: + if service.service == SERVICE_SEEK_FORWARD: + device.seek_forward() + elif service.service == SERVICE_SEEK_BACKWARD: + device.seek_backward() + elif service.service == SERVICE_SEEK_BY: + seconds = service.data.get('seconds') + device.seek_by(seconds) + + hass.services.register( + DOMAIN, SERVICE_SEEK_FORWARD, service_handler, + schema=CHANNELS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SEEK_BACKWARD, service_handler, + schema=CHANNELS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SEEK_BY, service_handler, + schema=CHANNELS_SEEK_BY_SCHEMA) + + +class ChannelsPlayer(MediaPlayerDevice): + """Representation of a Channels instance.""" + + # pylint: disable=too-many-public-methods + def __init__(self, name, host, port): + """Initialize the Channels app.""" + from pychannels import Channels + + self._name = name + self._host = host + self._port = port + + self.client = Channels(self._host, self._port) + + self.status = None + self.muted = None + + self.channel_number = None + self.channel_name = None + self.channel_image_url = None + + self.now_playing_title = None + self.now_playing_episode_title = None + self.now_playing_season_number = None + self.now_playing_episode_number = None + self.now_playing_summary = None + self.now_playing_image_url = None + + self.favorite_channels = [] + + def update_favorite_channels(self): + """Update the favorite channels from the client.""" + self.favorite_channels = self.client.favorite_channels() + + def update_state(self, state_hash): + """Update all the state properties with the passed in dictionary.""" + self.status = state_hash.get('status', "stopped") + self.muted = state_hash.get('muted', False) + + channel_hash = state_hash.get('channel') + np_hash = state_hash.get('now_playing') + + if channel_hash: + self.channel_number = channel_hash.get('channel_number') + self.channel_name = channel_hash.get('channel_name') + self.channel_image_url = channel_hash.get('channel_image_url') + else: + self.channel_number = None + self.channel_name = None + self.channel_image_url = None + + if np_hash: + self.now_playing_title = np_hash.get('title') + self.now_playing_episode_title = np_hash.get('episode_title') + self.now_playing_season_number = np_hash.get('season_number') + self.now_playing_episode_number = np_hash.get('episode_number') + self.now_playing_summary = np_hash.get('summary') + self.now_playing_image_url = np_hash.get('image_url') + else: + self.now_playing_title = None + self.now_playing_episode_title = None + self.now_playing_season_number = None + self.now_playing_episode_number = None + self.now_playing_summary = None + self.now_playing_image_url = None + + @property + def name(self): + """Return the name of the player.""" + return self._name + + @property + def state(self): + """Return the state of the player.""" + if self.status == 'stopped': + return STATE_IDLE + + if self.status == 'paused': + return STATE_PAUSED + + if self.status == 'playing': + return STATE_PLAYING + + return None + + def update(self): + """Retrieve latest state.""" + self.update_favorite_channels() + self.update_state(self.client.status()) + + @property + def source_list(self): + """List of favorite channels.""" + sources = [channel['name'] for channel in self.favorite_channels] + return sources + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.muted + + @property + def media_content_id(self): + """Content ID of current playing channel.""" + return self.channel_number + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_CHANNEL + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.now_playing_image_url: + return self.now_playing_image_url + elif self.channel_image_url: + return self.channel_image_url + + return 'https://getchannels.com/assets/img/icon-1024.png' + + @property + def media_title(self): + """Title of current playing media.""" + if self.state: + return self.now_playing_title + + return None + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return FEATURE_SUPPORT + + def mute_volume(self, mute): + """Mute (true) or unmute (false) player.""" + if mute != self.muted: + response = self.client.toggle_muted() + self.update_state(response) + + def media_stop(self): + """Send media_stop command to player.""" + self.status = "stopped" + response = self.client.stop() + self.update_state(response) + + def media_play(self): + """Send media_play command to player.""" + response = self.client.resume() + self.update_state(response) + + def media_pause(self): + """Send media_pause command to player.""" + response = self.client.pause() + self.update_state(response) + + def media_next_track(self): + """Seek ahead.""" + response = self.client.skip_forward() + self.update_state(response) + + def media_previous_track(self): + """Seek back.""" + response = self.client.skip_backward() + self.update_state(response) + + def select_source(self, source): + """Select a channel to tune to.""" + for channel in self.favorite_channels: + if channel["name"] == source: + response = self.client.play_channel(channel["number"]) + self.update_state(response) + break + + def play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the player.""" + if media_type == MEDIA_TYPE_CHANNEL: + response = self.client.play_channel(media_id) + self.update_state(response) + elif media_type in [MEDIA_TYPE_VIDEO, MEDIA_TYPE_EPISODE, + MEDIA_TYPE_TVSHOW]: + response = self.client.play_recording(media_id) + self.update_state(response) + + def seek_forward(self): + """Seek forward in the timeline.""" + response = self.client.seek_forward() + self.update_state(response) + + def seek_backward(self): + """Seek backward in the timeline.""" + response = self.client.seek_backward() + self.update_state(response) + + def seek_by(self, seconds): + """Seek backward in the timeline.""" + response = self.client.seek(seconds) + self.update_state(response) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 4d488a92300..beaea8a8ad0 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -242,6 +242,30 @@ sonos_set_option: description: Enable Speech Enhancement mode example: 'true' +channels_seek_forward: + description: Seek forward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + +channels_seek_backward: + description: Seek backward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + +channels_seek_by: + description: Seek by an inputted number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + seconds: + description: Number of seconds to seek by. Negative numbers seek backwards. + example: 120 + soundtouch_play_everywhere: description: Play on all Bose Soundtouch devices. fields: @@ -367,7 +391,7 @@ bluesound_clear_sleep_timer: songpal_set_sound_setting: description: Change sound setting. - + fields: entity_id: description: Target device. diff --git a/requirements_all.txt b/requirements_all.txt index 8b7e6c8c16d..4eae1e2688f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -675,6 +675,9 @@ pybbox==0.0.5-alpha # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 +# homeassistant.components.media_player.channels +pychannels==1.0.0 + # homeassistant.components.media_player.cast pychromecast==2.0.0 From 35bae1eef2ce8ec7524a611b75c6c6bc3f857abc Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Wed, 7 Mar 2018 19:44:07 +0800 Subject: [PATCH 167/191] Telegram_bot three platform support proxy_url and proxy_params (#12878) * telegram_bot three platform support proxy_url and proxy_params * add telegram_bot._initialize_bot to create the bot instance * rename _initialize_bot to initialize_bot --- .../components/telegram_bot/__init__.py | 34 ++++++++++++------- .../components/telegram_bot/broadcast.py | 7 ++-- .../components/telegram_bot/polling.py | 6 ++-- .../components/telegram_bot/webhooks.py | 7 ++-- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index d4ac115d9c6..9e5d4cd9665 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -237,13 +237,12 @@ def async_setup(hass, config): _LOGGER.exception("Error setting up platform %s", p_type) return False + bot = initialize_bot(p_config) notify_service = TelegramNotificationService( hass, - p_config.get(CONF_API_KEY), + bot, p_config.get(CONF_ALLOWED_CHAT_IDS), - p_config.get(ATTR_PARSER), - p_config.get(CONF_PROXY_URL), - p_config.get(CONF_PROXY_PARAMS) + p_config.get(ATTR_PARSER) ) @asyncio.coroutine @@ -302,15 +301,28 @@ def async_setup(hass, config): return True +def initialize_bot(p_config): + """Initialize telegram bot with proxy support.""" + from telegram import Bot + from telegram.utils.request import Request + + api_key = p_config.get(CONF_API_KEY) + proxy_url = p_config.get(CONF_PROXY_URL) + proxy_params = p_config.get(CONF_PROXY_PARAMS) + + request = None + if proxy_url is not None: + request = Request(proxy_url=proxy_url, + urllib3_proxy_kwargs=proxy_params) + return Bot(token=api_key, request=request) + + class TelegramNotificationService: """Implement the notification services for the Telegram Bot domain.""" - def __init__(self, hass, api_key, allowed_chat_ids, parser, - proxy_url=None, proxy_params=None): + def __init__(self, hass, bot, allowed_chat_ids, parser): """Initialize the service.""" - from telegram import Bot from telegram.parsemode import ParseMode - from telegram.utils.request import Request self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] @@ -318,11 +330,7 @@ class TelegramNotificationService: self._parsers = {PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN} self._parse_mode = self._parsers.get(parser) - request = None - if proxy_url is not None: - request = Request(proxy_url=proxy_url, - urllib3_proxy_kwargs=proxy_params) - self.bot = Bot(token=api_key, request=request) + self.bot = bot self.hass = hass def _get_msg_ids(self, msg_data, chat_id): diff --git a/homeassistant/components/telegram_bot/broadcast.py b/homeassistant/components/telegram_bot/broadcast.py index 091aab58be8..7e157fdb0c7 100644 --- a/homeassistant/components/telegram_bot/broadcast.py +++ b/homeassistant/components/telegram_bot/broadcast.py @@ -8,8 +8,8 @@ import asyncio import logging from homeassistant.components.telegram_bot import ( + initialize_bot, PLATFORM_SCHEMA as TELEGRAM_PLATFORM_SCHEMA) -from homeassistant.const import CONF_API_KEY _LOGGER = logging.getLogger(__name__) @@ -20,8 +20,9 @@ PLATFORM_SCHEMA = TELEGRAM_PLATFORM_SCHEMA def async_setup_platform(hass, config): """Set up the Telegram broadcast platform.""" # Check the API key works - import telegram - bot = telegram.Bot(config[CONF_API_KEY]) + + bot = initialize_bot(config) + bot_config = yield from hass.async_add_job(bot.getMe) _LOGGER.debug("Telegram broadcast platform setup with bot %s", bot_config['username']) diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index bec239ba1dd..ba8dc54b264 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -13,10 +13,11 @@ from aiohttp.client_exceptions import ClientError from aiohttp.hdrs import CONNECTION, KEEP_ALIVE from homeassistant.components.telegram_bot import ( + initialize_bot, CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA as TELEGRAM_PLATFORM_SCHEMA) from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_API_KEY) + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -35,8 +36,7 @@ class WrongHttpStatus(Exception): @asyncio.coroutine def async_setup_platform(hass, config): """Set up the Telegram polling platform.""" - import telegram - bot = telegram.Bot(config[CONF_API_KEY]) + bot = initialize_bot(config) pol = TelegramPoll(bot, hass, config[CONF_ALLOWED_CHAT_IDS]) @callback diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 5c293459447..b7dd7ab8269 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -14,9 +14,10 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.const import KEY_REAL_IP from homeassistant.components.telegram_bot import ( - CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA) + CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA, + initialize_bot) from homeassistant.const import ( - CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, + EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, CONF_URL) import homeassistant.helpers.config_validation as cv @@ -50,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def async_setup_platform(hass, config): """Set up the Telegram webhooks platform.""" import telegram - bot = telegram.Bot(config[CONF_API_KEY]) + bot = initialize_bot(config) current_status = yield from hass.async_add_job(bot.getWebhookInfo) base_url = config.get(CONF_URL, hass.config.api.base_url) From 4218b31e7ba81ec5bb41b184ded06668a3be1d2c Mon Sep 17 00:00:00 2001 From: maxclaey Date: Wed, 7 Mar 2018 13:17:52 +0100 Subject: [PATCH 168/191] Add support for alarm system, switch and thermostat to homekit (#12819) * Added support for security system, switch and thermostat * Processing review * Only perform set call when the call didn't come from HomeKit * Added support for alarm_code * Take into account review remarks * Provide tests for HomeKit security systems, switches and thermostats * Support STATE_AUTO * Guard if state exists * Improve support for thermostat auto mode * Provide both high and low at the same time for home assistant * Set default values within accepted ranges * Added tests for auto mode * Fix thermostat test error * Use attributes.get instead of indexing for safety * Avoid hardcoded attributes in tests --- homeassistant/components/homekit/__init__.py | 26 +- homeassistant/components/homekit/const.py | 12 + .../components/homekit/security_systems.py | 92 +++++++ homeassistant/components/homekit/switches.py | 62 +++++ .../components/homekit/thermostats.py | 245 ++++++++++++++++++ script/gen_requirements_all.py | 5 +- .../homekit/test_security_systems.py | 92 +++++++ tests/components/homekit/test_switches.py | 64 +++++ tests/components/homekit/test_thermostats.py | 179 +++++++++++++ 9 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homekit/security_systems.py create mode 100644 homeassistant/components/homekit/switches.py create mode 100644 homeassistant/components/homekit/thermostats.py create mode 100644 tests/components/homekit/test_security_systems.py create mode 100644 tests/components/homekit/test_switches.py create mode 100644 tests/components/homekit/test_thermostats.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 40d43e2e14c..ad70740536e 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -13,6 +13,8 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.components.climate import ( + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry @@ -67,7 +69,8 @@ def import_types(): """Import all types from files in the HomeKit directory.""" _LOGGER.debug("Import type files.") # pylint: disable=unused-variable - from . import covers, sensors # noqa F401 + from . import ( # noqa F401 + covers, security_systems, sensors, switches, thermostats) def get_accessory(hass, state): @@ -87,6 +90,27 @@ def get_accessory(hass, state): state.entity_id, 'Window') return TYPES['Window'](hass, state.entity_id, state.name) + elif state.domain == 'alarm_control_panel': + _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, + 'SecuritySystem') + return TYPES['SecuritySystem'](hass, state.entity_id, state.name) + + elif state.domain == 'climate': + support_auto = False + features = state.attributes.get(ATTR_SUPPORTED_FEATURES) + # Check if climate device supports auto mode + if (features & SUPPORT_TARGET_TEMPERATURE_HIGH) \ + and (features & SUPPORT_TARGET_TEMPERATURE_LOW): + support_auto = True + _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Thermostat') + return TYPES['Thermostat'](hass, state.entity_id, + state.name, support_auto) + + elif state.domain == 'switch' or state.domain == 'remote' \ + or state.domain == 'input_boolean': + _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Switch') + return TYPES['Switch'](hass, state.entity_id, state.name) + return None diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 5201e21608a..35bd25eabd3 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -4,21 +4,33 @@ MANUFACTURER = 'HomeAssistant' # Services SERV_ACCESSORY_INFO = 'AccessoryInformation' SERV_BRIDGING_STATE = 'BridgingState' +SERV_SECURITY_SYSTEM = 'SecuritySystem' +SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' +SERV_THERMOSTAT = 'Thermostat' SERV_WINDOW_COVERING = 'WindowCovering' # Characteristics CHAR_ACC_IDENTIFIER = 'AccessoryIdentifier' CHAR_CATEGORY = 'Category' +CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' +CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' +CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' CHAR_LINK_QUALITY = 'LinkQuality' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' +CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' CHAR_REACHABLE = 'Reachable' CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' +CHAR_TARGET_TEMPERATURE = 'TargetTemperature' +CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' # Properties PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} diff --git a/homeassistant/components/homekit/security_systems.py b/homeassistant/components/homekit/security_systems.py new file mode 100644 index 00000000000..1b8f0a6820b --- /dev/null +++ b/homeassistant/components/homekit/security_systems.py @@ -0,0 +1,92 @@ +"""Class to hold all alarm control panel accessories.""" +import logging + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + ATTR_ENTITY_ID, ATTR_CODE) +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import ( + SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, + CHAR_TARGET_SECURITY_STATE) + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = {STATE_ALARM_DISARMED: 3, STATE_ALARM_ARMED_HOME: 0, + STATE_ALARM_ARMED_AWAY: 1, STATE_ALARM_ARMED_NIGHT: 2} +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +STATE_TO_SERVICE = {STATE_ALARM_DISARMED: 'alarm_disarm', + STATE_ALARM_ARMED_HOME: 'alarm_arm_home', + STATE_ALARM_ARMED_AWAY: 'alarm_arm_away', + STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night'} + + +@TYPES.register('SecuritySystem') +class SecuritySystem(HomeAccessory): + """Generate an SecuritySystem accessory for an alarm control panel.""" + + def __init__(self, hass, entity_id, display_name, alarm_code=None): + """Initialize a SecuritySystem accessory object.""" + super().__init__(display_name, entity_id, 'ALARM_SYSTEM') + + self._hass = hass + self._entity_id = entity_id + self._alarm_code = alarm_code + + self.flag_target_state = False + + self.service_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM) + self.char_current_state = self.service_alarm. \ + get_characteristic(CHAR_CURRENT_SECURITY_STATE) + self.char_current_state.value = 3 + self.char_target_state = self.service_alarm. \ + get_characteristic(CHAR_TARGET_SECURITY_STATE) + self.char_target_state.value = 3 + + self.char_target_state.setter_callback = self.set_security_state + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_security_state(new_state=state) + + async_track_state_change(self._hass, self._entity_id, + self.update_security_state) + + def set_security_state(self, value): + """Move security state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set security state to %d", + self._entity_id, value) + self.flag_target_state = True + hass_value = HOMEKIT_TO_HASS[value] + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self._entity_id} + if self._alarm_code is not None: + params[ATTR_CODE] = self._alarm_code + self._hass.services.call('alarm_control_panel', service, params) + + def update_security_state(self, entity_id=None, + old_state=None, new_state=None): + """Update security state after state changed.""" + if new_state is None: + return + + hass_state = new_state.state + if hass_state not in HASS_TO_HOMEKIT: + return + current_security_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_security_state) + _LOGGER.debug("%s: Updated current state to %s (%d)", + self._entity_id, hass_state, + current_security_state) + + if not self.flag_target_state: + self.char_target_state.set_value(current_security_state, + should_callback=False) + elif self.char_target_state.get_value() \ + == self.char_current_state.get_value(): + self.flag_target_state = False diff --git a/homeassistant/components/homekit/switches.py b/homeassistant/components/homekit/switches.py new file mode 100644 index 00000000000..876b3406d28 --- /dev/null +++ b/homeassistant/components/homekit/switches.py @@ -0,0 +1,62 @@ +"""Class to hold all switch accessories.""" +import logging + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import split_entity_id +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import SERV_SWITCH, CHAR_ON + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Switch') +class Switch(HomeAccessory): + """Generate a Switch accessory.""" + + def __init__(self, hass, entity_id, display_name): + """Initialize a Switch accessory object to represent a remote.""" + super().__init__(display_name, entity_id, 'SWITCH') + + self._hass = hass + self._entity_id = entity_id + self._domain = split_entity_id(entity_id)[0] + + self.flag_target_state = False + + self.service_switch = add_preload_service(self, SERV_SWITCH) + self.char_on = self.service_switch.get_characteristic(CHAR_ON) + self.char_on.value = False + self.char_on.setter_callback = self.set_state + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_state(new_state=state) + + async_track_state_change(self._hass, self._entity_id, + self.update_state) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", + self._entity_id, value) + self.flag_target_state = True + service = 'turn_on' if value else 'turn_off' + self._hass.services.call(self._domain, service, + {ATTR_ENTITY_ID: self._entity_id}) + + def update_state(self, entity_id=None, old_state=None, new_state=None): + """Update switch state after state changed.""" + if new_state is None: + return + + current_state = (new_state.state == 'on') + if not self.flag_target_state: + _LOGGER.debug("%s: Set current state to %s", + self._entity_id, current_state) + self.char_on.set_value(current_state, should_callback=False) + else: + self.flag_target_state = False diff --git a/homeassistant/components/homekit/thermostats.py b/homeassistant/components/homekit/thermostats.py new file mode 100644 index 00000000000..766a7e3585d --- /dev/null +++ b/homeassistant/components/homekit/thermostats.py @@ -0,0 +1,245 @@ +"""Class to hold all thermostat accessories.""" +import logging + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, + STATE_HEAT, STATE_COOL, STATE_AUTO) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import ( + SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, + CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, + CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, + CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +STATE_OFF = 'off' +UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} +UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()} +HC_HASS_TO_HOMEKIT = {STATE_OFF: 0, STATE_HEAT: 1, + STATE_COOL: 2, STATE_AUTO: 3} +HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()} + + +@TYPES.register('Thermostat') +class Thermostat(HomeAccessory): + """Generate a Thermostat accessory for a climate.""" + + def __init__(self, hass, entity_id, display_name, support_auto=False): + """Initialize a Thermostat accessory object.""" + super().__init__(display_name, entity_id, 'THERMOSTAT') + + self._hass = hass + self._entity_id = entity_id + self._call_timer = None + + self.heat_cool_flag_target_state = False + self.temperature_flag_target_state = False + self.coolingthresh_flag_target_state = False + self.heatingthresh_flag_target_state = False + + extra_chars = None + # Add additional characteristics if auto mode is supported + if support_auto: + extra_chars = [CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE] + + # Preload the thermostat service + self.service_thermostat = add_preload_service(self, SERV_THERMOSTAT, + extra_chars) + + # Current and target mode characteristics + self.char_current_heat_cool = self.service_thermostat. \ + get_characteristic(CHAR_CURRENT_HEATING_COOLING) + self.char_current_heat_cool.value = 0 + self.char_target_heat_cool = self.service_thermostat. \ + get_characteristic(CHAR_TARGET_HEATING_COOLING) + self.char_target_heat_cool.value = 0 + self.char_target_heat_cool.setter_callback = self.set_heat_cool + + # Current and target temperature characteristics + self.char_current_temp = self.service_thermostat. \ + get_characteristic(CHAR_CURRENT_TEMPERATURE) + self.char_current_temp.value = 21.0 + self.char_target_temp = self.service_thermostat. \ + get_characteristic(CHAR_TARGET_TEMPERATURE) + self.char_target_temp.value = 21.0 + self.char_target_temp.setter_callback = self.set_target_temperature + + # Display units characteristic + self.char_display_units = self.service_thermostat. \ + get_characteristic(CHAR_TEMP_DISPLAY_UNITS) + self.char_display_units.value = 0 + + # If the device supports it: high and low temperature characteristics + if support_auto: + self.char_cooling_thresh_temp = self.service_thermostat. \ + get_characteristic(CHAR_COOLING_THRESHOLD_TEMPERATURE) + self.char_cooling_thresh_temp.value = 23.0 + self.char_cooling_thresh_temp.setter_callback = \ + self.set_cooling_threshold + + self.char_heating_thresh_temp = self.service_thermostat. \ + get_characteristic(CHAR_HEATING_THRESHOLD_TEMPERATURE) + self.char_heating_thresh_temp.value = 19.0 + self.char_heating_thresh_temp.setter_callback = \ + self.set_heating_threshold + else: + self.char_cooling_thresh_temp = None + self.char_heating_thresh_temp = None + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_thermostat(new_state=state) + + async_track_state_change(self._hass, self._entity_id, + self.update_thermostat) + + def set_heat_cool(self, value): + """Move operation mode to value if call came from HomeKit.""" + if value in HC_HOMEKIT_TO_HASS: + _LOGGER.debug("%s: Set heat-cool to %d", self._entity_id, value) + self.heat_cool_flag_target_state = True + hass_value = HC_HOMEKIT_TO_HASS[value] + self._hass.services.call('climate', 'set_operation_mode', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_OPERATION_MODE: hass_value}) + + def set_cooling_threshold(self, value): + """Set cooling threshold temp to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set cooling threshold temperature to %.2f", + self._entity_id, value) + self.coolingthresh_flag_target_state = True + low = self.char_heating_thresh_temp.get_value() + self._hass.services.call( + 'climate', 'set_temperature', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_TARGET_TEMP_HIGH: value, + ATTR_TARGET_TEMP_LOW: low}) + + def set_heating_threshold(self, value): + """Set heating threshold temp to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set heating threshold temperature to %.2f", + self._entity_id, value) + self.heatingthresh_flag_target_state = True + # Home assistant always wants to set low and high at the same time + high = self.char_cooling_thresh_temp.get_value() + self._hass.services.call( + 'climate', 'set_temperature', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_TARGET_TEMP_LOW: value, + ATTR_TARGET_TEMP_HIGH: high}) + + def set_target_temperature(self, value): + """Set target temperature to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set target temperature to %.2f", + self._entity_id, value) + self.temperature_flag_target_state = True + self._hass.services.call( + 'climate', 'set_temperature', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_TEMPERATURE: value}) + + def update_thermostat(self, entity_id=None, + old_state=None, new_state=None): + """Update security state after state changed.""" + if new_state is None: + return + + # Update current temperature + current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + self.char_current_temp.set_value(current_temp) + + # Update target temperature + target_temp = new_state.attributes.get(ATTR_TEMPERATURE) + if target_temp is not None: + if not self.temperature_flag_target_state: + self.char_target_temp.set_value(target_temp, + should_callback=False) + else: + self.temperature_flag_target_state = False + + # Update cooling threshold temperature if characteristic exists + if self.char_cooling_thresh_temp is not None: + cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + if cooling_thresh is not None: + if not self.coolingthresh_flag_target_state: + self.char_cooling_thresh_temp.set_value( + cooling_thresh, should_callback=False) + else: + self.coolingthresh_flag_target_state = False + + # Update heating threshold temperature if characteristic exists + if self.char_heating_thresh_temp is not None: + heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + if heating_thresh is not None: + if not self.heatingthresh_flag_target_state: + self.char_heating_thresh_temp.set_value( + heating_thresh, should_callback=False) + else: + self.heatingthresh_flag_target_state = False + + # Update display units + display_units = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if display_units is not None \ + and display_units in UNIT_HASS_TO_HOMEKIT: + self.char_display_units.set_value( + UNIT_HASS_TO_HOMEKIT[display_units]) + + # Update target operation mode + operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) + if operation_mode is not None \ + and operation_mode in HC_HASS_TO_HOMEKIT: + if not self.heat_cool_flag_target_state: + self.char_target_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[operation_mode], should_callback=False) + else: + self.heat_cool_flag_target_state = False + + # Set current operation mode based on temperatures and target mode + if operation_mode == STATE_HEAT: + if current_temp < target_temp: + current_operation_mode = STATE_HEAT + else: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_COOL: + if current_temp > target_temp: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_AUTO: + # Check if auto is supported + if self.char_cooling_thresh_temp is not None: + lower_temp = self.char_heating_thresh_temp.get_value() + upper_temp = self.char_cooling_thresh_temp.get_value() + if current_temp < lower_temp: + current_operation_mode = STATE_HEAT + elif current_temp > upper_temp: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + else: + # Check if heating or cooling are supported + heat = STATE_HEAT in new_state.attributes[ATTR_OPERATION_LIST] + cool = STATE_COOL in new_state.attributes[ATTR_OPERATION_LIST] + if current_temp < target_temp and heat: + current_operation_mode = STATE_HEAT + elif current_temp > target_temp and cool: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + else: + current_operation_mode = STATE_OFF + + self.char_current_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[current_operation_mode]) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 087f722eed1..2d2c6bd7563 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -95,7 +95,10 @@ IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', 'homeassistant.components.homekit.accessories', 'homeassistant.components.homekit.covers', - 'homeassistant.components.homekit.sensors' + 'homeassistant.components.homekit.security_systems', + 'homeassistant.components.homekit.sensors', + 'homeassistant.components.homekit.switches', + 'homeassistant.components.homekit.thermostats' ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') diff --git a/tests/components/homekit/test_security_systems.py b/tests/components/homekit/test_security_systems.py new file mode 100644 index 00000000000..4753e86c084 --- /dev/null +++ b/tests/components/homekit/test_security_systems.py @@ -0,0 +1,92 @@ +"""Test different accessory types: Security Systems.""" +import unittest +from unittest.mock import patch + +from homeassistant.core import callback +from homeassistant.components.homekit.security_systems import SecuritySystem +from homeassistant.const import ( + ATTR_SERVICE, EVENT_CALL_SERVICE, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED) + +from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('security_systems') + + +class TestHomekitSecuritySystems(unittest.TestCase): + """Test class for all accessory types regarding security systems.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_switch_set_state(self): + """Test if accessory and HA are updated accordingly.""" + acp = 'alarm_control_panel.testsecurity' + + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = SecuritySystem(self.hass, acp, 'SecuritySystem') + acc.run() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 3) + + self.hass.states.set(acp, STATE_ALARM_ARMED_AWAY) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 1) + self.assertEqual(acc.char_current_state.value, 1) + + self.hass.states.set(acp, STATE_ALARM_ARMED_HOME) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 0) + self.assertEqual(acc.char_current_state.value, 0) + + self.hass.states.set(acp, STATE_ALARM_ARMED_NIGHT) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 2) + self.assertEqual(acc.char_current_state.value, 2) + + self.hass.states.set(acp, STATE_ALARM_DISARMED) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 3) + self.assertEqual(acc.char_current_state.value, 3) + + # Set from HomeKit + acc.char_target_state.set_value(0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') + self.assertEqual(acc.char_target_state.value, 0) + + acc.char_target_state.set_value(1) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'alarm_arm_away') + self.assertEqual(acc.char_target_state.value, 1) + + acc.char_target_state.set_value(2) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'alarm_arm_night') + self.assertEqual(acc.char_target_state.value, 2) + + acc.char_target_state.set_value(3) + self.hass.block_till_done() + self.assertEqual( + self.events[3].data[ATTR_SERVICE], 'alarm_disarm') + self.assertEqual(acc.char_target_state.value, 3) diff --git a/tests/components/homekit/test_switches.py b/tests/components/homekit/test_switches.py new file mode 100644 index 00000000000..d9f2d6c1d1a --- /dev/null +++ b/tests/components/homekit/test_switches.py @@ -0,0 +1,64 @@ +"""Test different accessory types: Switches.""" +import unittest +from unittest.mock import patch + +from homeassistant.core import callback +from homeassistant.components.homekit.switches import Switch +from homeassistant.const import ATTR_SERVICE, EVENT_CALL_SERVICE + +from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('switches') + + +class TestHomekitSwitches(unittest.TestCase): + """Test class for all accessory types regarding switches.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_switch_set_state(self): + """Test if accessory and HA are updated accordingly.""" + switch = 'switch.testswitch' + + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = Switch(self.hass, switch, 'Switch') + acc.run() + + self.assertEqual(acc.char_on.value, False) + + self.hass.states.set(switch, 'on') + self.hass.block_till_done() + self.assertEqual(acc.char_on.value, True) + + self.hass.states.set(switch, 'off') + self.hass.block_till_done() + self.assertEqual(acc.char_on.value, False) + + # Set from HomeKit + acc.char_on.set_value(True) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'turn_on') + self.assertEqual(acc.char_on.value, True) + + acc.char_on.set_value(False) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'turn_off') + self.assertEqual(acc.char_on.value, False) diff --git a/tests/components/homekit/test_thermostats.py b/tests/components/homekit/test_thermostats.py new file mode 100644 index 00000000000..fabffe881bb --- /dev/null +++ b/tests/components/homekit/test_thermostats.py @@ -0,0 +1,179 @@ +"""Test different accessory types: Thermostats.""" +import unittest +from unittest.mock import patch + +from homeassistant.core import callback +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, + ATTR_OPERATION_MODE, STATE_HEAT, STATE_AUTO) +from homeassistant.components.homekit.thermostats import Thermostat, STATE_OFF +from homeassistant.const import ( + ATTR_SERVICE, EVENT_CALL_SERVICE, ATTR_SERVICE_DATA, + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) + +from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('thermostats') + + +class TestHomekitThermostats(unittest.TestCase): + """Test class for all accessory types regarding thermostats.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_default_thermostat(self): + """Test if accessory and HA are updated accordingly.""" + climate = 'climate.testclimate' + + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = Thermostat(self.hass, climate, 'Climate', False) + acc.run() + + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 0) + self.assertEqual(acc.char_current_temp.value, 21.0) + self.assertEqual(acc.char_target_temp.value, 21.0) + self.assertEqual(acc.char_display_units.value, 0) + self.assertEqual(acc.char_cooling_thresh_temp, None) + self.assertEqual(acc.char_heating_thresh_temp, None) + + self.hass.states.set(climate, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_target_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 1) + self.assertEqual(acc.char_target_heat_cool.value, 1) + self.assertEqual(acc.char_current_temp.value, 18.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 23.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_target_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 1) + self.assertEqual(acc.char_current_temp.value, 23.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_OFF, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_target_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 0) + self.assertEqual(acc.char_current_temp.value, 18.0) + self.assertEqual(acc.char_display_units.value, 0) + + # Set from HomeKit + acc.char_target_temp.set_value(19.0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_temperature') + self.assertEqual( + self.events[0].data[ATTR_SERVICE_DATA][ATTR_TEMPERATURE], 19.0) + self.assertEqual(acc.char_target_temp.value, 19.0) + + acc.char_target_heat_cool.set_value(1) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'set_operation_mode') + self.assertEqual( + self.events[1].data[ATTR_SERVICE_DATA][ATTR_OPERATION_MODE], + STATE_HEAT) + self.assertEqual(acc.char_target_heat_cool.value, 1) + + def test_auto_thermostat(self): + """Test if accessory and HA are updated accordingly.""" + climate = 'climate.testclimate' + + acc = Thermostat(self.hass, climate, 'Climate', True) + acc.run() + + self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) + self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) + + self.hass.states.set(climate, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 1) + self.assertEqual(acc.char_target_heat_cool.value, 3) + self.assertEqual(acc.char_current_temp.value, 18.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 24.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) + self.assertEqual(acc.char_current_heat_cool.value, 2) + self.assertEqual(acc.char_target_heat_cool.value, 3) + self.assertEqual(acc.char_current_temp.value, 24.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 3) + self.assertEqual(acc.char_current_temp.value, 21.0) + self.assertEqual(acc.char_display_units.value, 0) + + # Set from HomeKit + acc.char_heating_thresh_temp.set_value(20.0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_temperature') + self.assertEqual( + self.events[0].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_LOW], 20.0) + self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) + + acc.char_cooling_thresh_temp.set_value(25.0) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'set_temperature') + self.assertEqual( + self.events[1].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_HIGH], + 25.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 25.0) From a99c8eb6c6ee410590ebc47b85a1c458988afb82 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 7 Mar 2018 09:46:21 -0500 Subject: [PATCH 169/191] Pin lokalise script to working version (#12965) --- .travis.yml | 2 +- script/translations_download | 3 +-- script/translations_upload | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index cd068461392..fce86348817 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,7 +31,7 @@ script: travis_wait 30 tox --develop services: - docker before_deploy: - - docker pull lokalise/lokalise-cli + - docker pull lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 deploy: skip_cleanup: true provider: script diff --git a/script/translations_download b/script/translations_download index de1f4640988..099e32c9d1b 100755 --- a/script/translations_download +++ b/script/translations_download @@ -26,10 +26,9 @@ FILE_FORMAT=json mkdir -p ${LOCAL_DIR} -docker pull lokalise/lokalise-cli docker run \ -v ${LOCAL_DIR}:/opt/dest/locale \ - lokalise/lokalise-cli lokalise \ + lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ --token ${LOKALISE_TOKEN} \ export ${PROJECT_ID} \ --export_empty skip \ diff --git a/script/translations_upload b/script/translations_upload index fcc12ef272f..578cc8c0ccf 100755 --- a/script/translations_upload +++ b/script/translations_upload @@ -33,10 +33,9 @@ fi script/translations_upload_merge.py -docker pull lokalise/lokalise-cli docker run \ -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \ - lokalise/lokalise-cli lokalise \ + lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ --token ${LOKALISE_TOKEN} \ import ${PROJECT_ID} \ --file /opt/src/${LOCAL_FILE} \ From b159e8acee180d4ed0ab75e6ab59dd3cc18e4072 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 7 Mar 2018 09:51:36 -0800 Subject: [PATCH 170/191] Hue: Don't change brightness when changing just color (#12940) --- homeassistant/components/light/hue.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 40c2f647940..bee6840f346 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -302,7 +302,6 @@ class HueLight(Light): xyb = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['xy'] = xyb[0], xyb[1] - command['bri'] = xyb[2] elif ATTR_COLOR_TEMP in kwargs: temp = kwargs[ATTR_COLOR_TEMP] command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) From 5dd0193ba6d32dfc2126b84ff0e09d198c2041c6 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 8 Mar 2018 07:24:35 +0100 Subject: [PATCH 171/191] LIFX async/await conversion (#12973) * LIFX async/await conversion * async with --- homeassistant/components/light/lifx.py | 149 +++++++++++-------------- 1 file changed, 67 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 5e347742681..18bc39d88d2 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -123,8 +123,10 @@ def aiolifx_effects(): return aiolifx_effects_module -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): """Set up the LIFX platform.""" if sys.platform == 'win32': _LOGGER.warning("The lifx platform is known to not work on Windows. " @@ -214,8 +216,7 @@ class LIFXManager(object): def register_set_state(self): """Register the LIFX set_state service call.""" - @asyncio.coroutine - def async_service_handle(service): + async def service_handler(service): """Apply a service.""" tasks = [] for light in self.service_to_entities(service): @@ -223,36 +224,34 @@ class LIFXManager(object): task = light.async_set_state(**service.data) tasks.append(self.hass.async_add_job(task)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self.hass.services.async_register( - DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle, + DOMAIN, SERVICE_LIFX_SET_STATE, service_handler, schema=LIFX_SET_STATE_SCHEMA) def register_effects(self): """Register the LIFX effects as hass service calls.""" - @asyncio.coroutine - def async_service_handle(service): + async def service_handler(service): """Apply a service, i.e. start an effect.""" entities = self.service_to_entities(service) if entities: - yield from self.start_effect( + await self.start_effect( entities, service.service, **service.data) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, + DOMAIN, SERVICE_EFFECT_PULSE, service_handler, schema=LIFX_EFFECT_PULSE_SCHEMA) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, + DOMAIN, SERVICE_EFFECT_COLORLOOP, service_handler, schema=LIFX_EFFECT_COLORLOOP_SCHEMA) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_STOP, async_service_handle, + DOMAIN, SERVICE_EFFECT_STOP, service_handler, schema=LIFX_EFFECT_STOP_SCHEMA) - @asyncio.coroutine - def start_effect(self, entities, service, **kwargs): + async def start_effect(self, entities, service, **kwargs): """Start a light effect on entities.""" devices = list(map(lambda l: l.device, entities)) @@ -264,7 +263,7 @@ class LIFXManager(object): mode=kwargs.get(ATTR_MODE), hsbk=find_hsbk(**kwargs), ) - yield from self.effects_conductor.start(effect, devices) + await self.effects_conductor.start(effect, devices) elif service == SERVICE_EFFECT_COLORLOOP: preprocess_turn_on_alternatives(kwargs) @@ -280,9 +279,9 @@ class LIFXManager(object): transition=kwargs.get(ATTR_TRANSITION), brightness=brightness, ) - yield from self.effects_conductor.start(effect, devices) + await self.effects_conductor.start(effect, devices) elif service == SERVICE_EFFECT_STOP: - yield from self.effects_conductor.stop(devices) + await self.effects_conductor.stop(devices) def service_to_entities(self, service): """Return the known devices that a service call mentions.""" @@ -297,25 +296,24 @@ class LIFXManager(object): @callback def register(self, device): - """Handle newly detected bulb.""" - self.hass.async_add_job(self.async_register(device)) + """Handle aiolifx detected bulb.""" + self.hass.async_add_job(self.register_new_device(device)) - @asyncio.coroutine - def async_register(self, device): + async def register_new_device(self, device): """Handle newly detected bulb.""" if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] entity.registered = True _LOGGER.debug("%s register AGAIN", entity.who) - yield from entity.update_hass() + await entity.update_hass() else: _LOGGER.debug("%s register NEW", device.ip_addr) # Read initial state ack = AwaitAioLIFX().wait - version_resp = yield from ack(device.get_version) + version_resp = await ack(device.get_version) if version_resp: - color_resp = yield from ack(device.get_color) + color_resp = await ack(device.get_color) if version_resp is None or color_resp is None: _LOGGER.error("Failed to initialize %s", device.ip_addr) @@ -337,7 +335,7 @@ class LIFXManager(object): @callback def unregister(self, device): - """Handle disappearing bulbs.""" + """Handle aiolifx disappearing bulbs.""" if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] _LOGGER.debug("%s unregister", entity.who) @@ -361,15 +359,14 @@ class AwaitAioLIFX: self.message = message self.event.set() - @asyncio.coroutine - def wait(self, method): + async def wait(self, method): """Call an aiolifx method and wait for its response.""" self.device = None self.message = None self.event.clear() method(callb=self.callback) - yield from self.event.wait() + await self.event.wait() return self.message @@ -466,21 +463,19 @@ class LIFXLight(Light): return 'lifx_effect_' + effect.name return None - @asyncio.coroutine - def update_hass(self, now=None): + async def update_hass(self, now=None): """Request new status and push it to hass.""" self.postponed_update = None - yield from self.async_update() - yield from self.async_update_ha_state() + await self.async_update() + await self.async_update_ha_state() - @asyncio.coroutine - def update_during_transition(self, when): + async def update_during_transition(self, when): """Update state at the start and end of a transition.""" if self.postponed_update: self.postponed_update() # Transition has started - yield from self.update_hass() + await self.update_hass() # Transition has ended if when > 0: @@ -488,28 +483,25 @@ class LIFXLight(Light): self.hass, self.update_hass, util.dt.utcnow() + timedelta(milliseconds=when)) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" kwargs[ATTR_POWER] = True - self.hass.async_add_job(self.async_set_state(**kwargs)) + self.hass.async_add_job(self.set_state(**kwargs)) - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" kwargs[ATTR_POWER] = False - self.hass.async_add_job(self.async_set_state(**kwargs)) + self.hass.async_add_job(self.set_state(**kwargs)) - @asyncio.coroutine - def async_set_state(self, **kwargs): + async def set_state(self, **kwargs): """Set a color on the light and turn it on/off.""" - with (yield from self.lock): + async with self.lock: bulb = self.device - yield from self.effects_conductor.stop([bulb]) + await self.effects_conductor.stop([bulb]) if ATTR_EFFECT in kwargs: - yield from self.default_effect(**kwargs) + await self.default_effect(**kwargs) return if ATTR_INFRARED in kwargs: @@ -531,51 +523,47 @@ class LIFXLight(Light): if not self.is_on: if power_off: - yield from self.set_power(ack, False) + await self.set_power(ack, False) if hsbk: - yield from self.set_color(ack, hsbk, kwargs) + await self.set_color(ack, hsbk, kwargs) if power_on: - yield from self.set_power(ack, True, duration=fade) + await self.set_power(ack, True, duration=fade) else: if power_on: - yield from self.set_power(ack, True) + await self.set_power(ack, True) if hsbk: - yield from self.set_color(ack, hsbk, kwargs, duration=fade) + await self.set_color(ack, hsbk, kwargs, duration=fade) if power_off: - yield from self.set_power(ack, False, duration=fade) + await self.set_power(ack, False, duration=fade) # Avoid state ping-pong by holding off updates as the state settles - yield from asyncio.sleep(0.3) + await asyncio.sleep(0.3) # Update when the transition starts and ends - yield from self.update_during_transition(fade) + await self.update_during_transition(fade) - @asyncio.coroutine - def set_power(self, ack, pwr, duration=0): + async def set_power(self, ack, pwr, duration=0): """Send a power change to the device.""" - yield from ack(partial(self.device.set_power, pwr, duration=duration)) + await ack(partial(self.device.set_power, pwr, duration=duration)) - @asyncio.coroutine - def set_color(self, ack, hsbk, kwargs, duration=0): + async def set_color(self, ack, hsbk, kwargs, duration=0): """Send a color change to the device.""" hsbk = merge_hsbk(self.device.color, hsbk) - yield from ack(partial(self.device.set_color, hsbk, duration=duration)) + await ack(partial(self.device.set_color, hsbk, duration=duration)) - @asyncio.coroutine - def default_effect(self, **kwargs): + async def default_effect(self, **kwargs): """Start an effect with default parameters.""" service = kwargs[ATTR_EFFECT] data = { ATTR_ENTITY_ID: self.entity_id, } - yield from self.hass.services.async_call(DOMAIN, service, data) + await self.hass.services.async_call(DOMAIN, service, data) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update bulb status.""" _LOGGER.debug("%s async_update", self.who) if self.available and not self.lock.locked(): - yield from AwaitAioLIFX().wait(self.device.get_color) + await AwaitAioLIFX().wait(self.device.get_color) class LIFXWhite(LIFXLight): @@ -624,8 +612,7 @@ class LIFXColor(LIFXLight): class LIFXStrip(LIFXColor): """Representation of a LIFX light strip with multiple zones.""" - @asyncio.coroutine - def set_color(self, ack, hsbk, kwargs, duration=0): + async def set_color(self, ack, hsbk, kwargs, duration=0): """Send a color change to the device.""" bulb = self.device num_zones = len(bulb.color_zones) @@ -635,7 +622,7 @@ class LIFXStrip(LIFXColor): # Fast track: setting all zones to the same brightness and color # can be treated as a single-zone bulb. if hsbk[2] is not None and hsbk[3] is not None: - yield from super().set_color(ack, hsbk, kwargs, duration) + await super().set_color(ack, hsbk, kwargs, duration) return zones = list(range(0, num_zones)) @@ -644,11 +631,11 @@ class LIFXStrip(LIFXColor): # Zone brightness is not reported when powered off if not self.is_on and hsbk[2] is None: - yield from self.set_power(ack, True) - yield from asyncio.sleep(0.3) - yield from self.update_color_zones() - yield from self.set_power(ack, False) - yield from asyncio.sleep(0.3) + await self.set_power(ack, True) + await asyncio.sleep(0.3) + await self.update_color_zones() + await self.set_power(ack, False) + await asyncio.sleep(0.3) # Send new color to each zone for index, zone in enumerate(zones): @@ -660,23 +647,21 @@ class LIFXStrip(LIFXColor): color=zone_hsbk, duration=duration, apply=apply) - yield from ack(set_zone) + await ack(set_zone) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update strip status.""" if self.available and not self.lock.locked(): - yield from super().async_update() - yield from self.update_color_zones() + await super().async_update() + await self.update_color_zones() - @asyncio.coroutine - def update_color_zones(self): + async def update_color_zones(self): """Get updated color information for each zone.""" zone = 0 top = 1 while self.available and zone < top: # Each get_color_zones can update 8 zones at once - resp = yield from AwaitAioLIFX().wait(partial( + resp = await AwaitAioLIFX().wait(partial( self.device.get_color_zones, start_index=zone)) if resp: From 8792fd22b91731141185bacdbc03a6e7a9856592 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 8 Mar 2018 21:30:50 +0100 Subject: [PATCH 172/191] IMAP sensor async/await conversion (#12988) --- homeassistant/components/sensor/imap.py | 57 +++++++++++-------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index f0585228851..647a40295ac 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -39,8 +39,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): """Set up the IMAP platform.""" sensor = ImapSensor(config.get(CONF_NAME), config.get(CONF_USERNAME), @@ -48,8 +50,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_SERVER), config.get(CONF_PORT), config.get(CONF_FOLDER)) - - if not (yield from sensor.connection()): + if not await sensor.connection(): raise PlatformNotReady hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.shutdown()) @@ -72,8 +73,7 @@ class ImapSensor(Entity): self._does_push = None self._idle_loop_task = None - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Handle when an entity is about to be added to Home Assistant.""" if not self.should_poll: self._idle_loop_task = self.hass.loop.create_task(self.idle_loop()) @@ -103,8 +103,7 @@ class ImapSensor(Entity): """Return if polling is needed.""" return not self._does_push - @asyncio.coroutine - def connection(self): + async def connection(self): """Return a connection to the server, establishing it if necessary.""" import aioimaplib @@ -112,53 +111,50 @@ class ImapSensor(Entity): try: self._connection = aioimaplib.IMAP4_SSL( self._server, self._port) - yield from self._connection.wait_hello_from_server() - yield from self._connection.login(self._user, self._password) - yield from self._connection.select(self._folder) + await self._connection.wait_hello_from_server() + await self._connection.login(self._user, self._password) + await self._connection.select(self._folder) self._does_push = self._connection.has_capability('IDLE') except (aioimaplib.AioImapException, asyncio.TimeoutError): self._connection = None return self._connection - @asyncio.coroutine - def idle_loop(self): + async def idle_loop(self): """Wait for data pushed from server.""" import aioimaplib while True: try: - if (yield from self.connection()): - yield from self.refresh_unread_count() - yield from self.async_update_ha_state() + if await self.connection(): + await self.refresh_unread_count() + await self.async_update_ha_state() - idle = yield from self._connection.idle_start() - yield from self._connection.wait_server_push() + idle = await self._connection.idle_start() + await self._connection.wait_server_push() self._connection.idle_done() with async_timeout.timeout(10): - yield from idle + await idle else: - yield from self.async_update_ha_state() + await self.async_update_ha_state() except (aioimaplib.AioImapException, asyncio.TimeoutError): self.disconnected() - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Periodic polling of state.""" import aioimaplib try: - if (yield from self.connection()): - yield from self.refresh_unread_count() + if await self.connection(): + await self.refresh_unread_count() except (aioimaplib.AioImapException, asyncio.TimeoutError): self.disconnected() - @asyncio.coroutine - def refresh_unread_count(self): + async def refresh_unread_count(self): """Check the number of unread emails.""" if self._connection: - yield from self._connection.noop() - _, lines = yield from self._connection.search('UnSeen UnDeleted') + await self._connection.noop() + _, lines = await self._connection.search('UnSeen UnDeleted') self._unread_count = len(lines[0].split()) def disconnected(self): @@ -166,12 +162,11 @@ class ImapSensor(Entity): _LOGGER.warning("Lost %s (will attempt to reconnect)", self._server) self._connection = None - @asyncio.coroutine - def shutdown(self): + async def shutdown(self): """Close resources.""" if self._connection: if self._connection.has_pending_idle(): self._connection.idle_done() - yield from self._connection.logout() + await self._connection.logout() if self._idle_loop_task: self._idle_loop_task.cancel() From 9b1a75a74ba105533812a368acda7a4b9402e133 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Mar 2018 14:39:10 -0800 Subject: [PATCH 173/191] Refactor Google Assistant (#12959) * Refactor Google Assistant * Fix cloud test * Fix supported features media player demo * Fix query * Fix execute * Fix demo media player tests * Add tests for traits * Lint * Lint * Add integration tests * Add more tests * update logging * Catch out of range temp errrors * Fix cloud error * Lint --- homeassistant/components/cloud/__init__.py | 7 +- homeassistant/components/cloud/iot.py | 9 +- .../components/google_assistant/__init__.py | 4 +- .../components/google_assistant/const.py | 28 +- .../components/google_assistant/helpers.py | 23 + .../components/google_assistant/http.py | 8 +- .../components/google_assistant/smart_home.py | 688 +++++++----------- .../components/google_assistant/trait.py | 510 +++++++++++++ homeassistant/components/light/__init__.py | 6 +- homeassistant/components/media_player/demo.py | 26 +- tests/components/cloud/test_iot.py | 3 +- tests/components/google_assistant/__init__.py | 18 +- .../google_assistant/test_google_assistant.py | 154 ++-- .../google_assistant/test_smart_home.py | 431 ++++++----- .../components/google_assistant/test_trait.py | 569 +++++++++++++++ tests/components/media_player/test_demo.py | 14 - 16 files changed, 1676 insertions(+), 822 deletions(-) create mode 100644 homeassistant/components/google_assistant/helpers.py create mode 100644 homeassistant/components/google_assistant/trait.py create mode 100644 tests/components/google_assistant/test_trait.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 3657b64b989..adf0b8f51b6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -15,12 +15,12 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME, CONF_TYPE) + EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import smart_home as ga_sh +from homeassistant.components.google_assistant import helpers as ga_h from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -51,7 +51,6 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ GOOGLE_ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.In(ga_sh.MAPPING_COMPONENT), vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) }) @@ -175,7 +174,7 @@ class Cloud: """If an entity should be exposed.""" return conf['filter'](entity.entity_id) - self._gactions_config = ga_sh.Config( + self._gactions_config = ga_h.Config( should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 91fbc85df6b..7cf8e50e866 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -1,6 +1,7 @@ """Module to handle messages from Home Assistant cloud.""" import asyncio import logging +import pprint from aiohttp import hdrs, client_exceptions, WSMsgType @@ -154,7 +155,9 @@ class CloudIoT: disconnect_warn = 'Received invalid JSON.' break - _LOGGER.debug("Received message: %s", msg) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received message:\n%s\n", + pprint.pformat(msg)) response = { 'msgid': msg['msgid'], @@ -176,7 +179,9 @@ class CloudIoT: _LOGGER.exception("Error handling message") response['error'] = 'exception' - _LOGGER.debug("Publishing message: %s", response) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(response)) yield from client.send_json(response) except client_exceptions.WSServerHandshakeError as err: diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 20dee082a08..676654c2c91 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.loader import bind_hass @@ -31,7 +31,6 @@ from .const import ( ) from .auth import GoogleAssistantAuthView from .http import async_register_http -from .smart_home import MAPPING_COMPONENT _LOGGER = logging.getLogger(__name__) @@ -41,7 +40,6 @@ DEFAULT_AGENT_USER_ID = 'home-assistant' ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT), vol.Optional(CONF_EXPOSE): cv.boolean, vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_ROOM_HINT): cv.string diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 1f1ae4682b4..12888ea2cf6 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,25 +22,6 @@ DEFAULT_EXPOSED_DOMAINS = [ CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} -PREFIX_TRAITS = 'action.devices.traits.' -TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' -TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' -TRAIT_RGB_COLOR = PREFIX_TRAITS + 'ColorSpectrum' -TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' -TRAIT_SCENE = PREFIX_TRAITS + 'Scene' -TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' - -PREFIX_COMMANDS = 'action.devices.commands.' -COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' -COMMAND_BRIGHTNESS = PREFIX_COMMANDS + 'BrightnessAbsolute' -COMMAND_COLOR = PREFIX_COMMANDS + 'ColorAbsolute' -COMMAND_ACTIVATESCENE = PREFIX_COMMANDS + 'ActivateScene' -COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') -COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') -COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' - PREFIX_TYPES = 'action.devices.types.' TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' @@ -50,3 +31,12 @@ TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' + +# Error codes used for SmartHomeError class +# https://developers.google.com/actions/smarthome/create-app#error_responses +ERR_DEVICE_OFFLINE = "deviceOffline" +ERR_DEVICE_NOT_FOUND = "deviceNotFound" +ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" +ERR_NOT_SUPPORTED = "notSupported" +ERR_PROTOCOL_ERROR = 'protocolError' +ERR_UNKNOWN_ERROR = 'unknownError' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py new file mode 100644 index 00000000000..ef6ae109eb5 --- /dev/null +++ b/homeassistant/components/google_assistant/helpers.py @@ -0,0 +1,23 @@ +"""Helper classes for Google Assistant integration.""" + + +class SmartHomeError(Exception): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, code, msg): + """Log error code.""" + super().__init__(msg) + self.code = code + + +class Config: + """Hold the configuration for Google Assistant.""" + + def __init__(self, should_expose, agent_user_id, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.agent_user_id = agent_user_id + self.entity_config = entity_config or {} diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f376435d2ef..0caea3aadf4 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,8 +10,6 @@ import logging from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response # NOQA -from homeassistant.const import HTTP_UNAUTHORIZED - # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports from homeassistant.components.http import HomeAssistantView @@ -27,7 +25,8 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_EXPOSE, ) -from .smart_home import async_handle_message, Config +from .smart_home import async_handle_message +from .helpers import Config _LOGGER = logging.getLogger(__name__) @@ -83,8 +82,7 @@ class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" auth = request.headers.get(AUTHORIZATION, None) if 'Bearer {}'.format(self.access_token) != auth: - return self.json_message( - "missing authorization", status_code=HTTP_UNAUTHORIZED) + return self.json_message("missing authorization", status_code=401) message = yield from request.json() # type: dict result = yield from async_handle_message( diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index f638b847bcb..48d24c00b97 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,5 +1,6 @@ """Support for Google Assistant Smart Home API.""" -import asyncio +import collections +from itertools import product import logging # Typing imports @@ -9,447 +10,222 @@ from aiohttp.web import Request, Response # NOQA from typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA -from homeassistant.util import color from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.decorator import Registry +from homeassistant.core import callback from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, - STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON, - TEMP_FAHRENHEIT, TEMP_CELSIUS, - CONF_NAME, CONF_TYPE -) + CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate, - sensor ) -from homeassistant.util.unit_system import METRIC_SYSTEM +from . import trait from .const import ( - COMMAND_COLOR, - COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, - COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE, - TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP, - TRAIT_RGB_COLOR, TRAIT_SCENE, TRAIT_TEMPERATURE_SETTING, TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT, - CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES, - CLIMATE_MODE_HEATCOOL + CONF_ALIASES, CONF_ROOM_HINT, + ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, + ERR_UNKNOWN_ERROR ) +from .helpers import SmartHomeError HANDLERS = Registry() -QUERY_HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -# Mapping is [actions schema, primary trait, optional features] -# optional is SUPPORT_* = (trait, command) -MAPPING_COMPONENT = { - group.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], - script.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], - switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - light.DOMAIN: [ - TYPE_LIGHT, TRAIT_ONOFF, { - light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS, - light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR, - light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP, +DOMAIN_TO_GOOGLE_TYPES = { + group.DOMAIN: TYPE_SWITCH, + scene.DOMAIN: TYPE_SCENE, + script.DOMAIN: TYPE_SCENE, + switch.DOMAIN: TYPE_SWITCH, + fan.DOMAIN: TYPE_SWITCH, + light.DOMAIN: TYPE_LIGHT, + cover.DOMAIN: TYPE_SWITCH, + media_player.DOMAIN: TYPE_SWITCH, + climate.DOMAIN: TYPE_THERMOSTAT, +} + + +def deep_update(target, source): + """Update a nested dictionary with another nested dictionary.""" + for key, value in source.items(): + if isinstance(value, collections.Mapping): + target[key] = deep_update(target.get(key, {}), value) + else: + target[key] = value + return target + + +class _GoogleEntity: + """Adaptation of Entity expressed in Google's terms.""" + + def __init__(self, hass, config, state): + self.hass = hass + self.config = config + self.state = state + + @property + def entity_id(self): + """Return entity ID.""" + return self.state.entity_id + + @callback + def traits(self): + """Return traits for entity.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + return [Trait(state) for Trait in trait.TRAITS + if Trait.supported(domain, features)] + + @callback + def sync_serialize(self): + """Serialize entity for a SYNC response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + traits = self.traits() + state = self.state + + # Found no supported traits for this entity + if not traits: + return None + + entity_config = self.config.entity_config.get(state.entity_id, {}) + + device = { + 'id': state.entity_id, + 'name': { + 'name': entity_config.get(CONF_NAME) or state.name + }, + 'attributes': {}, + 'traits': [trait.name for trait in traits], + 'willReportState': False, + 'type': DOMAIN_TO_GOOGLE_TYPES[state.domain], } - ], - cover.DOMAIN: [ - TYPE_SWITCH, TRAIT_ONOFF, { - cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS - } - ], - media_player.DOMAIN: [ - TYPE_SWITCH, TRAIT_ONOFF, { - media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS - } - ], - climate.DOMAIN: [TYPE_THERMOSTAT, TRAIT_TEMPERATURE_SETTING, None], -} # type: Dict[str, list] + + # use aliases + aliases = entity_config.get(CONF_ALIASES) + if aliases: + device['name']['nicknames'] = aliases + + # add room hint if annotated + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + + return device + + @callback + def query_serialize(self): + """Serialize entity for a QUERY response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ + state = self.state + + if state.state == STATE_UNAVAILABLE: + return {'online': False} + + attrs = {'online': True} + + for trt in self.traits(): + deep_update(attrs, trt.query_attributes()) + + return attrs + + async def execute(self, command, params): + """Execute a command. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + executed = False + for trt in self.traits(): + if trt.can_execute(command, params): + await trt.execute(self.hass, command, params) + executed = True + break + + if not executed: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + 'Unable to execute {} for {}'.format(command, + self.state.entity_id)) + + @callback + def async_update(self): + """Update the entity with latest info from Home Assistant.""" + self.state = self.hass.states.get(self.entity_id) -"""Error code used for SmartHomeError class.""" -ERROR_NOT_SUPPORTED = "notSupported" +async def async_handle_message(hass, config, message): + """Handle incoming API messages.""" + response = await _process(hass, config, message) - -class SmartHomeError(Exception): - """Google Assistant Smart Home errors.""" - - def __init__(self, code, msg): - """Log error code.""" - super(SmartHomeError, self).__init__(msg) - _LOGGER.error( - "An error has occurred in Google SmartHome: %s." - "Error code: %s", msg, code - ) - self.code = code - - -class Config: - """Hold the configuration for Google Assistant.""" - - def __init__(self, should_expose, agent_user_id, entity_config=None): - """Initialize the configuration.""" - self.should_expose = should_expose - self.agent_user_id = agent_user_id - self.entity_config = entity_config or {} - - -def entity_to_device(entity: Entity, config: Config, units: UnitSystem): - """Convert a hass entity into a google actions device.""" - entity_config = config.entity_config.get(entity.entity_id, {}) - google_domain = entity_config.get(CONF_TYPE) - class_data = MAPPING_COMPONENT.get( - google_domain or entity.domain) - - if class_data is None: - return None - - device = { - 'id': entity.entity_id, - 'name': {}, - 'attributes': {}, - 'traits': [], - 'willReportState': False, - } - device['type'] = class_data[0] - device['traits'].append(class_data[1]) - - # handle custom names - device['name']['name'] = entity_config.get(CONF_NAME) or entity.name - - # use aliases - aliases = entity_config.get(CONF_ALIASES) - if aliases: - device['name']['nicknames'] = aliases - - # add room hint if annotated - room = entity_config.get(CONF_ROOM_HINT) - if room: - device['roomHint'] = room - - # add trait if entity supports feature - if class_data[2]: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - for feature, trait in class_data[2].items(): - if feature & supported > 0: - device['traits'].append(trait) - - # Actions require this attributes for a device - # supporting temperature - # For IKEA trådfri, these attributes only seem to - # be set only if the device is on? - if trait == TRAIT_COLOR_TEMP: - if entity.attributes.get( - light.ATTR_MAX_MIREDS) is not None: - device['attributes']['temperatureMinK'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_MAX_MIREDS)))) - if entity.attributes.get( - light.ATTR_MIN_MIREDS) is not None: - device['attributes']['temperatureMaxK'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_MIN_MIREDS)))) - - if entity.domain == climate.DOMAIN: - modes = [] - for mode in entity.attributes.get(climate.ATTR_OPERATION_LIST, []): - if mode in CLIMATE_SUPPORTED_MODES: - modes.append(mode) - elif mode == climate.STATE_AUTO: - modes.append(CLIMATE_MODE_HEATCOOL) - - device['attributes'] = { - 'availableThermostatModes': ','.join(modes), - 'thermostatTemperatureUnit': - 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', - } - _LOGGER.debug('Thermostat attributes %s', device['attributes']) - - if entity.domain == sensor.DOMAIN: - if google_domain == climate.DOMAIN: - unit_of_measurement = entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, - units.temperature_unit - ) - - device['attributes'] = { - 'thermostatTemperatureUnit': - 'F' if unit_of_measurement == TEMP_FAHRENHEIT else 'C', - } - _LOGGER.debug('Sensor attributes %s', device['attributes']) - - return device - - -def celsius(deg: Optional[float], units: UnitSystem) -> Optional[float]: - """Convert a float to Celsius and rounds to one decimal place.""" - if deg is None: - return None - return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) - - -@QUERY_HANDLERS.register(sensor.DOMAIN) -def query_response_sensor( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a sensor entity to a QUERY response.""" - entity_config = config.entity_config.get(entity.entity_id, {}) - google_domain = entity_config.get(CONF_TYPE) - - if google_domain != climate.DOMAIN: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Sensor type {} is not supported".format(google_domain) - ) - - # check if we have a string value to convert it to number - value = entity.state - if isinstance(entity.state, str): - try: - value = float(value) - except ValueError: - value = None - - if value is None: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Invalid value {} for the climate sensor" - .format(entity.state) - ) - - # detect if we report temperature or humidity - unit_of_measurement = entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, - units.temperature_unit - ) - if unit_of_measurement in [TEMP_FAHRENHEIT, TEMP_CELSIUS]: - value = celsius(value, units) - attr = 'thermostatTemperatureAmbient' - elif unit_of_measurement == '%': - attr = 'thermostatHumidityAmbient' - else: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Unit {} is not supported by the climate sensor" - .format(unit_of_measurement) - ) - - return {attr: value} - - -@QUERY_HANDLERS.register(climate.DOMAIN) -def query_response_climate( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a climate entity to a QUERY response.""" - mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) - if mode is None: - mode = entity.state - mode = mode.lower() - if mode not in CLIMATE_SUPPORTED_MODES: - mode = 'heat' - attrs = entity.attributes - response = { - 'thermostatMode': mode, - 'thermostatTemperatureSetpoint': - celsius(attrs.get(climate.ATTR_TEMPERATURE), units), - 'thermostatTemperatureAmbient': - celsius(attrs.get(climate.ATTR_CURRENT_TEMPERATURE), units), - 'thermostatTemperatureSetpointHigh': - celsius(attrs.get(climate.ATTR_TARGET_TEMP_HIGH), units), - 'thermostatTemperatureSetpointLow': - celsius(attrs.get(climate.ATTR_TARGET_TEMP_LOW), units), - 'thermostatHumidityAmbient': - attrs.get(climate.ATTR_CURRENT_HUMIDITY), - } - return {k: v for k, v in response.items() if v is not None} - - -@QUERY_HANDLERS.register(media_player.DOMAIN) -def query_response_media_player( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a media_player entity to a QUERY response.""" - level = entity.attributes.get( - media_player.ATTR_MEDIA_VOLUME_LEVEL, - 1.0 if entity.state != STATE_OFF else 0.0) - # Convert 0.0-1.0 to 0-255 - brightness = int(level * 100) - - return {'brightness': brightness} - - -@QUERY_HANDLERS.register(light.DOMAIN) -def query_response_light( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a light entity to a QUERY response.""" - response = {} # type: Dict[str, Any] - - brightness = entity.attributes.get(light.ATTR_BRIGHTNESS) - if brightness is not None: - response['brightness'] = int(100 * (brightness / 255)) - - supported_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported_features & \ - (light.SUPPORT_COLOR_TEMP | light.SUPPORT_RGB_COLOR): - response['color'] = {} - - if entity.attributes.get(light.ATTR_COLOR_TEMP) is not None: - response['color']['temperature'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_COLOR_TEMP)))) - - if entity.attributes.get(light.ATTR_COLOR_NAME) is not None: - response['color']['name'] = \ - entity.attributes.get(light.ATTR_COLOR_NAME) - - if entity.attributes.get(light.ATTR_RGB_COLOR) is not None: - color_rgb = entity.attributes.get(light.ATTR_RGB_COLOR) - if color_rgb is not None: - response['color']['spectrumRGB'] = \ - int(color.color_rgb_to_hex( - color_rgb[0], color_rgb[1], color_rgb[2]), 16) + if 'errorCode' in response['payload']: + _LOGGER.error('Error handling message %s: %s', + message, response['payload']) return response -def query_device(entity: Entity, config: Config, units: UnitSystem) -> dict: - """Take an entity and return a properly formatted device object.""" - state = entity.state != STATE_OFF - defaults = { - 'on': state, - 'online': True - } - - handler = QUERY_HANDLERS.get(entity.domain) - if callable(handler): - defaults.update(handler(entity, config, units)) - - return defaults - - -# erroneous bug on old pythons and pylint -# https://github.com/PyCQA/pylint/issues/1212 -# pylint: disable=invalid-sequence-index -def determine_service( - entity_id: str, command: str, params: dict, - units: UnitSystem) -> Tuple[str, dict]: - """ - Determine service and service_data. - - Attempt to return a tuple of service and service_data based on the entity - and action requested. - """ - _LOGGER.debug("Handling command %s with data %s", command, params) - domain = entity_id.split('.')[0] - service_data = {ATTR_ENTITY_ID: entity_id} # type: Dict[str, Any] - # special media_player handling - if domain == media_player.DOMAIN and command == COMMAND_BRIGHTNESS: - brightness = params.get('brightness', 0) - service_data[media_player.ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100 - return (media_player.SERVICE_VOLUME_SET, service_data) - - # special cover handling - if domain == cover.DOMAIN: - if command == COMMAND_BRIGHTNESS: - service_data['position'] = params.get('brightness', 0) - return (cover.SERVICE_SET_COVER_POSITION, service_data) - if command == COMMAND_ONOFF and params.get('on') is True: - return (cover.SERVICE_OPEN_COVER, service_data) - return (cover.SERVICE_CLOSE_COVER, service_data) - - # special climate handling - if domain == climate.DOMAIN: - if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - service_data['temperature'] = \ - units.temperature( - params['thermostatTemperatureSetpoint'], TEMP_CELSIUS) - return (climate.SERVICE_SET_TEMPERATURE, service_data) - if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: - service_data['target_temp_high'] = units.temperature( - params.get('thermostatTemperatureSetpointHigh', 25), - TEMP_CELSIUS) - service_data['target_temp_low'] = units.temperature( - params.get('thermostatTemperatureSetpointLow', 18), - TEMP_CELSIUS) - return (climate.SERVICE_SET_TEMPERATURE, service_data) - if command == COMMAND_THERMOSTAT_SET_MODE: - mode = params['thermostatMode'] - - if mode == CLIMATE_MODE_HEATCOOL: - mode = climate.STATE_AUTO - - service_data['operation_mode'] = mode - return (climate.SERVICE_SET_OPERATION_MODE, service_data) - - if command == COMMAND_BRIGHTNESS: - brightness = params.get('brightness') - service_data['brightness'] = int(brightness / 100 * 255) - return (SERVICE_TURN_ON, service_data) - - if command == COMMAND_COLOR: - color_data = params.get('color') - if color_data is not None: - if color_data.get('temperature', 0) > 0: - service_data[light.ATTR_KELVIN] = color_data.get('temperature') - return (SERVICE_TURN_ON, service_data) - if color_data.get('spectrumRGB', 0) > 0: - # blue is 255 so pad up to 6 chars - hex_value = \ - ('%0x' % int(color_data.get('spectrumRGB'))).zfill(6) - service_data[light.ATTR_RGB_COLOR] = \ - color.rgb_hex_to_rgb_list(hex_value) - return (SERVICE_TURN_ON, service_data) - - if command == COMMAND_ACTIVATESCENE: - return (SERVICE_TURN_ON, service_data) - - if COMMAND_ONOFF == command: - if params.get('on') is True: - return (SERVICE_TURN_ON, service_data) - return (SERVICE_TURN_OFF, service_data) - - return (None, service_data) - - -@asyncio.coroutine -def async_handle_message(hass, config, message): - """Handle incoming API messages.""" +async def _process(hass, config, message): + """Process a message.""" request_id = message.get('requestId') # type: str inputs = message.get('inputs') # type: list - if len(inputs) > 1: - _LOGGER.warning('Got unexpected more than 1 input. %s', message) + if len(inputs) != 1: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } - # Only use first input - intent = inputs[0].get('intent') - payload = inputs[0].get('payload') + handler = HANDLERS.get(inputs[0].get('intent')) - handler = HANDLERS.get(intent) + if handler is None: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } - if handler: - result = yield from handler(hass, config, payload) - else: - result = {'errorCode': 'protocolError'} - - return {'requestId': request_id, 'payload': result} + try: + result = await handler(hass, config, inputs[0].get('payload')) + return {'requestId': request_id, 'payload': result} + except SmartHomeError as err: + return { + 'requestId': request_id, + 'payload': {'errorCode': err.code} + } + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception('Unexpected error') + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_UNKNOWN_ERROR} + } @HANDLERS.register('action.devices.SYNC') -@asyncio.coroutine -def async_devices_sync(hass, config: Config, payload): - """Handle action.devices.SYNC request.""" +async def async_devices_sync(hass, config, payload): + """Handle action.devices.SYNC request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ devices = [] - for entity in hass.states.async_all(): - if not config.should_expose(entity): + for state in hass.states.async_all(): + if not config.should_expose(state): continue - device = entity_to_device(entity, config, hass.config.units) - if device is None: - _LOGGER.warning("No mapping for %s domain", entity.domain) + entity = _GoogleEntity(hass, config, state) + serialized = entity.sync_serialize() + + if serialized is None: + _LOGGER.debug("No mapping for %s domain", entity.state) continue - devices.append(device) + devices.append(serialized) return { 'agentUserId': config.agent_user_id, @@ -458,53 +234,79 @@ def async_devices_sync(hass, config: Config, payload): @HANDLERS.register('action.devices.QUERY') -@asyncio.coroutine -def async_devices_query(hass, config, payload): - """Handle action.devices.QUERY request.""" +async def async_devices_query(hass, config, payload): + """Handle action.devices.QUERY request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ devices = {} for device in payload.get('devices', []): - devid = device.get('id') - # In theory this should never happen - if not devid: - _LOGGER.error('Device missing ID: %s', device) - continue - + devid = device['id'] state = hass.states.get(devid) + if not state: # If we can't find a state, the device is offline devices[devid] = {'online': False} - else: - try: - devices[devid] = query_device(state, config, hass.config.units) - except SmartHomeError as error: - devices[devid] = {'errorCode': error.code} + continue + + devices[devid] = _GoogleEntity(hass, config, state).query_serialize() return {'devices': devices} @HANDLERS.register('action.devices.EXECUTE') -@asyncio.coroutine -def handle_devices_execute(hass, config, payload): - """Handle action.devices.EXECUTE request.""" - commands = [] - for command in payload.get('commands', []): - ent_ids = [ent.get('id') for ent in command.get('devices', [])] - for execution in command.get('execution'): - for eid in ent_ids: - success = False - domain = eid.split('.')[0] - (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params'), - hass.config.units) - if domain == "group": - domain = "homeassistant" - success = yield from hass.services.async_call( - domain, service, service_data, blocking=True) - result = {"ids": [eid], "states": {}} - if success: - result['status'] = 'SUCCESS' - else: - result['status'] = 'ERROR' - commands.append(result) +async def handle_devices_execute(hass, config, payload): + """Handle action.devices.EXECUTE request. - return {'commands': commands} + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + entities = {} + results = {} + + for command in payload['commands']: + for device, execution in product(command['devices'], + command['execution']): + entity_id = device['id'] + + # Happens if error occurred. Skip entity for further processing + if entity_id in results: + continue + + if entity_id not in entities: + state = hass.states.get(entity_id) + + if state is None: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': ERR_DEVICE_OFFLINE + } + continue + + entities[entity_id] = _GoogleEntity(hass, config, state) + + try: + await entities[entity_id].execute(execution['command'], + execution.get('params', {})) + except SmartHomeError as err: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': err.code + } + + final_results = list(results.values()) + + for entity in entities.values(): + if entity.entity_id in results: + continue + + entity.async_update() + + final_results.append({ + 'ids': [entity.entity_id], + 'status': 'SUCCESS', + 'states': entity.query_serialize(), + }) + + return {'commands': final_results} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py new file mode 100644 index 00000000000..e0edc017ed3 --- /dev/null +++ b/homeassistant/components/google_assistant/trait.py @@ -0,0 +1,510 @@ +"""Implement the Smart Home traits.""" +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + group, + fan, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util import color as color_util, temperature as temp_util + +from .const import ERR_VALUE_OUT_OF_RANGE +from .helpers import SmartHomeError + +PREFIX_TRAITS = 'action.devices.traits.' +TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' +TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' +TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum' +TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' +TRAIT_SCENE = PREFIX_TRAITS + 'Scene' +TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' + +PREFIX_COMMANDS = 'action.devices.commands.' +COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' +COMMAND_BRIGHTNESS_ABSOLUTE = PREFIX_COMMANDS + 'BrightnessAbsolute' +COMMAND_COLOR_ABSOLUTE = PREFIX_COMMANDS + 'ColorAbsolute' +COMMAND_ACTIVATE_SCENE = PREFIX_COMMANDS + 'ActivateScene' +COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') +COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') +COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' + + +TRAITS = [] + + +def register_trait(trait): + """Decorator to register a trait.""" + TRAITS.append(trait) + return trait + + +def _google_temp_unit(state): + """Return Google temperature unit.""" + if (state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == + TEMP_FAHRENHEIT): + return 'F' + return 'C' + + +class _Trait: + """Represents a Trait inside Google Assistant skill.""" + + commands = [] + + def __init__(self, state): + """Initialize a trait for a state.""" + self.state = state + + def sync_attributes(self): + """Return attributes for a sync request.""" + raise NotImplementedError + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + raise NotImplementedError + + def can_execute(self, command, params): + """Test if command can be executed.""" + return command in self.commands + + async def execute(self, hass, command, params): + """Execute a trait command.""" + raise NotImplementedError + + +@register_trait +class BrightnessTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/brightness + """ + + name = TRAIT_BRIGHTNESS + commands = [ + COMMAND_BRIGHTNESS_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain == light.DOMAIN: + return features & light.SUPPORT_BRIGHTNESS + elif domain == cover.DOMAIN: + return features & cover.SUPPORT_SET_POSITION + elif domain == media_player.DOMAIN: + return features & media_player.SUPPORT_VOLUME_SET + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + domain = self.state.domain + response = {} + + if domain == light.DOMAIN: + brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) + if brightness is not None: + response['brightness'] = int(100 * (brightness / 255)) + + elif domain == cover.DOMAIN: + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + if position is not None: + response['brightness'] = position + + elif domain == media_player.DOMAIN: + level = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + if level is not None: + # Convert 0.0-1.0 to 0-255 + response['brightness'] = int(level * 100) + + return response + + async def execute(self, hass, command, params): + """Execute a brightness command.""" + domain = self.state.domain + + if domain == light.DOMAIN: + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_BRIGHTNESS_PCT: params['brightness'] + }, blocking=True) + elif domain == cover.DOMAIN: + await hass.services.async_call( + cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { + ATTR_ENTITY_ID: self.state.entity_id, + cover.ATTR_POSITION: params['brightness'] + }, blocking=True) + elif domain == media_player.DOMAIN: + await hass.services.async_call( + media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + params['brightness'] / 100 + }, blocking=True) + + +@register_trait +class OnOffTrait(_Trait): + """Trait to offer basic on and off functionality. + + https://developers.google.com/actions/smarthome/traits/onoff + """ + + name = TRAIT_ONOFF + commands = [ + COMMAND_ONOFF + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in ( + group.DOMAIN, + switch.DOMAIN, + fan.DOMAIN, + light.DOMAIN, + cover.DOMAIN, + media_player.DOMAIN, + ) + + def sync_attributes(self): + """Return OnOff attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return OnOff query attributes.""" + if self.state.domain == cover.DOMAIN: + return {'on': self.state.state != cover.STATE_CLOSED} + return {'on': self.state.state != STATE_OFF} + + async def execute(self, hass, command, params): + """Execute an OnOff command.""" + domain = self.state.domain + + if domain == cover.DOMAIN: + service_domain = domain + if params['on']: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_CLOSE_COVER + + elif domain == group.DOMAIN: + service_domain = HA_DOMAIN + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + else: + service_domain = domain + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + await hass.services.async_call(service_domain, service, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=True) + + +@register_trait +class ColorSpectrumTrait(_Trait): + """Trait to offer color spectrum functionality. + + https://developers.google.com/actions/smarthome/traits/colorspectrum + """ + + name = TRAIT_COLOR_SPECTRUM + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & (light.SUPPORT_RGB_COLOR | light.SUPPORT_XY_COLOR) + + def sync_attributes(self): + """Return color spectrum attributes for a sync request.""" + # Other colorModel is hsv + return {'colorModel': 'rgb'} + + def query_attributes(self): + """Return color spectrum query attributes.""" + response = {} + + # No need to handle XY color because light component will always + # convert XY to RGB if possible (which is when brightness is available) + color_rgb = self.state.attributes.get(light.ATTR_RGB_COLOR) + if color_rgb is not None: + response['color'] = { + 'spectrumRGB': int(color_util.color_rgb_to_hex( + color_rgb[0], color_rgb[1], color_rgb[2]), 16), + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'spectrumRGB' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color spectrum command.""" + # Convert integer to hex format and left pad with 0's till length 6 + hex_value = "{0:06x}".format(params['color']['spectrumRGB']) + color = color_util.rgb_hex_to_rgb_list(hex_value) + + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_RGB_COLOR: color + }, blocking=True) + + +@register_trait +class ColorTemperatureTrait(_Trait): + """Trait to offer color temperature functionality. + + https://developers.google.com/actions/smarthome/traits/colortemperature + """ + + name = TRAIT_COLOR_TEMP + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & light.SUPPORT_COLOR_TEMP + + def sync_attributes(self): + """Return color temperature attributes for a sync request.""" + attrs = self.state.attributes + return { + 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MIN_MIREDS)), + 'temperatureMaxK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MAX_MIREDS)), + } + + def query_attributes(self): + """Return color temperature query attributes.""" + response = {} + + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + if temp is not None: + response['color'] = { + 'temperature': + color_util.color_temperature_mired_to_kelvin(temp) + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'temperature' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color temperature command.""" + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_KELVIN: params['color']['temperature'], + }, blocking=True) + + +@register_trait +class SceneTrait(_Trait): + """Trait to offer scene functionality. + + https://developers.google.com/actions/smarthome/traits/scene + """ + + name = TRAIT_SCENE + commands = [ + COMMAND_ACTIVATE_SCENE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in (scene.DOMAIN, script.DOMAIN) + + def sync_attributes(self): + """Return scene attributes for a sync request.""" + # Neither supported domain can support sceneReversible + return {} + + def query_attributes(self): + """Return scene query attributes.""" + return {} + + async def execute(self, hass, command, params): + """Execute a scene command.""" + # Don't block for scripts as they can be slow. + await hass.services.async_call(self.state.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=self.state.domain != script.DOMAIN) + + +@register_trait +class TemperatureSettingTrait(_Trait): + """Trait to offer handling both temperature point and modes functionality. + + https://developers.google.com/actions/smarthome/traits/temperaturesetting + """ + + name = TRAIT_TEMPERATURE_SETTING + commands = [ + COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + COMMAND_THERMOSTAT_SET_MODE, + ] + # We do not support "on" as we are unable to know how to restore + # the last mode. + hass_to_google = { + climate.STATE_HEAT: 'heat', + climate.STATE_COOL: 'cool', + climate.STATE_OFF: 'off', + climate.STATE_AUTO: 'heatcool', + } + google_to_hass = {value: key for key, value in hass_to_google.items()} + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != climate.DOMAIN: + return False + + return features & climate.SUPPORT_OPERATION_MODE + + def sync_attributes(self): + """Return temperature point and modes attributes for a sync request.""" + modes = [] + for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, []): + google_mode = self.hass_to_google.get(mode) + if google_mode is not None: + modes.append(google_mode) + + return { + 'availableThermostatModes': ','.join(modes), + 'thermostatTemperatureUnit': _google_temp_unit(self.state), + } + + def query_attributes(self): + """Return temperature point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + operation = attrs.get(climate.ATTR_OPERATION_MODE) + if operation is not None and operation in self.hass_to_google: + response['thermostatMode'] = self.hass_to_google[operation] + + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ + round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1) + + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response['thermostatHumidityAmbient'] = current_humidity + + if (operation == climate.STATE_AUTO and + climate.ATTR_TARGET_TEMP_HIGH in attrs and + climate.ATTR_TARGET_TEMP_LOW in attrs): + response['thermostatTemperatureSetpointHigh'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_HIGH], + unit, TEMP_CELSIUS), 1) + response['thermostatTemperatureSetpointLow'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_LOW], + unit, TEMP_CELSIUS), 1) + else: + target_temp = attrs.get(climate.ATTR_TEMPERATURE) + if target_temp is not None: + response['thermostatTemperatureSetpoint'] = round( + temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) + + return response + + async def execute(self, hass, command, params): + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + min_temp = self.state.attributes[climate.ATTR_MIN_TEMP] + max_temp = self.state.attributes[climate.ATTR_MAX_TEMP] + + if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: + temp = temp_util.convert(params['thermostatTemperatureSetpoint'], + TEMP_CELSIUS, unit) + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TEMPERATURE: temp + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: + temp_high = temp_util.convert( + params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, + unit) + + if temp_high < min_temp or temp_high > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Upper bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + temp_low = temp_util.convert( + params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + + if temp_low < min_temp or temp_low > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Lower bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TARGET_TEMP_HIGH: temp_high, + climate.ATTR_TARGET_TEMP_LOW: temp_low, + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_SET_MODE: + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_OPERATION_MODE: + self.google_to_hass[params['thermostatMode']], + }, blocking=True) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d7862f81975..a3a962a7e34 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -86,8 +86,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" PROP_TO_ATTR = { 'brightness': ATTR_BRIGHTNESS, 'color_temp': ATTR_COLOR_TEMP, - 'min_mireds': ATTR_MIN_MIREDS, - 'max_mireds': ATTR_MAX_MIREDS, 'rgb_color': ATTR_RGB_COLOR, 'xy_color': ATTR_XY_COLOR, 'white_value': ATTR_WHITE_VALUE, @@ -476,6 +474,10 @@ class Light(ToggleEntity): """Return optional state attributes.""" data = {} + if self.supported_features & SUPPORT_COLOR_TEMP: + data[ATTR_MIN_MIREDS] = self.min_mireds + data[ATTR_MAX_MIREDS] = self.max_mireds + if self.is_on: for prop, attr in PROP_TO_ATTR.items(): value = getattr(self, prop) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 9e4e912f314..2be7ad431cf 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -37,11 +37,13 @@ YOUTUBE_PLAYER_SUPPORT = \ MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ - SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK class AbstractDemoPlayer(MediaPlayerDevice): @@ -284,15 +286,7 @@ class DemoMusicPlayer(AbstractDemoPlayer): @property def supported_features(self): """Flag media player features that are supported.""" - support = MUSIC_PLAYER_SUPPORT - - if self._cur_track > 0: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_track < len(self.tracks) - 1: - support |= SUPPORT_NEXT_TRACK - - return support + return MUSIC_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" @@ -379,15 +373,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer): @property def supported_features(self): """Flag media player features that are supported.""" - support = NETFLIX_PLAYER_SUPPORT - - if self._cur_episode > 1: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_episode < self._episode_count: - support |= SUPPORT_NEXT_TRACK - - return support + return NETFLIX_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index d6a26ee37e0..f4ae81ad2f2 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -318,7 +318,6 @@ def test_handler_google_actions(hass): 'entity_config': { 'switch.test': { 'name': 'Config name', - 'type': 'light', 'aliases': 'Config alias' } } @@ -347,7 +346,7 @@ def test_handler_google_actions(hass): assert device['id'] == 'switch.test' assert device['name']['name'] == 'Config name' assert device['name']['nicknames'] == ['Config alias'] - assert device['type'] == 'action.devices.types.LIGHT' + assert device['type'] == 'action.devices.types.SWITCH' async def test_refresh_token_expired(hass): diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 022cf852b88..6c4dd713b32 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -36,7 +36,7 @@ DEMO_DEVICES = [{ 'traits': [ 'action.devices.traits.OnOff' ], - 'type': 'action.devices.types.LIGHT', # This is used for custom type + 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -230,20 +230,4 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.TemperatureSetting'], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False -}, { - 'id': 'sensor.outside_temperature', - 'name': { - 'name': 'Outside Temperature' - }, - 'traits': ['action.devices.traits.TemperatureSetting'], - 'type': 'action.devices.types.THERMOSTAT', - 'willReportState': False -}, { - 'id': 'sensor.outside_humidity', - 'name': { - 'name': 'Outside Humidity' - }, - 'traits': ['action.devices.traits.TemperatureSetting'], - 'type': 'action.devices.types.THERMOSTAT', - 'willReportState': False }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 43c36d1ca2a..cb319b67bb2 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -8,9 +8,8 @@ import pytest from homeassistant import core, const, setup from homeassistant.components import ( - fan, cover, light, switch, climate, async_setup, media_player, sensor) + fan, cover, light, switch, climate, async_setup, media_player) from homeassistant.components import google_assistant as ga -from homeassistant.util.unit_system import IMPERIAL_SYSTEM from . import DEMO_DEVICES @@ -41,17 +40,6 @@ def assistant_client(loop, hass, test_client): 'aliases': ['top lights', 'ceiling lights'], 'name': 'Roof Lights', }, - 'switch.decorative_lights': { - 'type': 'light' - }, - 'sensor.outside_humidity': { - 'type': 'climate', - 'expose': True - }, - 'sensor.outside_temperature': { - 'type': 'climate', - 'expose': True - } } } })) @@ -105,13 +93,6 @@ def hass_fixture(loop, hass): }] })) - loop.run_until_complete( - setup.async_setup_component(hass, sensor.DOMAIN, { - 'sensor': [{ - 'platform': 'demo' - }] - })) - return hass @@ -196,7 +177,6 @@ def test_query_request(hass_fixture, assistant_client): assert devices['light.kitchen_lights']['color']['spectrumRGB'] == 16727919 assert devices['light.kitchen_lights']['color']['temperature'] == 4166 assert devices['media_player.lounge_room']['on'] is True - assert devices['media_player.lounge_room']['brightness'] == 100 @asyncio.coroutine @@ -213,8 +193,6 @@ def test_query_climate_request(hass_fixture, assistant_client): {'id': 'climate.hvac'}, {'id': 'climate.heatpump'}, {'id': 'climate.ecobee'}, - {'id': 'sensor.outside_temperature'}, - {'id': 'sensor.outside_humidity'} ] } }] @@ -227,47 +205,39 @@ def test_query_climate_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert devices == { - 'climate.heatpump': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': 20.0, - 'thermostatTemperatureAmbient': 25.0, - 'thermostatMode': 'heat', - }, - 'climate.ecobee': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpointHigh': 24, - 'thermostatTemperatureAmbient': 23, - 'thermostatMode': 'heat', - 'thermostatTemperatureSetpointLow': 21 - }, - 'climate.hvac': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': 21, - 'thermostatTemperatureAmbient': 22, - 'thermostatMode': 'cool', - 'thermostatHumidityAmbient': 54, - }, - 'sensor.outside_temperature': { - 'on': True, - 'online': True, - 'thermostatTemperatureAmbient': 15.6 - }, - 'sensor.outside_humidity': { - 'on': True, - 'online': True, - 'thermostatHumidityAmbient': 54.0 - } + assert len(devices) == 3 + assert devices['climate.heatpump'] == { + 'online': True, + 'thermostatTemperatureSetpoint': 20.0, + 'thermostatTemperatureAmbient': 25.0, + 'thermostatMode': 'heat', + } + assert devices['climate.ecobee'] == { + 'online': True, + 'thermostatTemperatureSetpointHigh': 24, + 'thermostatTemperatureAmbient': 23, + 'thermostatMode': 'heatcool', + 'thermostatTemperatureSetpointLow': 21 + } + assert devices['climate.hvac'] == { + 'online': True, + 'thermostatTemperatureSetpoint': 21, + 'thermostatTemperatureAmbient': 22, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, } @asyncio.coroutine def test_query_climate_request_f(hass_fixture, assistant_client): """Test a query request.""" - hass_fixture.config.units = IMPERIAL_SYSTEM + # Mock demo devices as fahrenheit to see if we convert to celsius + for entity_id in ('climate.hvac', 'climate.heatpump', 'climate.ecobee'): + state = hass_fixture.states.get(entity_id) + attr = dict(state.attributes) + attr[const.ATTR_UNIT_OF_MEASUREMENT] = const.TEMP_FAHRENHEIT + hass_fixture.states.async_set(entity_id, state.state, attr) + reqid = '5711642932632160984' data = { 'requestId': @@ -279,7 +249,6 @@ def test_query_climate_request_f(hass_fixture, assistant_client): {'id': 'climate.hvac'}, {'id': 'climate.heatpump'}, {'id': 'climate.ecobee'}, - {'id': 'sensor.outside_temperature'} ] } }] @@ -292,35 +261,26 @@ def test_query_climate_request_f(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert devices == { - 'climate.heatpump': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': -6.7, - 'thermostatTemperatureAmbient': -3.9, - 'thermostatMode': 'heat', - }, - 'climate.ecobee': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpointHigh': -4.4, - 'thermostatTemperatureAmbient': -5, - 'thermostatMode': 'heat', - 'thermostatTemperatureSetpointLow': -6.1, - }, - 'climate.hvac': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': -6.1, - 'thermostatTemperatureAmbient': -5.6, - 'thermostatMode': 'cool', - 'thermostatHumidityAmbient': 54, - }, - 'sensor.outside_temperature': { - 'on': True, - 'online': True, - 'thermostatTemperatureAmbient': -9.1 - } + assert len(devices) == 3 + assert devices['climate.heatpump'] == { + 'online': True, + 'thermostatTemperatureSetpoint': -6.7, + 'thermostatTemperatureAmbient': -3.9, + 'thermostatMode': 'heat', + } + assert devices['climate.ecobee'] == { + 'online': True, + 'thermostatTemperatureSetpointHigh': -4.4, + 'thermostatTemperatureAmbient': -5, + 'thermostatMode': 'heatcool', + 'thermostatTemperatureSetpointLow': -6.1, + } + assert devices['climate.hvac'] == { + 'online': True, + 'thermostatTemperatureSetpoint': -6.1, + 'thermostatTemperatureAmbient': -5.6, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, } @@ -359,19 +319,6 @@ def test_execute_request(hass_fixture, assistant_client): "brightness": 70 } }] - }, { - "devices": [{ - "id": "light.kitchen_lights", - }], - "execution": [{ - "command": "action.devices.commands.ColorAbsolute", - "params": { - "color": { - "spectrumRGB": 16711680, - "temperature": 2100 - } - } - }] }, { "devices": [{ "id": "light.kitchen_lights", @@ -415,13 +362,14 @@ def test_execute_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid commands = body['payload']['commands'] - assert len(commands) == 8 + assert len(commands) == 6 + + assert not any(result['status'] == 'ERROR' for result in commands) ceiling = hass_fixture.states.get('light.ceiling_lights') assert ceiling.state == 'off' kitchen = hass_fixture.states.get('light.kitchen_lights') - assert kitchen.attributes.get(light.ATTR_COLOR_TEMP) == 476 assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) bed = hass_fixture.states.get('light.bed_light') diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index bb8f1b706e6..8d139fa8211 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,191 +1,246 @@ -"""The tests for the Google Actions component.""" -# pylint: disable=protected-access -import asyncio - -from homeassistant import const +"""Test Google Smart Home.""" +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.setup import async_setup_component from homeassistant.components import climate -from homeassistant.components import google_assistant as ga -from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) - -DETERMINE_SERVICE_TESTS = [{ # Test light brightness - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 95 - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'brightness': 242} - ) -}, { # Test light color temperature - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'temperature': 2300, - 'name': 'warm white' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'kelvin': 2300} - ) -}, { # Test light color blue - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'spectrumRGB': 255, - 'name': 'blue' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'rgb_color': [0, 0, 255]} - ) -}, { # Test light color yellow - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'spectrumRGB': 16776960, - 'name': 'yellow' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'rgb_color': [255, 255, 0]} - ) -}, { # Test unhandled action/service - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'unhandled': 2300 - } - }, - 'expected': ( - None, - {'entity_id': 'light.test'} - ) -}, { # Test switch to light custom type - 'entity_id': 'switch.decorative_lights', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'switch.decorative_lights'} - ) -}, { # Test light on / off - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': False - }, - 'expected': (const.SERVICE_TURN_OFF, {'entity_id': 'light.test'}) -}, { - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': (const.SERVICE_TURN_ON, {'entity_id': 'light.test'}) -}, { # Test Cover open close - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': (const.SERVICE_OPEN_COVER, {'entity_id': 'cover.bedroom'}), -}, { - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': False - }, - 'expected': (const.SERVICE_CLOSE_COVER, {'entity_id': 'cover.bedroom'}), -}, { # Test cover position - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 50 - }, - 'expected': ( - const.SERVICE_SET_COVER_POSITION, - {'entity_id': 'cover.bedroom', 'position': 50} - ), -}, { # Test media_player volume - 'entity_id': 'media_player.living_room', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 30 - }, - 'expected': ( - const.SERVICE_VOLUME_SET, - {'entity_id': 'media_player.living_room', 'volume_level': 0.3} - ), -}, { # Test climate temperature - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - 'params': {'thermostatTemperatureSetpoint': 24.5}, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', 'temperature': 24.5} - ), -}, { # Test climate temperature Fahrenheit - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - 'params': {'thermostatTemperatureSetpoint': 24.5}, - 'units': IMPERIAL_SYSTEM, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', 'temperature': 76.1} - ), -}, { # Test climate temperature range - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, - 'params': { - 'thermostatTemperatureSetpointHigh': 24.5, - 'thermostatTemperatureSetpointLow': 20.5, - }, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', - 'target_temp_high': 24.5, 'target_temp_low': 20.5} - ), -}, { # Test climate temperature range Fahrenheit - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, - 'params': { - 'thermostatTemperatureSetpointHigh': 24.5, - 'thermostatTemperatureSetpointLow': 20.5, - }, - 'units': IMPERIAL_SYSTEM, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', - 'target_temp_high': 76.1, 'target_temp_low': 68.9} - ), -}, { # Test climate operation mode - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, - 'params': {'thermostatMode': 'heat'}, - 'expected': ( - climate.SERVICE_SET_OPERATION_MODE, - {'entity_id': 'climate.living_room', 'operation_mode': 'heat'} - ), -}] +from homeassistant.components.google_assistant import ( + const, trait, helpers, smart_home as sh) +from homeassistant.components.light.demo import DemoLight -@asyncio.coroutine -def test_determine_service(): - """Test all branches of determine service.""" - for test in DETERMINE_SERVICE_TESTS: - result = ga.smart_home.determine_service( - test['entity_id'], - test['command'], - test['params'], - test.get('units', METRIC_SYSTEM)) - assert result == test['expected'] +BASIC_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', +) +REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' + + +async def test_sync_message(hass): + """Test a sync message.""" + light = DemoLight( + None, 'Demo Light', + state=False, + rgb=[237, 224, 33] + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + # This should not show up in the sync request + hass.states.async_set('sensor.no_match', 'something') + + # Excluded via config + hass.states.async_set('light.not_expose', 'on') + + config = helpers.Config( + should_expose=lambda state: state.entity_id != 'light.not_expose', + agent_user_id='test-agent', + entity_config={ + 'light.demo_light': { + const.CONF_ROOM_HINT: 'Living Room', + const.CONF_ALIASES: ['Hello', 'World'] + } + } + ) + + result = await sh.async_handle_message(hass, config, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'id': 'light.demo_light', + 'name': { + 'name': 'Demo Light', + 'nicknames': [ + 'Hello', + 'World', + ] + }, + 'traits': [ + trait.TRAIT_BRIGHTNESS, + trait.TRAIT_ONOFF, + trait.TRAIT_COLOR_SPECTRUM, + trait.TRAIT_COLOR_TEMP, + ], + 'type': sh.TYPE_LIGHT, + 'willReportState': False, + 'attributes': { + 'colorModel': 'rgb', + 'temperatureMinK': 6493, + 'temperatureMaxK': 2000, + }, + 'roomHint': 'Living Room' + }] + } + } + + +async def test_query_message(hass): + """Test a sync message.""" + light = DemoLight( + None, 'Demo Light', + state=False, + rgb=[237, 224, 33] + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + light2 = DemoLight( + None, 'Another Light', + state=True, + rgb=[237, 224, 33], + ct=400, + brightness=78, + ) + light2.hass = hass + light2.entity_id = 'light.another_light' + await light2.async_update_ha_state() + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.QUERY", + "payload": { + "devices": [{ + "id": "light.demo_light", + }, { + "id": "light.another_light", + }, { + "id": "light.non_existing", + }] + } + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'devices': { + 'light.non_existing': { + 'online': False, + }, + 'light.demo_light': { + 'on': False, + 'online': True, + }, + 'light.another_light': { + 'on': True, + 'online': True, + 'brightness': 30, + 'color': { + 'spectrumRGB': 15589409, + 'temperature': 2500, + } + }, + } + } + } + + +async def test_execute(hass): + """Test an execute command.""" + await async_setup_component(hass, 'light', { + 'light': {'platform': 'demo'} + }) + await hass.services.async_call( + 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'}, + blocking=True) + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + ], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": True + } + }, { + "command": + "action.devices.commands.BrightnessAbsolute", + "params": { + "brightness": 20 + } + }] + }] + } + }] + }) + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [{ + "ids": ['light.non_existing'], + "status": "ERROR", + "errorCode": "deviceOffline" + }, { + "ids": ['light.ceiling_lights'], + "status": "SUCCESS", + "states": { + "on": True, + "online": True, + 'brightness': 20, + 'color': { + 'spectrumRGB': 15589409, + 'temperature': 2631, + }, + } + }] + } + } + + +async def test_raising_error_trait(hass): + """Test raising an error while executing a trait command.""" + hass.states.async_set('climate.bla', climate.STATE_HEAT, { + climate.ATTR_MIN_TEMP: 15, + climate.ATTR_MAX_TEMP: 30, + ATTR_SUPPORTED_FEATURES: climate.SUPPORT_OPERATION_MODE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + }) + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "climate.bla"}, + ], + "execution": [{ + "command": "action.devices.commands." + "ThermostatTemperatureSetpoint", + "params": { + "thermostatTemperatureSetpoint": 10 + } + }] + }] + } + }] + }) + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [{ + "ids": ['climate.bla'], + "status": "ERROR", + "errorCode": "valueOutOfRange" + }] + } + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py new file mode 100644 index 00000000000..512c9612e60 --- /dev/null +++ b/tests/components/google_assistant/test_trait.py @@ -0,0 +1,569 @@ +"""Tests for the Google Assistant traits.""" +import pytest + +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + fan, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.components.google_assistant import trait, helpers + +from tests.common import async_mock_service + + +async def test_brightness_light(hass): + """Test brightness trait support for light domain.""" + assert trait.BrightnessTrait.supported(light.DOMAIN, + light.SUPPORT_BRIGHTNESS) + + trt = trait.BrightnessTrait(State('light.bla', light.STATE_ON, { + light.ATTR_BRIGHTNESS: 243 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 95 + } + + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 50 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_BRIGHTNESS_PCT: 50 + } + + +async def test_brightness_cover(hass): + """Test brightness trait support for cover domain.""" + assert trait.BrightnessTrait.supported(cover.DOMAIN, + cover.SUPPORT_SET_POSITION) + + trt = trait.BrightnessTrait(State('cover.bla', cover.STATE_OPEN, { + cover.ATTR_CURRENT_POSITION: 75 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 75 + } + + calls = async_mock_service( + hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 50 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + cover.ATTR_POSITION: 50 + } + + +async def test_brightness_media_player(hass): + """Test brightness trait support for media player domain.""" + assert trait.BrightnessTrait.supported(media_player.DOMAIN, + media_player.SUPPORT_VOLUME_SET) + + trt = trait.BrightnessTrait(State( + 'media_player.bla', media_player.STATE_PLAYING, { + media_player.ATTR_MEDIA_VOLUME_LEVEL: .3 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 30 + } + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 60 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + media_player.ATTR_MEDIA_VOLUME_LEVEL: .6 + } + + +async def test_onoff_group(hass): + """Test OnOff trait support for group domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('group.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('group.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'group.bla', + } + + off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'group.bla', + } + + +async def test_onoff_switch(hass): + """Test OnOff trait support for switch domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('switch.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('switch.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'switch.bla', + } + + off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'switch.bla', + } + + +async def test_onoff_fan(hass): + """Test OnOff trait support for fan domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('fan.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('fan.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'fan.bla', + } + + off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'fan.bla', + } + + +async def test_onoff_light(hass): + """Test OnOff trait support for light domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('light.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('light.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + } + + off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + } + + +async def test_onoff_cover(hass): + """Test OnOff trait support for cover domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('cover.bla', cover.STATE_OPEN)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('cover.bla', cover.STATE_CLOSED)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + } + + off_calls = async_mock_service(hass, cover.DOMAIN, + cover.SERVICE_CLOSE_COVER) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + } + + +async def test_onoff_media_player(hass): + """Test OnOff trait support for media_player domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('media_player.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('media_player.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + } + + off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + } + + +async def test_color_spectrum_light(hass): + """Test ColorSpectrum trait support for light domain.""" + assert not trait.ColorSpectrumTrait.supported(light.DOMAIN, 0) + assert trait.ColorSpectrumTrait.supported(light.DOMAIN, + light.SUPPORT_RGB_COLOR) + assert trait.ColorSpectrumTrait.supported(light.DOMAIN, + light.SUPPORT_XY_COLOR) + + trt = trait.ColorSpectrumTrait(State('light.bla', STATE_ON, { + light.ATTR_RGB_COLOR: [255, 10, 10] + })) + + assert trt.sync_attributes() == { + 'colorModel': 'rgb' + } + + assert trt.query_attributes() == { + 'color': { + 'spectrumRGB': 16714250 + } + } + + assert not trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 400 + } + }) + assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 16715792 + } + }) + + calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 1052927 + } + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_RGB_COLOR: [16, 16, 255] + } + + +async def test_color_temperature_light(hass): + """Test ColorTemperature trait support for light domain.""" + assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0) + assert trait.ColorTemperatureTrait.supported(light.DOMAIN, + light.SUPPORT_COLOR_TEMP) + + trt = trait.ColorTemperatureTrait(State('light.bla', STATE_ON, { + light.ATTR_MIN_MIREDS: 200, + light.ATTR_COLOR_TEMP: 300, + light.ATTR_MAX_MIREDS: 500, + })) + + assert trt.sync_attributes() == { + 'temperatureMinK': 5000, + 'temperatureMaxK': 2000, + } + + assert trt.query_attributes() == { + 'color': { + 'temperature': 3333 + } + } + + assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 400 + } + }) + assert not trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 16715792 + } + }) + + calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 2857 + } + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_KELVIN: 2857 + } + + +async def test_scene_scene(hass): + """Test Scene trait support for scene domain.""" + assert trait.SceneTrait.supported(scene.DOMAIN, 0) + + trt = trait.SceneTrait(State('scene.bla', scene.STATE)) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_ACTIVATE_SCENE, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'scene.bla', + } + + +async def test_scene_script(hass): + """Test Scene trait support for script domain.""" + assert trait.SceneTrait.supported(script.DOMAIN, 0) + + trt = trait.SceneTrait(State('script.bla', STATE_OFF)) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_ACTIVATE_SCENE, {}) + + # We don't wait till script execution is done. + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'script.bla', + } + + +async def test_temperature_setting_climate_range(hass): + """Test TemperatureSetting trait support for climate domain - range.""" + assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0) + assert trait.TemperatureSettingTrait.supported( + climate.DOMAIN, climate.SUPPORT_OPERATION_MODE) + + trt = trait.TemperatureSettingTrait(State( + 'climate.bla', climate.STATE_AUTO, { + climate.ATTR_CURRENT_TEMPERATURE: 70, + climate.ATTR_CURRENT_HUMIDITY: 25, + climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, + climate.ATTR_OPERATION_LIST: [ + climate.STATE_OFF, + climate.STATE_COOL, + climate.STATE_HEAT, + climate.STATE_AUTO, + ], + climate.ATTR_TARGET_TEMP_HIGH: 75, + climate.ATTR_TARGET_TEMP_LOW: 65, + climate.ATTR_MIN_TEMP: 50, + climate.ATTR_MAX_TEMP: 80, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, + })) + assert trt.sync_attributes() == { + 'availableThermostatModes': 'off,cool,heat,heatcool', + 'thermostatTemperatureUnit': 'F', + } + assert trt.query_attributes() == { + 'thermostatMode': 'heatcool', + 'thermostatTemperatureAmbient': 21.1, + 'thermostatHumidityAmbient': 25, + 'thermostatTemperatureSetpointLow': 18.3, + 'thermostatTemperatureSetpointHigh': 23.9, + } + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + await trt.execute(hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, { + 'thermostatTemperatureSetpointHigh': 25, + 'thermostatTemperatureSetpointLow': 20, + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_TARGET_TEMP_HIGH: 77, + climate.ATTR_TARGET_TEMP_LOW: 68, + } + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) + await trt.execute(hass, trait.COMMAND_THERMOSTAT_SET_MODE, { + 'thermostatMode': 'heatcool', + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, + } + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': -100, + }) + + +async def test_temperature_setting_climate_setpoint(hass): + """Test TemperatureSetting trait support for climate domain - setpoint.""" + assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0) + assert trait.TemperatureSettingTrait.supported( + climate.DOMAIN, climate.SUPPORT_OPERATION_MODE) + + trt = trait.TemperatureSettingTrait(State( + 'climate.bla', climate.STATE_AUTO, { + climate.ATTR_OPERATION_MODE: climate.STATE_COOL, + climate.ATTR_OPERATION_LIST: [ + climate.STATE_OFF, + climate.STATE_COOL, + ], + climate.ATTR_MIN_TEMP: 10, + climate.ATTR_MAX_TEMP: 30, + climate.ATTR_TEMPERATURE: 18, + climate.ATTR_CURRENT_TEMPERATURE: 20, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + })) + assert trt.sync_attributes() == { + 'availableThermostatModes': 'off,cool', + 'thermostatTemperatureUnit': 'C', + } + assert trt.query_attributes() == { + 'thermostatMode': 'cool', + 'thermostatTemperatureAmbient': 20, + 'thermostatTemperatureSetpoint': 18, + } + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': -100, + }) + + await trt.execute(hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': 19, + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_TEMPERATURE: 19 + } diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index a798c5f3987..65ca2eb6a01 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -154,29 +154,21 @@ class TestDemoMediaPlayer(unittest.TestCase): {'media_player': {'platform': 'demo'}}) state = self.hass.states.get(entity_id) assert 1 == state.attributes.get('media_track') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 2 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 3 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_previous_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 2 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) assert setup_component( self.hass, mp.DOMAIN, @@ -184,22 +176,16 @@ class TestDemoMediaPlayer(unittest.TestCase): ent_id = 'media_player.lounge_room' state = self.hass.states.get(ent_id) assert 1 == state.attributes.get('media_episode') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 2 == state.attributes.get('media_episode') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_previous_track(self.hass, ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 1 == state.attributes.get('media_episode') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.' 'media_seek', autospec=True) From 6be81feb2df27f68b3965b8746aba183218c0883 Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Thu, 8 Mar 2018 18:23:51 -0500 Subject: [PATCH 174/191] Bump pyEmby version to support aiohttp => 3 (#12986) --- homeassistant/components/media_player/emby.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index e363ab12f92..7b5658c56d9 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -21,7 +21,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyemby==1.4'] +REQUIREMENTS = ['pyemby==1.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4eae1e2688f..25fce334ccf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -719,7 +719,7 @@ pyedimax==0.1 pyeight==0.0.7 # homeassistant.components.media_player.emby -pyemby==1.4 +pyemby==1.5 # homeassistant.components.envisalink pyenvisalink==2.2 From ab397e2b1a2882c329df38921d7cc1b5dc1981cb Mon Sep 17 00:00:00 2001 From: koolsb <14332595+koolsb@users.noreply.github.com> Date: Thu, 8 Mar 2018 17:24:28 -0600 Subject: [PATCH 175/191] Update pyalarmdotcom version (#12987) * Update pyalarmdotcom version * Update pyalarmdotcom version --- homeassistant/components/alarm_control_panel/alarmdotcom.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index ceb815f11a0..0e96e6448ff 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyalarmdotcom==0.3.0'] +REQUIREMENTS = ['pyalarmdotcom==0.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 25fce334ccf..e845aa05d9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -654,7 +654,7 @@ pyads==2.2.6 pyairvisual==1.0.0 # homeassistant.components.alarm_control_panel.alarmdotcom -pyalarmdotcom==0.3.0 +pyalarmdotcom==0.3.1 # homeassistant.components.arlo pyarlo==0.1.2 From 6d7dbe55368c5a1c40060e287f32f81a969ba342 Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Thu, 8 Mar 2018 23:25:10 +0000 Subject: [PATCH 176/191] Show the error message when Zabbix fails to log in (#12985) * Show the error message when Zabbix fails to log in * More verbose name for exception variable --- homeassistant/components/zabbix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zabbix.py b/homeassistant/components/zabbix.py index adbf34a474c..ea5a6d85d6b 100644 --- a/homeassistant/components/zabbix.py +++ b/homeassistant/components/zabbix.py @@ -52,8 +52,8 @@ def setup(hass, config): try: zapi.login(username, password) _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version()) - except ZabbixAPIException: - _LOGGER.error("Unable to login to the Zabbix API") + except ZabbixAPIException as login_exception: + _LOGGER.error("Unable to login to the Zabbix API: %s", login_exception) return False hass.data[DOMAIN] = zapi From eaf525d41de4624a2939ded191fc070ec03ca685 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 9 Mar 2018 00:28:49 +0100 Subject: [PATCH 177/191] Script/gen_requirements: Ignore package families (#12963) * Added fnmatch for IGNORE_PACKAGE_FAMILIES --- script/gen_requirements_all.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2d2c6bd7563..a9a68d09491 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -5,6 +5,7 @@ import os import pkgutil import re import sys +import fnmatch COMMENT_REQUIREMENTS = ( 'RPi.GPIO', @@ -93,12 +94,7 @@ TEST_REQUIREMENTS = ( IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', - 'homeassistant.components.homekit.accessories', - 'homeassistant.components.homekit.covers', - 'homeassistant.components.homekit.security_systems', - 'homeassistant.components.homekit.sensors', - 'homeassistant.components.homekit.switches', - 'homeassistant.components.homekit.thermostats' + 'homeassistant.components.homekit.*' ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') @@ -164,7 +160,10 @@ def gather_modules(): try: module = importlib.import_module(package) except ImportError: - if package not in IGNORE_PACKAGES: + for pattern in IGNORE_PACKAGES: + if fnmatch.fnmatch(package, pattern): + break + else: errors.append(package) continue From 44e4f8d1bad624f8d27dfda7230f0a0a2409a404 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 9 Mar 2018 00:39:31 +0100 Subject: [PATCH 178/191] Fix Sonos group discovery (#12970) * Avoid iterating sonos devices that are not yet added * Rebuild zone topology for each new device --- .../components/media_player/sonos.py | 29 ++++++++++--------- tests/components/media_player/test_sonos.py | 26 +++++++++-------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index a03a2e1db97..9ea33b4c396 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -114,6 +114,7 @@ class SonosData: def __init__(self): """Initialize the data.""" + self.uids = set() self.devices = [] self.topology_lock = threading.Lock() @@ -148,15 +149,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): player = soco.SoCo(discovery_info.get('host')) # If device already exists by config - if player.uid in [x.unique_id for x in hass.data[DATA_SONOS].devices]: + if player.uid in hass.data[DATA_SONOS].uids: return if player.is_visible: - device = SonosDevice(player) - hass.data[DATA_SONOS].devices.append(device) - add_devices([device]) - if len(hass.data[DATA_SONOS].devices) > 1: - return + hass.data[DATA_SONOS].uids.add(player.uid) + add_devices([SonosDevice(player)]) else: players = None hosts = config.get(CONF_HOSTS, None) @@ -180,19 +178,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - hass.data[DATA_SONOS].devices = [SonosDevice(p) for p in players] - add_devices(hass.data[DATA_SONOS].devices) + hass.data[DATA_SONOS].uids.update([p.uid for p in players]) + add_devices([SonosDevice(p) for p in players]) _LOGGER.debug("Added %s Sonos speakers", len(players)) def service_handle(service): """Handle for services.""" entity_ids = service.data.get('entity_id') + devices = hass.data[DATA_SONOS].devices if entity_ids: - devices = [device for device in hass.data[DATA_SONOS].devices - if device.entity_id in entity_ids] - else: - devices = hass.data[DATA_SONOS].devices + devices = [d for d in devices if d.entity_id in entity_ids] if service.service == SERVICE_JOIN: master = [device for device in hass.data[DATA_SONOS].devices @@ -365,6 +361,7 @@ class SonosDevice(MediaPlayerDevice): @asyncio.coroutine def async_added_to_hass(self): """Subscribe sonos events.""" + self.hass.data[DATA_SONOS].devices.append(self) self.hass.async_add_job(self._subscribe_to_player_events) @property @@ -435,6 +432,10 @@ class SonosDevice(MediaPlayerDevice): """Add event subscriptions.""" player = self.soco + # New player available, build the current group topology + for device in self.hass.data[DATA_SONOS].devices: + device.process_zonegrouptopology_event(None) + queue = _ProcessSonosEventQueue(self.process_avtransport_event) player.avTransport.subscribe(auto_renew=True, event_queue=queue) @@ -520,11 +521,11 @@ class SonosDevice(MediaPlayerDevice): def process_zonegrouptopology_event(self, event): """Process a zone group topology event coming from a player.""" - if not hasattr(event, 'zone_player_uui_ds_in_group'): + if event and not hasattr(event, 'zone_player_uui_ds_in_group'): return with self.hass.data[DATA_SONOS].topology_lock: - group = event.zone_player_uui_ds_in_group + group = event and event.zone_player_uui_ds_in_group if group: # New group information is pushed coordinator_uid, *slave_uids = group.split(',') diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index f1a0f4a82fc..3470c79ad64 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -122,11 +122,13 @@ class SoCoMock(): return -def fake_add_device(devices, update_befor_add=False): - """Fake add device / update.""" - if update_befor_add: - for speaker in devices: - speaker.update() +def add_devices_factory(hass): + """Add devices factory.""" + def add_devices(devices, update_befor_add=False): + """Fake add device.""" + hass.data[sonos.DATA_SONOS].devices = devices + + return add_devices class TestSonosMediaPlayer(unittest.TestCase): @@ -156,7 +158,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_discovery(self, *args): """Test a single device using the autodiscovery provided by HASS.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) @@ -260,7 +262,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" - sonos.setup_platform(self.hass, {}, fake_add_device) + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass)) devices = self.hass.data[sonos.DATA_SONOS].devices self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') @@ -270,7 +272,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch.object(SoCoMock, 'set_sleep_timer') def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args): """Ensuring soco methods called for sonos_set_sleep_timer service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -284,7 +286,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch.object(SoCoMock, 'set_sleep_timer') def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args): """Ensuring soco methods called for sonos_clear_sleep_timer service.""" - sonos.setup_platform(self.hass, {}, mock.MagicMock(), { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -298,7 +300,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('socket.create_connection', side_effect=socket.error()) def test_update_alarm(self, soco_mock, alarm_mock, *args): """Ensuring soco methods called for sonos_set_sleep_timer service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -328,7 +330,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch.object(soco.snapshot.Snapshot, 'snapshot') def test_sonos_snapshot(self, snapshotMock, *args): """Ensuring soco methods called for sonos_snapshot service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -346,7 +348,7 @@ class TestSonosMediaPlayer(unittest.TestCase): """Ensuring soco methods called for sonos_restor service.""" from soco.snapshot import Snapshot - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] From c4a4802a8cd94909828887a58b0f5acbeecff1d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Mar 2018 16:37:49 -0800 Subject: [PATCH 179/191] Bump frontend to 20180309.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6ba2bc73442..501537e3ed3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180305.0'] +REQUIREMENTS = ['home-assistant-frontend==20180309.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index e845aa05d9a..e6eeb18fafc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180305.0 +home-assistant-frontend==20180309.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c347ccee88..65e94172553 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180305.0 +home-assistant-frontend==20180309.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 7f065e38a7d6d329cb22f6c3cb85c30d62f46be8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Mar 2018 17:43:41 -0800 Subject: [PATCH 180/191] Check color temp range for google assistant (#12994) --- .../components/google_assistant/trait.py | 13 ++++++++++++- tests/components/google_assistant/test_trait.py | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e0edc017ed3..dd7b761e782 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -330,9 +330,20 @@ class ColorTemperatureTrait(_Trait): async def execute(self, hass, command, params): """Execute a color temperature command.""" + temp = color_util.color_temperature_kelvin_to_mired( + params['color']['temperature']) + min_temp = self.state.attributes[light.ATTR_MIN_MIREDS] + max_temp = self.state.attributes[light.ATTR_MAX_MIREDS] + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, - light.ATTR_KELVIN: params['color']['temperature'], + light.ATTR_COLOR_TEMP: temp, }, blocking=True) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 512c9612e60..90dd5d33581 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -15,7 +15,8 @@ from homeassistant.components import ( script, switch, ) -from homeassistant.components.google_assistant import trait, helpers +from homeassistant.components.google_assistant import trait, helpers, const +from homeassistant.util import color from tests.common import async_mock_service @@ -399,6 +400,15 @@ async def test_color_temperature_light(hass): }) calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 5555 + } + }) + assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { 'color': { 'temperature': 2857 @@ -407,7 +417,7 @@ async def test_color_temperature_light(hass): assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', - light.ATTR_KELVIN: 2857 + light.ATTR_COLOR_TEMP: color.color_temperature_kelvin_to_mired(2857) } @@ -511,11 +521,12 @@ async def test_temperature_setting_climate_range(hass): climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, } - with pytest.raises(helpers.SmartHomeError): + with pytest.raises(helpers.SmartHomeError) as err: await trt.execute( hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { 'thermostatTemperatureSetpoint': -100, }) + assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE async def test_temperature_setting_climate_setpoint(hass): From 19a529e9172a1cdcf85d068c4827131eadea5bb4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 9 Mar 2018 02:47:53 +0100 Subject: [PATCH 181/191] Fix limitlessled color temperature (#12971) * Adjust limitlessled mired range * Do temperature conversion on a Kelvin scale --- homeassistant/components/light/limitlessled.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 0606d097d49..f011792a15c 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -17,6 +17,7 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +from homeassistant.util.color import color_temperature_mired_to_kelvin from homeassistant.helpers.restore_state import async_get_last_state REQUIREMENTS = ['limitlessled==1.1.0'] @@ -222,6 +223,16 @@ class LimitlessLEDGroup(Light): """Return the brightness property.""" return self._brightness + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 154 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 370 + @property def color_temp(self): """Return the temperature property.""" @@ -310,8 +321,11 @@ class LimitlessLEDGroup(Light): def limitlessled_temperature(self): """Convert Home Assistant color temperature units to percentage.""" - width = self.max_mireds - self.min_mireds - temperature = 1 - (self._temperature - self.min_mireds) / width + max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds) + min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds) + width = max_kelvin - min_kelvin + kelvin = color_temperature_mired_to_kelvin(self._temperature) + temperature = (kelvin - min_kelvin) / width return max(0, min(1, temperature)) def limitlessled_brightness(self): From 2ee73ca911eec2d25ccf9b8c7489daad8bd9afa2 Mon Sep 17 00:00:00 2001 From: corneyl Date: Fri, 9 Mar 2018 02:50:17 +0100 Subject: [PATCH 182/191] Fixes notify.html5 for notifications on FireFox (#12993) * Only pass the gcm_key when using Google Cloud Messaging as endpoint. * Test if the gcm_key is only included for GCM endpoints. --- homeassistant/components/notify/html5.py | 8 ++++- tests/components/notify/test_html5.py | 39 ++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index a67c20c7078..7ccf4f8db90 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -404,8 +404,14 @@ class HTML5NotificationService(BaseNotificationService): jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') payload[ATTR_DATA][ATTR_JWT] = jwt_token + # Only pass the gcm key if we're actually using GCM + # If we don't, notifications break on FireFox + gcm_key = self._gcm_key \ + if 'googleapis.com' in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \ + else None response = WebPusher(info[ATTR_SUBSCRIPTION]).send( - json.dumps(payload), gcm_key=self._gcm_key, ttl='86400') + json.dumps(payload), gcm_key=gcm_key, ttl='86400' + ) # pylint: disable=no-member if response.status_code == 410: diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 344051b6c39..9ec71020ef1 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -12,7 +12,7 @@ CONFIG_FILE = 'file.conf' SUBSCRIPTION_1 = { 'browser': 'chrome', 'subscription': { - 'endpoint': 'https://google.com', + 'endpoint': 'https://googleapis.com', 'keys': {'auth': 'auth', 'p256dh': 'p256dh'} }, } @@ -39,7 +39,7 @@ SUBSCRIPTION_3 = { SUBSCRIPTION_4 = { 'browser': 'chrome', 'subscription': { - 'endpoint': 'https://google.com', + 'endpoint': 'https://googleapis.com', 'expirationTime': None, 'keys': {'auth': 'auth', 'p256dh': 'p256dh'} }, @@ -115,6 +115,41 @@ class TestHtml5Notify(object): assert payload['body'] == 'Hello' assert payload['icon'] == 'beer.png' + @patch('pywebpush.WebPusher') + def test_gcm_key_include(self, mock_wp): + """Test if the gcm_key is only included for GCM endpoints.""" + hass = MagicMock() + + data = { + 'chrome': SUBSCRIPTION_1, + 'firefox': SUBSCRIPTION_2 + } + + m = mock_open(read_data=json.dumps(data)) + with patch('homeassistant.util.json.open', m, create=True): + service = html5.get_service(hass, { + 'gcm_sender_id': '100', + 'gcm_api_key': 'Y6i0JdZ0mj9LOaSI' + }) + + assert service is not None + + service.send_message('Hello', target=['chrome', 'firefox']) + + assert len(mock_wp.mock_calls) == 6 + + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription'] + assert mock_wp.mock_calls[3][1][0] == SUBSCRIPTION_2['subscription'] + + # Third mock_call checks the status_code of the response. + assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' + assert mock_wp.mock_calls[5][0] == '().send().status_code.__eq__' + + # Get the keys passed to the WebPusher's send method + assert mock_wp.mock_calls[1][2]['gcm_key'] is not None + assert mock_wp.mock_calls[4][2]['gcm_key'] is None + async def test_registering_new_device_view(hass, test_client): """Test that the HTML view works.""" From 321eb2ec6f077191e20a7fc07490a18df6f11f3f Mon Sep 17 00:00:00 2001 From: Boyi C Date: Fri, 9 Mar 2018 09:51:49 +0800 Subject: [PATCH 183/191] Move HomeAssistantView to separate file. Convert http to async syntax. [skip ci] (#12982) * Move HomeAssistantView to separate file. Convert http to async syntax. * pylint * websocket api * update emulated_hue for async/await * Lint --- .../components/emulated_hue/__init__.py | 11 +- homeassistant/components/http/__init__.py | 153 +++--------------- homeassistant/components/http/auth.py | 12 +- homeassistant/components/http/ban.py | 21 ++- homeassistant/components/http/cors.py | 5 +- .../components/http/data_validator.py | 11 +- homeassistant/components/http/real_ip.py | 10 +- homeassistant/components/http/static.py | 19 +-- homeassistant/components/http/view.py | 121 ++++++++++++++ homeassistant/components/websocket_api.py | 51 +++--- tests/components/http/__init__.py | 9 +- tests/components/http/test_auth.py | 58 +++---- tests/components/http/test_ban.py | 32 ++-- tests/components/http/test_cors.py | 28 ++-- tests/components/http/test_data_validator.py | 31 ++-- tests/components/http/test_init.py | 41 ++--- tests/components/http/test_real_ip.py | 23 ++- 17 files changed, 292 insertions(+), 344 deletions(-) create mode 100644 homeassistant/components/http/view.py diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index c89e4fda358..09ce1a57060 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -4,7 +4,6 @@ Support for local control of entities by emulating the Phillips Hue bridge. For more details about this component, please refer to the documentation at https://home-assistant.io/components/emulated_hue/ """ -import asyncio import logging import voluptuous as vol @@ -111,17 +110,15 @@ def setup(hass, yaml_config): config.upnp_bind_multicast, config.advertise_ip, config.advertise_port) - @asyncio.coroutine - def stop_emulated_hue_bridge(event): + async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - yield from server.stop() + await server.stop() - @asyncio.coroutine - def start_emulated_hue_bridge(event): + async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - yield from server.start() + await server.start() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1d4306565b1..4d313b5132e 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -4,21 +4,18 @@ This module provides WSGI application to serve the Home Assistant API. For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ -import asyncio + from ipaddress import ip_network -import json import logging import os import ssl from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +from aiohttp.web_exceptions import HTTPMovedPermanently import voluptuous as vol from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,) -from homeassistant.core import is_callback + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT) import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util @@ -28,10 +25,13 @@ from .auth import setup_auth from .ban import setup_bans from .cors import setup_cors from .real_ip import setup_real_ip -from .const import KEY_AUTHENTICATED, KEY_REAL_IP from .static import ( CachingFileResponse, CachingStaticResource, staticresource_middleware) +# Import as alias +from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa +from .view import HomeAssistantView # noqa + REQUIREMENTS = ['aiohttp_cors==0.6.0'] DOMAIN = 'http' @@ -98,8 +98,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the HTTP API and debug interface.""" conf = config.get(DOMAIN) @@ -135,16 +134,14 @@ def async_setup(hass, config): is_ban_enabled=is_ban_enabled ) - @asyncio.coroutine - def stop_server(event): + async def stop_server(event): """Stop the server.""" - yield from server.stop() + await server.stop() - @asyncio.coroutine - def start_server(event): + async def start_server(event): """Start the server.""" hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - yield from server.start() + await server.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) @@ -252,13 +249,11 @@ class HomeAssistantHTTP(object): return if cache_headers: - @asyncio.coroutine - def serve_file(request): + async def serve_file(request): """Serve file from disk.""" return CachingFileResponse(path) else: - @asyncio.coroutine - def serve_file(request): + async def serve_file(request): """Serve file from disk.""" return web.FileResponse(path) @@ -276,14 +271,13 @@ class HomeAssistantHTTP(object): self.app.router.add_route('GET', url_pattern, serve_file) - @asyncio.coroutine - def start(self): + async def start(self): """Start the WSGI server.""" # We misunderstood the startup signal. You're not allowed to change # anything during startup. Temp workaround. # pylint: disable=protected-access self.app._on_startup.freeze() - yield from self.app.startup() + await self.app.startup() if self.ssl_certificate: try: @@ -308,121 +302,18 @@ class HomeAssistantHTTP(object): self._handler = self.app.make_handler(loop=self.hass.loop) try: - self.server = yield from self.hass.loop.create_server( + self.server = await self.hass.loop.create_server( self._handler, self.server_host, self.server_port, ssl=context) except OSError as error: _LOGGER.error("Failed to create HTTP server at port %d: %s", self.server_port, error) - @asyncio.coroutine - def stop(self): + async def stop(self): """Stop the WSGI server.""" if self.server: self.server.close() - yield from self.server.wait_closed() - yield from self.app.shutdown() + await self.server.wait_closed() + await self.app.shutdown() if self._handler: - yield from self._handler.shutdown(10) - yield from self.app.cleanup() - - -class HomeAssistantView(object): - """Base view for all views.""" - - url = None - extra_urls = [] - requires_auth = True # Views inheriting from this class can override this - - # pylint: disable=no-self-use - def json(self, result, status_code=200, headers=None): - """Return a JSON response.""" - msg = json.dumps( - result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') - response = web.Response( - body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, - headers=headers) - response.enable_compression() - return response - - def json_message(self, message, status_code=200, message_code=None, - headers=None): - """Return a JSON message response.""" - data = {'message': message} - if message_code is not None: - data['code'] = message_code - return self.json(data, status_code, headers=headers) - - @asyncio.coroutine - # pylint: disable=no-self-use - def file(self, request, fil): - """Return a file.""" - assert isinstance(fil, str), 'only string paths allowed' - return web.FileResponse(fil) - - def register(self, router): - """Register the view with a router.""" - assert self.url is not None, 'No url set for view' - urls = [self.url] + self.extra_urls - - for method in ('get', 'post', 'delete', 'put'): - handler = getattr(self, method, None) - - if not handler: - continue - - handler = request_handler_factory(self, handler) - - for url in urls: - router.add_route(method, url, handler) - - # aiohttp_cors does not work with class based views - # self.app.router.add_route('*', self.url, self, name=self.name) - - # for url in self.extra_urls: - # self.app.router.add_route('*', url, self) - - -def request_handler_factory(view, handler): - """Wrap the handler classes.""" - assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ - "Handler should be a coroutine or a callback." - - @asyncio.coroutine - def handle(request): - """Handle incoming request.""" - if not request.app['hass'].is_running: - return web.Response(status=503) - - authenticated = request.get(KEY_AUTHENTICATED, False) - - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() - - _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, request.get(KEY_REAL_IP), authenticated) - - result = handler(request, **request.match_info) - - if asyncio.iscoroutine(result): - result = yield from result - - if isinstance(result, web.StreamResponse): - # The method handler returned a ready-made Response, how nice of it - return result - - status_code = 200 - - if isinstance(result, tuple): - result, status_code = result - - if isinstance(result, str): - result = result.encode('utf-8') - elif result is None: - result = b'' - elif not isinstance(result, bytes): - assert False, ('Result should be None, string, bytes or Response. ' - 'Got: {}').format(result) - - return web.Response(body=result, status=status_code) - - return handle + await self._handler.shutdown(10) + await self.app.cleanup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 3128489437a..65c70c37bd2 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,5 +1,5 @@ """Authentication for HTTP component.""" -import asyncio + import base64 import hmac import logging @@ -20,13 +20,12 @@ _LOGGER = logging.getLogger(__name__) def setup_auth(app, trusted_networks, api_password): """Create auth middleware for the app.""" @middleware - @asyncio.coroutine - def auth_middleware(request, handler): + async def auth_middleware(request, handler): """Authenticate as middleware.""" # If no password set, just always set authenticated=True if api_password is None: request[KEY_AUTHENTICATED] = True - return (yield from handler(request)) + return await handler(request) # Check authentication authenticated = False @@ -50,10 +49,9 @@ def setup_auth(app, trusted_networks, api_password): authenticated = True request[KEY_AUTHENTICATED] = authenticated - return (yield from handler(request)) + return await handler(request) - @asyncio.coroutine - def auth_startup(app): + async def auth_startup(app): """Initialize auth middleware when app starts up.""" app.middlewares.append(auth_middleware) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 4c797b05b19..fe8b7db84d1 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -1,5 +1,5 @@ """Ban logic for HTTP component.""" -import asyncio + from collections import defaultdict from datetime import datetime from ipaddress import ip_address @@ -38,11 +38,10 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ @callback def setup_bans(hass, app, login_threshold): """Create IP Ban middleware for the app.""" - @asyncio.coroutine - def ban_startup(app): + async def ban_startup(app): """Initialize bans when app starts up.""" app.middlewares.append(ban_middleware) - app[KEY_BANNED_IPS] = yield from hass.async_add_job( + app[KEY_BANNED_IPS] = await hass.async_add_job( load_ip_bans_config, hass.config.path(IP_BANS_FILE)) app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) app[KEY_LOGIN_THRESHOLD] = login_threshold @@ -51,12 +50,11 @@ def setup_bans(hass, app, login_threshold): @middleware -@asyncio.coroutine -def ban_middleware(request, handler): +async def ban_middleware(request, handler): """IP Ban middleware.""" if KEY_BANNED_IPS not in request.app: _LOGGER.error('IP Ban middleware loaded but banned IPs not loaded') - return (yield from handler(request)) + return await handler(request) # Verify if IP is not banned ip_address_ = request[KEY_REAL_IP] @@ -67,14 +65,13 @@ def ban_middleware(request, handler): raise HTTPForbidden() try: - return (yield from handler(request)) + return await handler(request) except HTTPUnauthorized: - yield from process_wrong_login(request) + await process_wrong_login(request) raise -@asyncio.coroutine -def process_wrong_login(request): +async def process_wrong_login(request): """Process a wrong login attempt.""" remote_addr = request[KEY_REAL_IP] @@ -98,7 +95,7 @@ def process_wrong_login(request): request.app[KEY_BANNED_IPS].append(new_ban) hass = request.app['hass'] - yield from hass.async_add_job( + await hass.async_add_job( update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban) _LOGGER.warning( diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 2eb92732d1e..0a37f22867e 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,5 +1,5 @@ """Provide cors support for the HTTP component.""" -import asyncio + from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE @@ -27,8 +27,7 @@ def setup_cors(app, origins): ) for host in origins }) - @asyncio.coroutine - def cors_startup(app): + async def cors_startup(app): """Initialize cors when app starts up.""" cors_added = set() diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 528c0a598e3..8fc7cd8e658 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,5 +1,5 @@ """Decorator for view methods to help with data validation.""" -import asyncio + from functools import wraps import logging @@ -24,16 +24,15 @@ class RequestDataValidator: def __call__(self, method): """Decorate a function.""" - @asyncio.coroutine @wraps(method) - def wrapper(view, request, *args, **kwargs): + async def wrapper(view, request, *args, **kwargs): """Wrap a request handler with data validation.""" data = None try: - data = yield from request.json() + data = await request.json() except ValueError: if not self._allow_empty or \ - (yield from request.content.read()) != b'': + (await request.content.read()) != b'': _LOGGER.error('Invalid JSON received.') return view.json_message('Invalid JSON.', 400) data = {} @@ -45,7 +44,7 @@ class RequestDataValidator: return view.json_message( 'Message format incorrect: {}'.format(err), 400) - result = yield from method(view, request, *args, **kwargs) + result = await method(view, request, *args, **kwargs) return result return wrapper diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index 1e50f33f69e..c394016a683 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -1,5 +1,5 @@ """Middleware to fetch real IP.""" -import asyncio + from ipaddress import ip_address from aiohttp.web import middleware @@ -14,8 +14,7 @@ from .const import KEY_REAL_IP def setup_real_ip(app, use_x_forwarded_for): """Create IP Ban middleware for the app.""" @middleware - @asyncio.coroutine - def real_ip_middleware(request, handler): + async def real_ip_middleware(request, handler): """Real IP middleware.""" if (use_x_forwarded_for and X_FORWARDED_FOR in request.headers): @@ -25,10 +24,9 @@ def setup_real_ip(app, use_x_forwarded_for): request[KEY_REAL_IP] = \ ip_address(request.transport.get_extra_info('peername')[0]) - return (yield from handler(request)) + return await handler(request) - @asyncio.coroutine - def app_startup(app): + async def app_startup(app): """Initialize bans when app starts up.""" app.middlewares.append(real_ip_middleware) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index f444e4b3180..3fbaf703d06 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,5 +1,5 @@ """Static file handling for HTTP component.""" -import asyncio + import re from aiohttp import hdrs @@ -14,8 +14,7 @@ _FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" - @asyncio.coroutine - def _handle(self, request): + async def _handle(self, request): filename = URL(request.match_info['filename']).path try: # PyLint is wrong about resolve not being a member. @@ -32,7 +31,7 @@ class CachingStaticResource(StaticResource): raise HTTPNotFound() from error if filepath.is_dir(): - return (yield from super()._handle(request)) + return await super()._handle(request) elif filepath.is_file(): return CachingFileResponse(filepath, chunk_size=self._chunk_size) else: @@ -49,26 +48,24 @@ class CachingFileResponse(FileResponse): orig_sendfile = self._sendfile - @asyncio.coroutine - def sendfile(request, fobj, count): + async def sendfile(request, fobj, count): """Sendfile that includes a cache header.""" cache_time = 31 * 86400 # = 1 month self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( cache_time) - yield from orig_sendfile(request, fobj, count) + await orig_sendfile(request, fobj, count) # Overwriting like this because __init__ can change implementation. self._sendfile = sendfile @middleware -@asyncio.coroutine -def staticresource_middleware(request, handler): +async def staticresource_middleware(request, handler): """Middleware to strip out fingerprint from fingerprinted assets.""" path = request.path if not path.startswith('/static/') and not path.startswith('/frontend'): - return (yield from handler(request)) + return await handler(request) fingerprinted = _FINGERPRINT.match(request.match_info['filename']) @@ -76,4 +73,4 @@ def staticresource_middleware(request, handler): request.match_info['filename'] = \ '{}.{}'.format(*fingerprinted.groups()) - return (yield from handler(request)) + return await handler(request) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py new file mode 100644 index 00000000000..299a10e9f5a --- /dev/null +++ b/homeassistant/components/http/view.py @@ -0,0 +1,121 @@ +""" +This module provides WSGI application to serve the Home Assistant API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/http/ +""" +import asyncio +import json +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized + +import homeassistant.remote as rem +from homeassistant.core import is_callback +from homeassistant.const import CONTENT_TYPE_JSON + +from .const import KEY_AUTHENTICATED, KEY_REAL_IP + + +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantView(object): + """Base view for all views.""" + + url = None + extra_urls = [] + requires_auth = True # Views inheriting from this class can override this + + # pylint: disable=no-self-use + def json(self, result, status_code=200, headers=None): + """Return a JSON response.""" + msg = json.dumps( + result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') + response = web.Response( + body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, + headers=headers) + response.enable_compression() + return response + + def json_message(self, message, status_code=200, message_code=None, + headers=None): + """Return a JSON message response.""" + data = {'message': message} + if message_code is not None: + data['code'] = message_code + return self.json(data, status_code, headers=headers) + + # pylint: disable=no-self-use + async def file(self, request, fil): + """Return a file.""" + assert isinstance(fil, str), 'only string paths allowed' + return web.FileResponse(fil) + + def register(self, router): + """Register the view with a router.""" + assert self.url is not None, 'No url set for view' + urls = [self.url] + self.extra_urls + + for method in ('get', 'post', 'delete', 'put'): + handler = getattr(self, method, None) + + if not handler: + continue + + handler = request_handler_factory(self, handler) + + for url in urls: + router.add_route(method, url, handler) + + # aiohttp_cors does not work with class based views + # self.app.router.add_route('*', self.url, self, name=self.name) + + # for url in self.extra_urls: + # self.app.router.add_route('*', url, self) + + +def request_handler_factory(view, handler): + """Wrap the handler classes.""" + assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ + "Handler should be a coroutine or a callback." + + async def handle(request): + """Handle incoming request.""" + if not request.app['hass'].is_running: + return web.Response(status=503) + + authenticated = request.get(KEY_AUTHENTICATED, False) + + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() + + _LOGGER.info('Serving %s to %s (auth: %s)', + request.path, request.get(KEY_REAL_IP), authenticated) + + result = handler(request, **request.match_info) + + if asyncio.iscoroutine(result): + result = await result + + if isinstance(result, web.StreamResponse): + # The method handler returned a ready-made Response, how nice of it + return result + + status_code = 200 + + if isinstance(result, tuple): + result, status_code = result + + if isinstance(result, str): + result = result.encode('utf-8') + elif result is None: + result = b'' + elif not isinstance(result, bytes): + assert False, ('Result should be None, string, bytes or Response. ' + 'Got: {}').format(result) + + return web.Response(body=result, status=status_code) + + return handle diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index b79812a8dce..47ef2c3eace 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -191,8 +191,7 @@ def result_message(iden, result=None): } -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Initialize the websocket API.""" hass.http.register_view(WebsocketAPIView) return True @@ -205,11 +204,10 @@ class WebsocketAPIView(HomeAssistantView): url = URL requires_auth = False - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Handle an incoming websocket connection.""" # pylint: disable=no-self-use - return ActiveConnection(request.app['hass'], request).handle() + return await ActiveConnection(request.app['hass'], request).handle() class ActiveConnection: @@ -233,17 +231,16 @@ class ActiveConnection: """Print an error message.""" _LOGGER.error("WS %s: %s %s", id(self.wsock), message1, message2) - @asyncio.coroutine - def _writer(self): + async def _writer(self): """Write outgoing messages.""" # Exceptions if Socket disconnected or cancelled by connection handler with suppress(RuntimeError, *CANCELLATION_ERRORS): while not self.wsock.closed: - message = yield from self.to_write.get() + message = await self.to_write.get() if message is None: break self.debug("Sending", message) - yield from self.wsock.send_json(message, dumps=JSON_DUMP) + await self.wsock.send_json(message, dumps=JSON_DUMP) @callback def send_message_outside(self, message): @@ -266,12 +263,11 @@ class ActiveConnection: self._handle_task.cancel() self._writer_task.cancel() - @asyncio.coroutine - def handle(self): + async def handle(self): """Handle the websocket connection.""" request = self.request wsock = self.wsock = web.WebSocketResponse(heartbeat=55) - yield from wsock.prepare(request) + await wsock.prepare(request) self.debug("Connected") # Get a reference to current task so we can cancel our connection @@ -294,8 +290,8 @@ class ActiveConnection: authenticated = True else: - yield from self.wsock.send_json(auth_required_message()) - msg = yield from wsock.receive_json() + await self.wsock.send_json(auth_required_message()) + msg = await wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) if validate_password(request, msg['api_password']): @@ -303,18 +299,18 @@ class ActiveConnection: else: self.debug("Invalid password") - yield from self.wsock.send_json( + await self.wsock.send_json( auth_invalid_message('Invalid password')) if not authenticated: - yield from process_wrong_login(request) + await process_wrong_login(request) return wsock - yield from self.wsock.send_json(auth_ok_message()) + await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- - msg = yield from wsock.receive_json() + msg = await wsock.receive_json() last_id = 0 while msg: @@ -332,7 +328,7 @@ class ActiveConnection: getattr(self, handler_name)(msg) last_id = cur_id - msg = yield from wsock.receive_json() + msg = await wsock.receive_json() except vol.Invalid as err: error_msg = "Message incorrectly formatted: " @@ -394,11 +390,11 @@ class ActiveConnection: self.to_write.put_nowait(final_message) self.to_write.put_nowait(None) # Make sure all error messages are written before closing - yield from self._writer_task + await self._writer_task except asyncio.QueueFull: self._writer_task.cancel() - yield from wsock.close() + await wsock.close() self.debug("Closed connection") return wsock @@ -410,8 +406,7 @@ class ActiveConnection: """ msg = SUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg) - @asyncio.coroutine - def forward_events(event): + async def forward_events(event): """Forward events to websocket.""" if event.event_type == EVENT_TIME_CHANGED: return @@ -447,10 +442,9 @@ class ActiveConnection: """ msg = CALL_SERVICE_MESSAGE_SCHEMA(msg) - @asyncio.coroutine - def call_service_helper(msg): + async def call_service_helper(msg): """Call a service and fire complete message.""" - yield from self.hass.services.async_call( + await self.hass.services.async_call( msg['domain'], msg['service'], msg.get('service_data'), True) self.send_message_outside(result_message(msg['id'])) @@ -473,10 +467,9 @@ class ActiveConnection: """ msg = GET_SERVICES_MESSAGE_SCHEMA(msg) - @asyncio.coroutine - def get_services_helper(msg): + async def get_services_helper(msg): """Get available services and fire complete message.""" - descriptions = yield from async_get_all_descriptions(self.hass) + descriptions = await async_get_all_descriptions(self.hass) self.send_message_outside(result_message(msg['id'], descriptions)) self.hass.async_add_job(get_services_helper(msg)) diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index ef9817a2f1b..64f6c94c0da 100644 --- a/tests/components/http/__init__.py +++ b/tests/components/http/__init__.py @@ -1,5 +1,4 @@ """Tests for the HTTP component.""" -import asyncio from ipaddress import ip_address from aiohttp import web @@ -18,18 +17,16 @@ def mock_real_ip(app): nonlocal ip_to_mock ip_to_mock = value - @asyncio.coroutine @web.middleware - def mock_real_ip(request, handler): + async def mock_real_ip(request, handler): """Mock Real IP middleware.""" nonlocal ip_to_mock request[KEY_REAL_IP] = ip_address(ip_to_mock) - return (yield from handler(request)) + return (await handler(request)) - @asyncio.coroutine - def real_ip_startup(app): + async def real_ip_startup(app): """Startup of real ip.""" app.middlewares.insert(0, mock_real_ip) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index c2687c05a8f..604ee9c0c9b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,6 +1,5 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -import asyncio from ipaddress import ip_network from unittest.mock import patch @@ -30,8 +29,7 @@ TRUSTED_ADDRESSES = ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1', UNTRUSTED_ADDRESSES = ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1', '::1'] -@asyncio.coroutine -def mock_handler(request): +async def mock_handler(request): """Return if request was authenticated.""" if not request[KEY_AUTHENTICATED]: raise HTTPUnauthorized @@ -47,84 +45,79 @@ def app(): return app -@asyncio.coroutine -def test_auth_middleware_loaded_by_default(hass): +async def test_auth_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_auth') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': {} }) assert len(mock_setup.mock_calls) == 1 -@asyncio.coroutine -def test_access_without_password(app, test_client): +async def test_access_without_password(app, test_client): """Test access without password.""" setup_auth(app, [], None) - client = yield from test_client(app) + client = await test_client(app) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 200 -@asyncio.coroutine -def test_access_with_password_in_header(app, test_client): +async def test_access_with_password_in_header(app, test_client): """Test access with password in URL.""" setup_auth(app, [], API_PASSWORD) - client = yield from test_client(app) + client = await test_client(app) - req = yield from client.get( + req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 - req = yield from client.get( + req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) assert req.status == 401 -@asyncio.coroutine -def test_access_with_password_in_query(app, test_client): +async def test_access_with_password_in_query(app, test_client): """Test access without password.""" setup_auth(app, [], API_PASSWORD) - client = yield from test_client(app) + client = await test_client(app) - resp = yield from client.get('/', params={ + resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401 - resp = yield from client.get('/', params={ + resp = await client.get('/', params={ 'api_password': 'wrong-password' }) assert resp.status == 401 -@asyncio.coroutine -def test_basic_auth_works(app, test_client): +async def test_basic_auth_works(app, test_client): """Test access with basic authentication.""" setup_auth(app, [], API_PASSWORD) - client = yield from test_client(app) + client = await test_client(app) - req = yield from client.get( + req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 - req = yield from client.get( + req = await client.get( '/', auth=BasicAuth('wrong_username', API_PASSWORD)) assert req.status == 401 - req = yield from client.get( + req = await client.get( '/', auth=BasicAuth('homeassistant', 'wrong password')) assert req.status == 401 - req = yield from client.get( + req = await client.get( '/', headers={ 'authorization': 'NotBasic abcdefg' @@ -132,8 +125,7 @@ def test_basic_auth_works(app, test_client): assert req.status == 401 -@asyncio.coroutine -def test_access_with_trusted_ip(test_client): +async def test_access_with_trusted_ip(test_client): """Test access with an untrusted ip address.""" app = web.Application() app.router.add_get('/', mock_handler) @@ -141,16 +133,16 @@ def test_access_with_trusted_ip(test_client): setup_auth(app, TRUSTED_NETWORKS, 'some-pass') set_mock_ip = mock_real_ip(app) - client = yield from test_client(app) + client = await test_client(app) for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401, \ "{} shouldn't be trusted".format(remote_addr) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index bd6df4f4e73..2d7885d959f 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,6 +1,5 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -import asyncio from unittest.mock import patch, mock_open from aiohttp import web @@ -16,8 +15,7 @@ from . import mock_real_ip BANNED_IPS = ['200.201.202.203', '100.64.0.2'] -@asyncio.coroutine -def test_access_from_banned_ip(hass, test_client): +async def test_access_from_banned_ip(hass, test_client): """Test accessing to server from banned IP. Both trusted and not.""" app = web.Application() setup_bans(hass, app, 5) @@ -26,19 +24,18 @@ def test_access_from_banned_ip(hass, test_client): with patch('homeassistant.components.http.ban.load_ip_bans_config', return_value=[IpBan(banned_ip) for banned_ip in BANNED_IPS]): - client = yield from test_client(app) + client = await test_client(app) for remote_addr in BANNED_IPS: set_real_ip(remote_addr) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 403 -@asyncio.coroutine -def test_ban_middleware_not_loaded_by_config(hass): +async def test_ban_middleware_not_loaded_by_config(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_bans') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': { http.CONF_IP_BAN_ENABLED: False, } @@ -47,25 +44,22 @@ def test_ban_middleware_not_loaded_by_config(hass): assert len(mock_setup.mock_calls) == 0 -@asyncio.coroutine -def test_ban_middleware_loaded_by_default(hass): +async def test_ban_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_bans') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': {} }) assert len(mock_setup.mock_calls) == 1 -@asyncio.coroutine -def test_ip_bans_file_creation(hass, test_client): +async def test_ip_bans_file_creation(hass, test_client): """Testing if banned IP file created.""" app = web.Application() app['hass'] = hass - @asyncio.coroutine - def unauth_handler(request): + async def unauth_handler(request): """Return a mock web response.""" raise HTTPUnauthorized @@ -76,21 +70,21 @@ def test_ip_bans_file_creation(hass, test_client): with patch('homeassistant.components.http.ban.load_ip_bans_config', return_value=[IpBan(banned_ip) for banned_ip in BANNED_IPS]): - client = yield from test_client(app) + client = await test_client(app) m = mock_open() with patch('homeassistant.components.http.ban.open', m, create=True): - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401 assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) assert m.call_count == 0 - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401 assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a') - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 403 assert m.call_count == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 22b70e1c0c5..50464b36277 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,5 +1,4 @@ """Test cors for the HTTP component.""" -import asyncio from unittest.mock import patch from aiohttp import web @@ -20,22 +19,20 @@ from homeassistant.components.http.cors import setup_cors TRUSTED_ORIGIN = 'https://home-assistant.io' -@asyncio.coroutine -def test_cors_middleware_not_loaded_by_default(hass): +async def test_cors_middleware_not_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': {} }) assert len(mock_setup.mock_calls) == 0 -@asyncio.coroutine -def test_cors_middleware_loaded_from_config(hass): +async def test_cors_middleware_loaded_from_config(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': { 'cors_allowed_origins': ['http://home-assistant.io'] } @@ -44,8 +41,7 @@ def test_cors_middleware_loaded_from_config(hass): assert len(mock_setup.mock_calls) == 1 -@asyncio.coroutine -def mock_handler(request): +async def mock_handler(request): """Return if request was authenticated.""" return web.Response(status=200) @@ -59,10 +55,9 @@ def client(loop, test_client): return loop.run_until_complete(test_client(app)) -@asyncio.coroutine -def test_cors_requests(client): +async def test_cors_requests(client): """Test cross origin requests.""" - req = yield from client.get('/', headers={ + req = await client.get('/', headers={ ORIGIN: TRUSTED_ORIGIN }) assert req.status == 200 @@ -70,7 +65,7 @@ def test_cors_requests(client): TRUSTED_ORIGIN # With password in URL - req = yield from client.get('/', params={ + req = await client.get('/', params={ 'api_password': 'some-pass' }, headers={ ORIGIN: TRUSTED_ORIGIN @@ -80,7 +75,7 @@ def test_cors_requests(client): TRUSTED_ORIGIN # With password in headers - req = yield from client.get('/', headers={ + req = await client.get('/', headers={ HTTP_HEADER_HA_AUTH: 'some-pass', ORIGIN: TRUSTED_ORIGIN }) @@ -89,10 +84,9 @@ def test_cors_requests(client): TRUSTED_ORIGIN -@asyncio.coroutine -def test_cors_preflight_allowed(client): +async def test_cors_preflight_allowed(client): """Test cross origin resource sharing preflight (OPTIONS) request.""" - req = yield from client.options('/', headers={ + req = await client.options('/', headers={ ORIGIN: TRUSTED_ORIGIN, ACCESS_CONTROL_REQUEST_METHOD: 'GET', ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index f00be4fc6f9..6cca1af8ccc 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -1,5 +1,4 @@ """Test data validator decorator.""" -import asyncio from unittest.mock import Mock from aiohttp import web @@ -9,8 +8,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -@asyncio.coroutine -def get_client(test_client, validator): +async def get_client(test_client, validator): """Generate a client that hits a view decorated with validator.""" app = web.Application() app['hass'] = Mock(is_running=True) @@ -20,58 +18,55 @@ def get_client(test_client, validator): name = 'test' requires_auth = False - @asyncio.coroutine @validator - def post(self, request, data): + async def post(self, request, data): """Test method.""" return b'' TestView().register(app.router) - client = yield from test_client(app) + client = await test_client(app) return client -@asyncio.coroutine -def test_validator(test_client): +async def test_validator(test_client): """Test the validator.""" - client = yield from get_client( + client = await get_client( test_client, RequestDataValidator(vol.Schema({ vol.Required('test'): str }))) - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 'bla' }) assert resp.status == 200 - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 100 }) assert resp.status == 400 - resp = yield from client.post('/') + resp = await client.post('/') assert resp.status == 400 -@asyncio.coroutine -def test_validator_allow_empty(test_client): +async def test_validator_allow_empty(test_client): """Test the validator with empty data.""" - client = yield from get_client( + client = await get_client( test_client, RequestDataValidator(vol.Schema({ # Although we allow empty, our schema should still be able # to validate an empty dict. vol.Optional('test'): str }), allow_empty=True)) - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 'bla' }) assert resp.status == 200 - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 100 }) assert resp.status == 400 - resp = yield from client.post('/') + resp = await client.post('/') assert resp.status == 200 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index ab06b48043e..1dcf45f48c3 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,6 +1,4 @@ """The tests for the Home Assistant HTTP component.""" -import asyncio - from homeassistant.setup import async_setup_component import homeassistant.components.http as http @@ -12,16 +10,14 @@ class TestView(http.HomeAssistantView): name = 'test' url = '/hello' - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Return a get request.""" return 'hello' -@asyncio.coroutine -def test_registering_view_while_running(hass, test_client, unused_port): +async def test_registering_view_while_running(hass, test_client, unused_port): """Test that we can register a view while the server is running.""" - yield from async_setup_component( + await async_setup_component( hass, http.DOMAIN, { http.DOMAIN: { http.CONF_SERVER_PORT: unused_port(), @@ -29,15 +25,14 @@ def test_registering_view_while_running(hass, test_client, unused_port): } ) - yield from hass.async_start() + await hass.async_start() # This raises a RuntimeError if app is frozen hass.http.register_view(TestView) -@asyncio.coroutine -def test_api_base_url_with_domain(hass): +async def test_api_base_url_with_domain(hass): """Test setting API URL.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' } @@ -46,10 +41,9 @@ def test_api_base_url_with_domain(hass): assert hass.config.api.base_url == 'http://example.com' -@asyncio.coroutine -def test_api_base_url_with_ip(hass): +async def test_api_base_url_with_ip(hass): """Test setting api url.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { 'server_host': '1.1.1.1' } @@ -58,10 +52,9 @@ def test_api_base_url_with_ip(hass): assert hass.config.api.base_url == 'http://1.1.1.1:8123' -@asyncio.coroutine -def test_api_base_url_with_ip_port(hass): +async def test_api_base_url_with_ip_port(hass): """Test setting api url.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { 'base_url': '1.1.1.1:8124' } @@ -70,10 +63,9 @@ def test_api_base_url_with_ip_port(hass): assert hass.config.api.base_url == 'http://1.1.1.1:8124' -@asyncio.coroutine -def test_api_no_base_url(hass): +async def test_api_no_base_url(hass): """Test setting api url.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { } }) @@ -81,10 +73,9 @@ def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' -@asyncio.coroutine -def test_not_log_password(hass, unused_port, test_client, caplog): +async def test_not_log_password(hass, unused_port, test_client, caplog): """Test access with password doesn't get logged.""" - result = yield from async_setup_component(hass, 'api', { + result = await async_setup_component(hass, 'api', { 'http': { http.CONF_SERVER_PORT: unused_port(), http.CONF_API_PASSWORD: 'some-pass' @@ -92,9 +83,9 @@ def test_not_log_password(hass, unused_port, test_client, caplog): }) assert result - client = yield from test_client(hass.http.app) + client = await test_client(hass.http.app) - resp = yield from client.get('/api/', params={ + resp = await client.get('/api/', params={ 'api_password': 'some-pass' }) diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py index 90201ab4c10..3e4f9023537 100644 --- a/tests/components/http/test_real_ip.py +++ b/tests/components/http/test_real_ip.py @@ -1,6 +1,4 @@ """Test real IP middleware.""" -import asyncio - from aiohttp import web from aiohttp.hdrs import X_FORWARDED_FOR @@ -8,41 +6,38 @@ from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.const import KEY_REAL_IP -@asyncio.coroutine -def mock_handler(request): +async def mock_handler(request): """Handler that returns the real IP as text.""" return web.Response(text=str(request[KEY_REAL_IP])) -@asyncio.coroutine -def test_ignore_x_forwarded_for(test_client): +async def test_ignore_x_forwarded_for(test_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) setup_real_ip(app, False) - mock_api_client = yield from test_client(app) + mock_api_client = await test_client(app) - resp = yield from mock_api_client.get('/', headers={ + resp = await mock_api_client.get('/', headers={ X_FORWARDED_FOR: '255.255.255.255' }) assert resp.status == 200 - text = yield from resp.text() + text = await resp.text() assert text != '255.255.255.255' -@asyncio.coroutine -def test_use_x_forwarded_for(test_client): +async def test_use_x_forwarded_for(test_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) setup_real_ip(app, True) - mock_api_client = yield from test_client(app) + mock_api_client = await test_client(app) - resp = yield from mock_api_client.get('/', headers={ + resp = await mock_api_client.get('/', headers={ X_FORWARDED_FOR: '255.255.255.255' }) assert resp.status == 200 - text = yield from resp.text() + text = await resp.text() assert text == '255.255.255.255' From 7e15f179c678b5797102e50c517d44baffb8392c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Mar 2018 17:52:41 -0800 Subject: [PATCH 184/191] Bump release 0.65 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1d90e530702..d8f7e00959c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 65 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 7410bc90f0090ce8829827027952ba3ab45c780d Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Thu, 8 Mar 2018 22:31:52 -0500 Subject: [PATCH 185/191] Get zha switch and binary_sensor state on startup (#11672) * Get zha switch and binary_sensor state on startup * Removed unused var * Make zha switch report status * Use right method name * Formatting fix * Updates to match latest dev * PR feedback updates * Use async for cluster commands --- homeassistant/components/binary_sensor/zha.py | 33 ++++++++++---- homeassistant/components/switch/zha.py | 43 ++++++++++++++----- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index de7896e595b..bf038a62465 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -4,7 +4,6 @@ Binary sensors on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.zha/ """ -import asyncio import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice @@ -25,8 +24,8 @@ CLASS_MAPPING = { } -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Zigbee Home Automation binary sensors.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: @@ -39,19 +38,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device_class = None cluster = in_clusters[IasZone.cluster_id] if discovery_info['new_join']: - yield from cluster.bind() + await cluster.bind() ieee = cluster.endpoint.device.application.ieee - yield from cluster.write_attributes({'cie_addr': ieee}) + await cluster.write_attributes({'cie_addr': ieee}) try: - zone_type = yield from cluster['zone_type'] + zone_type = await cluster['zone_type'] device_class = CLASS_MAPPING.get(zone_type, None) except Exception: # pylint: disable=broad-except # If we fail to read from the device, use a non-specific class pass sensor = BinarySensor(device_class, **discovery_info) - async_add_devices([sensor]) + async_add_devices([sensor], update_before_add=True) class BinarySensor(zha.Entity, BinarySensorDevice): @@ -66,6 +65,11 @@ class BinarySensor(zha.Entity, BinarySensorDevice): from zigpy.zcl.clusters.security import IasZone self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id] + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False + @property def is_on(self) -> bool: """Return True if entity is on.""" @@ -83,7 +87,18 @@ class BinarySensor(zha.Entity, BinarySensorDevice): if command_id == 0: self._state = args[0] & 3 _LOGGER.debug("Updated alarm state: %s", self._state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() elif command_id == 1: _LOGGER.debug("Enroll requested") - self.hass.add_job(self._ias_zone_cluster.enroll_response(0, 0)) + res = self._ias_zone_cluster.enroll_response(0, 0) + self.hass.async_add_job(res) + + async def async_update(self): + """Retrieve latest state.""" + from bellows.types.basic import uint16_t + + result = await zha.safe_read(self._endpoint.ias_zone, + ['zone_status']) + state = result.get('zone_status', self._state) + if isinstance(state, (int, uint16_t)): + self._state = result.get('zone_status', self._state) & 3 diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index c98db2e894e..7de9f1459b1 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -4,7 +4,6 @@ Switches on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/switch.zha/ """ -import asyncio import logging from homeassistant.components.switch import DOMAIN, SwitchDevice @@ -15,19 +14,39 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['zha'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Zigbee Home Automation switches.""" +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Zigbee Home Automation switches.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return - add_devices([Switch(**discovery_info)]) + from zigpy.zcl.clusters.general import OnOff + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await cluster.bind() + await cluster.configure_reporting(0, 0, 600, 1,) + + async_add_devices([Switch(**discovery_info)], update_before_add=True) class Switch(zha.Entity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN + value_attribute = 0 + + def attribute_updated(self, attribute, value): + """Handle attribute update from device.""" + _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) + if attribute == self.value_attribute: + self._state = value + self.async_schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False @property def is_on(self) -> bool: @@ -36,14 +55,18 @@ class Switch(zha.Entity, SwitchDevice): return False return bool(self._state) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on.""" - yield from self._endpoint.on_off.on() + await self._endpoint.on_off.on() self._state = 1 - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the entity off.""" - yield from self._endpoint.on_off.off() + await self._endpoint.on_off.off() self._state = 0 + + async def async_update(self): + """Retrieve latest state.""" + result = await zha.safe_read(self._endpoint.on_off, + ['on_off']) + self._state = result.get('on_off', self._state) From b2210f429e0f40b900bf8d0177b1c8d26bbee29f Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Thu, 8 Mar 2018 18:23:52 -0800 Subject: [PATCH 186/191] Add camera proxy (#12006) * Add camera proxy * Fix additional tox linting issues * Trivial cleanup * update to new async/await methods rather than decorators. Other minor fixes from code review --- homeassistant/components/camera/proxy.py | 262 +++++++++++++++++++++++ requirements_all.txt | 3 + 2 files changed, 265 insertions(+) create mode 100644 homeassistant/components/camera/proxy.py diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py new file mode 100644 index 00000000000..56b9db5c0ec --- /dev/null +++ b/homeassistant/components/camera/proxy.py @@ -0,0 +1,262 @@ +""" +Proxy camera platform that enables image processing of camera data. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/proxy +""" +import logging +import asyncio +import aiohttp +import async_timeout + +import voluptuous as vol + +from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.helpers import config_validation as cv + +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + CONF_NAME, CONF_ENTITY_ID, HTTP_HEADER_HA_AUTH) +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_web) + +REQUIREMENTS = ['pillow==5.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_MAX_IMAGE_WIDTH = "max_image_width" +CONF_IMAGE_QUALITY = "image_quality" +CONF_IMAGE_REFRESH_RATE = "image_refresh_rate" +CONF_FORCE_RESIZE = "force_resize" +CONF_MAX_STREAM_WIDTH = "max_stream_width" +CONF_STREAM_QUALITY = "stream_quality" +CONF_CACHE_IMAGES = "cache_images" + +DEFAULT_BASENAME = "Camera Proxy" +DEFAULT_QUALITY = 75 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_IMAGE_QUALITY): int, + vol.Optional(CONF_IMAGE_REFRESH_RATE): float, + vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_MAX_STREAM_WIDTH): int, + vol.Optional(CONF_STREAM_QUALITY): int, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Proxy camera platform.""" + async_add_devices([ProxyCamera(hass, config)]) + + +async def _read_frame(req): + """Read a single frame from an MJPEG stream.""" + # based on https://gist.github.com/russss/1143799 + import cgi + # Read in HTTP headers: + stream = req.content + # multipart/x-mixed-replace; boundary=--frameboundary + _mimetype, options = cgi.parse_header(req.headers['content-type']) + boundary = options.get('boundary').encode('utf-8') + if not boundary: + _LOGGER.error("Malformed MJPEG missing boundary") + raise Exception("Can't find content-type") + + line = await stream.readline() + # Seek ahead to the first chunk + while line.strip() != boundary: + line = await stream.readline() + # Read in chunk headers + while line.strip() != b'': + parts = line.split(b':') + if len(parts) > 1 and parts[0].lower() == b'content-length': + # Grab chunk length + length = int(parts[1].strip()) + line = await stream.readline() + image = await stream.read(length) + return image + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + if not opts: + return image + + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width + + img = Image.open(io.BytesIO(image)) + imgfmt = str(img.format) + if imgfmt != 'PNG' and imgfmt != 'JPEG': + _LOGGER.debug("Image is of unsupported type: %s", imgfmt) + return image + + (old_width, old_height) = img.size + old_size = len(image) + if old_width <= new_width: + if opts.quality is None: + _LOGGER.debug("Image is smaller-than / equal-to requested width") + return image + new_width = old_width + + scale = new_width / float(old_width) + new_height = int((float(old_height)*float(scale))) + + img = img.resize((new_width, new_height), Image.ANTIALIAS) + imgbuf = io.BytesIO() + img.save(imgbuf, "JPEG", optimize=True, quality=quality) + newimage = imgbuf.getvalue() + if not opts.force_resize and len(newimage) >= old_size: + _LOGGER.debug("Using original image(%d bytes) " + "because resized image (%d bytes) is not smaller", + old_size, len(newimage)) + return image + + _LOGGER.debug("Resized image " + "from (%dx%d - %d bytes) " + "to (%dx%d - %d bytes)", + old_width, old_height, old_size, + new_width, new_height, len(newimage)) + return newimage + + +class ImageOpts(): + """The representation of image options.""" + + def __init__(self, max_width, quality, force_resize): + """Initialize image options.""" + self.max_width = max_width + self.quality = quality + self.force_resize = force_resize + + def __bool__(self): + """Bool evalution rules.""" + return bool(self.max_width or self.quality) + + +class ProxyCamera(Camera): + """The representation of a Proxy camera.""" + + def __init__(self, hass, config): + """Initialize a proxy camera component.""" + super().__init__() + self.hass = hass + self._proxied_camera = config.get(CONF_ENTITY_ID) + self._name = ( + config.get(CONF_NAME) or + "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) + self._image_opts = ImageOpts( + config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_IMAGE_QUALITY), + config.get(CONF_FORCE_RESIZE)) + + self._stream_opts = ImageOpts( + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_STREAM_QUALITY), + True) + + self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) + self._cache_images = bool( + config.get(CONF_IMAGE_REFRESH_RATE) + or config.get(CONF_CACHE_IMAGES)) + self._last_image_time = 0 + self._last_image = None + self._headers = ( + {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} + if self.hass.config.api.api_password is not None + else None) + + def camera_image(self): + """Return camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + now = dt_util.utcnow() + + if (self._image_refresh_rate and + now < self._last_image_time + self._image_refresh_rate): + return self._last_image + + self._last_image_time = now + url = "{}/api/camera_proxy/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(10, loop=self.hass.loop): + response = await websession.get(url, headers=self._headers) + image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image") + return self._last_image + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image: %s", err) + return self._last_image + + image = await self.hass.async_add_job( + _resize_image, image, self._image_opts) + + if self._cache_images: + self._last_image = image + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from camera images.""" + websession = async_get_clientsession(self.hass) + url = "{}/api/camera_proxy_stream/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + stream_coro = websession.get(url, headers=self._headers) + + if not self._stream_opts: + await async_aiohttp_proxy_web(self.hass, request, stream_coro) + return + + response = aiohttp.web.StreamResponse() + response.content_type = ('multipart/x-mixed-replace; ' + 'boundary=--frameboundary') + await response.prepare(request) + + def write(img_bytes): + """Write image to stream.""" + response.write(bytes( + '--frameboundary\r\n' + 'Content-Type: {}\r\n' + 'Content-Length: {}\r\n\r\n'.format( + self.content_type, len(img_bytes)), + 'utf-8') + img_bytes + b'\r\n') + + with async_timeout.timeout(10, loop=self.hass.loop): + req = await stream_coro + + try: + while True: + image = await _read_frame(req) + if not image: + break + image = await self.hass.async_add_job( + _resize_image, image, self._stream_opts) + write(image) + except asyncio.CancelledError: + _LOGGER.debug("Stream closed by frontend.") + req.close() + response = None + + finally: + if response is not None: + await response.write_eof() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/requirements_all.txt b/requirements_all.txt index e6eeb18fafc..b7af19f0d66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -580,6 +580,9 @@ piglow==1.2.4 # homeassistant.components.pilight pilight==0.1.1 +# homeassistant.components.camera.proxy +pillow==5.0.0 + # homeassistant.components.dominos pizzapi==0.0.3 From 16d72d23512eb402babbeee19f32b2a1f254690a Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 9 Mar 2018 05:34:24 +0200 Subject: [PATCH 187/191] check_config script evolution (#12792) * Initial async_check_ha_config_file * check_ha_config_file * Various fixes * feedback - return the config * move_to_check_config --- homeassistant/bootstrap.py | 11 +- homeassistant/config.py | 31 ++- homeassistant/scripts/check_config.py | 285 ++++++++++++++++---------- tests/scripts/test_check_config.py | 239 ++++++++++----------- tests/test_config.py | 4 +- 5 files changed, 316 insertions(+), 254 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4971cbccc9c..2f093f061d9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -112,18 +112,13 @@ def async_from_config_dict(config: Dict[str, Any], if not loader.PREPARED: yield from hass.async_add_job(loader.prepare, hass) + # Make a copy because we are mutating it. + config = OrderedDict(config) + # Merge packages conf_util.merge_packages_config( config, core_config.get(conf_util.CONF_PACKAGES, {})) - # Make a copy because we are mutating it. - # Use OrderedDict in case original one was one. - # Convert values to dictionaries if they are None - new_config = OrderedDict() - for key, value in config.items(): - new_config[key] = value or {} - config = new_config - hass.config_entries = config_entries.ConfigEntries(hass, config) yield from hass.config_entries.async_load() diff --git a/homeassistant/config.py b/homeassistant/config.py index 1c8ca10f8c6..5f2c6cf1625 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -41,9 +41,9 @@ VERSION_FILE = '.HA_VERSION' CONFIG_DIR_NAME = '.homeassistant' DATA_CUSTOMIZE = 'hass_customize' -FILE_MIGRATION = [ - ['ios.conf', '.ios.conf'], -] +FILE_MIGRATION = ( + ('ios.conf', '.ios.conf'), +) DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) @@ -304,6 +304,9 @@ def load_yaml_config_file(config_path): _LOGGER.error(msg) raise HomeAssistantError(msg) + # Convert values to dictionaries if they are None + for key, value in conf_dict.items(): + conf_dict[key] = value or {} return conf_dict @@ -345,14 +348,22 @@ def process_ha_config_upgrade(hass): @callback def async_log_exception(ex, domain, config, hass): + """Log an error for configuration validation. + + This method must be run in the event loop. + """ + if hass is not None: + async_notify_setup_error(hass, domain, True) + _LOGGER.error(_format_config_error(ex, domain, config)) + + +@callback +def _format_config_error(ex, domain, config): """Generate log exception for configuration validation. This method must be run in the event loop. """ message = "Invalid config for [{}]: ".format(domain) - if hass is not None: - async_notify_setup_error(hass, domain, True) - if 'extra keys not allowed' in ex.error_message: message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ .format(ex.path[-1], domain, domain, @@ -369,7 +380,7 @@ def async_log_exception(ex, domain, config, hass): message += ('Please check the docs at ' 'https://home-assistant.io/components/{}/'.format(domain)) - _LOGGER.error(message) + return message async def async_process_ha_core_config(hass, config): @@ -497,7 +508,7 @@ async def async_process_ha_core_config(hass, config): def _log_pkg_error(package, component, config, message): - """Log an error while merging.""" + """Log an error while merging packages.""" message = "Package {} setup failed. Component {} {}".format( package, component, message) @@ -523,7 +534,7 @@ def _identify_config_schema(module): return '', schema -def merge_packages_config(config, packages): +def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): """Merge packages into the top-level configuration. Mutate config.""" # pylint: disable=too-many-nested-blocks PACKAGES_CONFIG_SCHEMA(packages) @@ -589,7 +600,7 @@ def merge_packages_config(config, packages): def async_process_component_config(hass, config, domain): """Check component configuration and return processed configuration. - Raise a vol.Invalid exception on error. + Returns None on error. This method must be run in the event loop. """ diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ecbd7ca22eb..4e80b3c6536 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -1,17 +1,23 @@ -"""Script to ensure a configuration file exists.""" +"""Script to check the configuration file.""" import argparse import logging import os -from collections import OrderedDict +from collections import OrderedDict, namedtuple from glob import glob from platform import system from unittest.mock import patch +import attr from typing import Dict, List, Sequence +import voluptuous as vol -from homeassistant.core import callback -from homeassistant import bootstrap, loader, setup, config as config_util +from homeassistant import bootstrap, core, loader +from homeassistant.config import ( + get_default_config_dir, CONF_CORE, CORE_CONFIG_SCHEMA, + CONF_PACKAGES, merge_packages_config, _format_config_error, + find_config_file, load_yaml_config_file, get_component, + extract_domain_configs, config_per_platform, get_platform) import homeassistant.util.yaml as yaml from homeassistant.exceptions import HomeAssistantError @@ -24,35 +30,18 @@ _LOGGER = logging.getLogger(__name__) MOCKS = { 'load': ("homeassistant.util.yaml.load_yaml", yaml.load_yaml), 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml), - 'get': ("homeassistant.loader.get_component", loader.get_component), 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml), - 'except': ("homeassistant.config.async_log_exception", - config_util.async_log_exception), - 'package_error': ("homeassistant.config._log_pkg_error", - config_util._log_pkg_error), - 'logger_exception': ("homeassistant.setup._LOGGER.error", - setup._LOGGER.error), - 'logger_exception_bootstrap': ("homeassistant.bootstrap._LOGGER.error", - bootstrap._LOGGER.error), } SILENCE = ( - 'homeassistant.bootstrap.async_enable_logging', # callback - 'homeassistant.bootstrap.clear_secret_cache', - 'homeassistant.bootstrap.async_register_signal_handling', # callback - 'homeassistant.config.process_ha_config_upgrade', + 'homeassistant.scripts.check_config.yaml.clear_secret_cache', ) + PATCHES = {} C_HEAD = 'bold' ERROR_STR = 'General Errors' -@callback -def mock_cb(*args): - """Callback that returns None.""" - return None - - def color(the_color, *args, reset=None): """Color helper.""" from colorlog.escape_codes import escape_codes, parse_colors @@ -74,11 +63,11 @@ def run(script_args: List) -> int: '--script', choices=['check_config']) parser.add_argument( '-c', '--config', - default=config_util.get_default_config_dir(), + default=get_default_config_dir(), help="Directory that contains the Home Assistant configuration") parser.add_argument( - '-i', '--info', - default=None, + '-i', '--info', nargs='?', + default=None, const='all', help="Show a portion of the config") parser.add_argument( '-f', '--files', @@ -89,21 +78,20 @@ def run(script_args: List) -> int: action='store_true', help="Show secret information") - args = parser.parse_args() + args, unknown = parser.parse_known_args() + if unknown: + print(color('red', "Unknown arguments:", ', '.join(unknown))) config_dir = os.path.join(os.getcwd(), args.config) - config_path = os.path.join(config_dir, 'configuration.yaml') - if not os.path.isfile(config_path): - print('Config does not exist:', config_path) - return 1 print(color('bold', "Testing configuration at", config_dir)) + res = check(config_dir, args.secrets) + domain_info = [] if args.info: domain_info = args.info.split(',') - res = check(config_path) if args.files: print(color(C_HEAD, 'yaml files'), '(used /', color('red', 'not used') + ')') @@ -158,59 +146,23 @@ def run(script_args: List) -> int: return len(res['except']) -def check(config_path): +def check(config_dir, secrets=False): """Perform a check by mocking hass load functions.""" - logging.getLogger('homeassistant.core').setLevel(logging.WARNING) - logging.getLogger('homeassistant.loader').setLevel(logging.WARNING) - logging.getLogger('homeassistant.setup').setLevel(logging.WARNING) - logging.getLogger('homeassistant.bootstrap').setLevel(logging.ERROR) - logging.getLogger('homeassistant.util.yaml').setLevel(logging.INFO) + logging.getLogger('homeassistant.loader').setLevel(logging.CRITICAL) res = { 'yaml_files': OrderedDict(), # yaml_files loaded 'secrets': OrderedDict(), # secret cache and secrets loaded 'except': OrderedDict(), # exceptions raised (with config) - 'components': OrderedDict(), # successful components - 'secret_cache': OrderedDict(), + 'components': None, # successful components + 'secret_cache': None, } # pylint: disable=unused-variable def mock_load(filename): - """Mock hass.util.load_yaml to save config files.""" + """Mock hass.util.load_yaml to save config file names.""" res['yaml_files'][filename] = True return MOCKS['load'][1](filename) - # pylint: disable=unused-variable - def mock_get(comp_name): - """Mock hass.loader.get_component to replace setup & setup_platform.""" - async def mock_async_setup(*args): - """Mock setup, only record the component name & config.""" - assert comp_name not in res['components'], \ - "Components should contain a list of platforms" - res['components'][comp_name] = args[1].get(comp_name) - return True - module = MOCKS['get'][1](comp_name) - - if module is None: - # Ensure list - msg = '{} not found: {}'.format( - 'Platform' if '.' in comp_name else 'Component', comp_name) - res['except'].setdefault(ERROR_STR, []).append(msg) - return None - - # Test if platform/component and overwrite setup - if '.' in comp_name: - module.async_setup_platform = mock_async_setup - - if hasattr(module, 'setup_platform'): - del module.setup_platform - else: - module.async_setup = mock_async_setup - - if hasattr(module, 'setup'): - del module.setup - - return module - # pylint: disable=unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" @@ -221,37 +173,14 @@ def check(config_path): res['secrets'][node.value] = val return val - def mock_except(ex, domain, config, # pylint: disable=unused-variable - hass=None): - """Mock config.log_exception.""" - MOCKS['except'][1](ex, domain, config, hass) - res['except'][domain] = config.get(domain, config) - - def mock_package_error( # pylint: disable=unused-variable - package, component, config, message): - """Mock config_util._log_pkg_error.""" - MOCKS['package_error'][1](package, component, config, message) - - pkg_key = 'homeassistant.packages.{}'.format(package) - res['except'][pkg_key] = config.get('homeassistant', {}) \ - .get('packages', {}).get(package) - - def mock_logger_exception(msg, *params): - """Log logger.exceptions.""" - res['except'].setdefault(ERROR_STR, []).append(msg % params) - MOCKS['logger_exception'][1](msg, *params) - - def mock_logger_exception_bootstrap(msg, *params): - """Log logger.exceptions.""" - res['except'].setdefault(ERROR_STR, []).append(msg % params) - MOCKS['logger_exception_bootstrap'][1](msg, *params) - # Patches to skip functions for sil in SILENCE: - PATCHES[sil] = patch(sil, return_value=mock_cb()) + PATCHES[sil] = patch(sil) # Patches with local mock functions for key, val in MOCKS.items(): + if not secrets and key == 'secrets': + continue # The * in the key is removed to find the mock_function (side_effect) # This allows us to use one side_effect to patch multiple locations mock_function = locals()['mock_' + key.replace('*', '')] @@ -260,22 +189,42 @@ def check(config_path): # Start all patches for pat in PATCHES.values(): pat.start() - # Ensure !secrets point to the patched function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + + if secrets: + # Ensure !secrets point to the patched function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) try: - with patch('homeassistant.util.logging.AsyncHandler._process'): - bootstrap.from_config_file(config_path, skip_pip=True) - res['secret_cache'] = dict(yaml.__SECRET_CACHE) + class HassConfig(): + """Hass object with config.""" + + def __init__(self, conf_dir): + """Init the config_dir.""" + self.config = core.Config() + self.config.config_dir = conf_dir + + loader.prepare(HassConfig(config_dir)) + + res['components'] = check_ha_config_file(config_dir) + + res['secret_cache'] = OrderedDict(yaml.__SECRET_CACHE) + + for err in res['components'].errors: + domain = err.domain or ERROR_STR + res['except'].setdefault(domain, []).append(err.message) + if err.config: + res['except'].setdefault(domain, []).append(err.config) + except Exception as err: # pylint: disable=broad-except print(color('red', 'Fatal error while loading config:'), str(err)) - res['except'].setdefault(ERROR_STR, []).append(err) + res['except'].setdefault(ERROR_STR, []).append(str(err)) finally: # Stop all patches for pat in PATCHES.values(): pat.stop() - # Ensure !secrets point to the original function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + if secrets: + # Ensure !secrets point to the original function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) bootstrap.clear_secret_cache() return res @@ -317,3 +266,125 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): dump_dict(i, indent_count + 2, True) else: print(' ', indent_str, i) + + +CheckConfigError = namedtuple( # pylint: disable=invalid-name + 'CheckConfigError', "message domain config") + + +@attr.s +class HomeAssistantConfig(OrderedDict): + """Configuration result with errors attribute.""" + + errors = attr.ib(default=attr.Factory(list)) + + def add_error(self, message, domain=None, config=None): + """Add a single error.""" + self.errors.append(CheckConfigError(str(message), domain, config)) + return self + + +def check_ha_config_file(config_dir): + """Check if Home Assistant configuration file is valid.""" + result = HomeAssistantConfig() + + def _pack_error(package, component, config, message): + """Handle errors from packages: _log_pkg_error.""" + message = "Package {} setup failed. Component {} {}".format( + package, component, message) + domain = 'homeassistant.packages.{}.{}'.format(package, component) + pack_config = core_config[CONF_PACKAGES].get(package, config) + result.add_error(message, domain, pack_config) + + def _comp_error(ex, domain, config): + """Handle errors from components: async_log_exception.""" + result.add_error( + _format_config_error(ex, domain, config), domain, config) + + # Load configuration.yaml + try: + config_path = find_config_file(config_dir) + if not config_path: + return result.add_error("File configuration.yaml not found.") + config = load_yaml_config_file(config_path) + except HomeAssistantError as err: + return result.add_error(err) + finally: + yaml.clear_secret_cache() + + # Extract and validate core [homeassistant] config + try: + core_config = config.pop(CONF_CORE, {}) + core_config = CORE_CONFIG_SCHEMA(core_config) + result[CONF_CORE] = core_config + except vol.Invalid as err: + result.add_error(err, CONF_CORE, core_config) + core_config = {} + + # Merge packages + merge_packages_config( + config, core_config.get(CONF_PACKAGES, {}), _pack_error) + del core_config[CONF_PACKAGES] + + # Filter out repeating config sections + components = set(key.split(' ')[0] for key in config.keys()) + + # Process and validate config + for domain in components: + component = get_component(domain) + if not component: + result.add_error("Component not found: {}".format(domain)) + continue + + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + result[domain] = config[domain] + except vol.Invalid as ex: + _comp_error(ex, domain, config) + continue + + if not hasattr(component, 'PLATFORM_SCHEMA'): + continue + + platforms = [] + for p_name, p_config in config_per_platform(config, domain): + # Validate component specific platform schema + try: + p_validated = component.PLATFORM_SCHEMA(p_config) + except vol.Invalid as ex: + _comp_error(ex, domain, config) + continue + + # Not all platform components follow same pattern for platforms + # So if p_name is None we are not going to validate platform + # (the automation component is one of them) + if p_name is None: + platforms.append(p_validated) + continue + + platform = get_platform(domain, p_name) + + if platform is None: + result.add_error( + "Platform not found: {}.{}".format(domain, p_name)) + continue + + # Validate platform specific schema + if hasattr(platform, 'PLATFORM_SCHEMA'): + # pylint: disable=no-member + try: + p_validated = platform.PLATFORM_SCHEMA(p_validated) + except vol.Invalid as ex: + _comp_error( + ex, '{}.{}'.format(domain, p_name), p_validated) + continue + + platforms.append(p_validated) + + # Remove config for current component and add validated config back in. + for filter_comp in extract_domain_configs(config, domain): + del config[filter_comp] + result[domain] = platforms + + return result diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 728e683a43a..677ed8de110 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -1,9 +1,12 @@ """Test check_config script.""" import asyncio import logging +import os # noqa: F401 pylint: disable=unused-import import unittest +from unittest.mock import patch import homeassistant.scripts.check_config as check_config +from homeassistant.config import YAML_CONFIG_FILE from homeassistant.loader import set_component from tests.common import patch_yaml_files, get_test_config_dir @@ -21,21 +24,14 @@ BASE_CONFIG = ( ) -def change_yaml_files(check_dict): - """Change the ['yaml_files'] property and remove the configuration path. - - Also removes other files like service.yaml that gets loaded. - """ +def normalize_yaml_files(check_dict): + """Remove configuration path from ['yaml_files'].""" root = get_test_config_dir() - keys = check_dict['yaml_files'].keys() - check_dict['yaml_files'] = [] - for key in sorted(keys): - if not key.startswith('/'): - check_dict['yaml_files'].append(key) - if key.startswith(root): - check_dict['yaml_files'].append('...' + key[len(root):]) + return [key.replace(root, '...') + for key in sorted(check_dict['yaml_files'].keys())] +# pylint: disable=unsubscriptable-object class TestCheckConfig(unittest.TestCase): """Tests for the homeassistant.scripts.check_config module.""" @@ -51,176 +47,165 @@ class TestCheckConfig(unittest.TestCase): asyncio.set_event_loop(asyncio.new_event_loop()) # Will allow seeing full diff - self.maxDiff = None + self.maxDiff = None # pylint: disable=invalid-name # pylint: disable=no-self-use,invalid-name - def test_config_platform_valid(self): + @patch('os.path.isfile', return_value=True) + def test_config_platform_valid(self, isfile_patch): """Test a valid platform setup.""" files = { - 'light.yaml': BASE_CONFIG + 'light:\n platform: demo', + YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: demo', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('light.yaml')) - change_yaml_files(res) - self.assertDictEqual({ - 'components': {'light': [{'platform': 'demo'}], 'group': None}, - 'except': {}, - 'secret_cache': {}, - 'secrets': {}, - 'yaml_files': ['.../light.yaml'] - }, res) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant', 'light'} + assert res['components']['light'] == [{'platform': 'demo'}] + assert res['except'] == {} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_config_component_platform_fail_validation(self): + @patch('os.path.isfile', return_value=True) + def test_config_component_platform_fail_validation(self, isfile_patch): """Test errors if component & platform not found.""" files = { - 'component.yaml': BASE_CONFIG + 'http:\n password: err123', + YAML_CONFIG_FILE: BASE_CONFIG + 'http:\n password: err123', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('component.yaml')) - change_yaml_files(res) - - self.assertDictEqual({}, res['components']) - res['except'].pop(check_config.ERROR_STR) - self.assertDictEqual( - {'http': {'password': 'err123'}}, - res['except'] - ) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../component.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant'} + assert res['except'].keys() == {'http'} + assert res['except']['http'][1] == {'http': {'password': 'err123'}} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 files = { - 'platform.yaml': (BASE_CONFIG + 'mqtt:\n\n' - 'light:\n platform: mqtt_json'), + YAML_CONFIG_FILE: (BASE_CONFIG + 'mqtt:\n\n' + 'light:\n platform: mqtt_json'), } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('platform.yaml')) - change_yaml_files(res) - self.assertDictEqual( - {'mqtt': { - 'keepalive': 60, - 'port': 1883, - 'protocol': '3.1.1', - 'discovery': False, - 'discovery_prefix': 'homeassistant', - 'tls_version': 'auto', - }, - 'light': [], - 'group': None}, - res['components'] - ) - self.assertDictEqual( - {'light.mqtt_json': {'platform': 'mqtt_json'}}, - res['except'] - ) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../platform.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == { + 'homeassistant', 'light', 'mqtt'} + assert res['components']['light'] == [] + assert res['components']['mqtt'] == { + 'keepalive': 60, + 'port': 1883, + 'protocol': '3.1.1', + 'discovery': False, + 'discovery_prefix': 'homeassistant', + 'tls_version': 'auto', + } + assert res['except'].keys() == {'light.mqtt_json'} + assert res['except']['light.mqtt_json'][1] == { + 'platform': 'mqtt_json'} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_component_platform_not_found(self): + @patch('os.path.isfile', return_value=True) + def test_component_platform_not_found(self, isfile_patch): """Test errors if component or platform not found.""" # Make sure they don't exist set_component('beer', None) - set_component('light.beer', None) files = { - 'badcomponent.yaml': BASE_CONFIG + 'beer:', - 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer', + YAML_CONFIG_FILE: BASE_CONFIG + 'beer:', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('badcomponent.yaml')) - change_yaml_files(res) - self.assertDictEqual({}, res['components']) - self.assertDictEqual({ - check_config.ERROR_STR: [ - 'Component not found: beer', - 'Setup failed for beer: Component not found.'] - }, res['except']) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../badcomponent.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant'} + assert res['except'] == { + check_config.ERROR_STR: ['Component not found: beer']} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - res = check_config.check(get_test_config_dir('badplatform.yaml')) - change_yaml_files(res) - assert res['components'] == {'light': [], 'group': None} + set_component('light.beer', None) + files = { + YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: beer', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant', 'light'} + assert res['components']['light'] == [] assert res['except'] == { check_config.ERROR_STR: [ 'Platform not found: light.beer', ]} - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../badplatform.yaml'], res['yaml_files']) + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_secrets(self): + @patch('os.path.isfile', return_value=True) + def test_secrets(self, isfile_patch): """Test secrets config checking method.""" + secrets_path = get_test_config_dir('secrets.yaml') + files = { - get_test_config_dir('secret.yaml'): ( - BASE_CONFIG + + get_test_config_dir(YAML_CONFIG_FILE): BASE_CONFIG + ( 'http:\n' ' api_password: !secret http_pw'), - 'secrets.yaml': ('logger: debug\n' - 'http_pw: abc123'), + secrets_path: ( + 'logger: debug\n' + 'http_pw: abc123'), } with patch_yaml_files(files): - config_path = get_test_config_dir('secret.yaml') - secrets_path = get_test_config_dir('secrets.yaml') - res = check_config.check(config_path) - change_yaml_files(res) + res = check_config.check(get_test_config_dir(), True) - # convert secrets OrderedDict to dict for assertequal - for key, val in res['secret_cache'].items(): - res['secret_cache'][key] = dict(val) + assert res['except'] == {} + assert res['components'].keys() == {'homeassistant', 'http'} + assert res['components']['http'] == { + 'api_password': 'abc123', + 'cors_allowed_origins': [], + 'ip_ban_enabled': True, + 'login_attempts_threshold': -1, + 'server_host': '0.0.0.0', + 'server_port': 8123, + 'trusted_networks': [], + 'use_x_forwarded_for': False} + assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}} + assert res['secrets'] == {'http_pw': 'abc123'} + assert normalize_yaml_files(res) == [ + '.../configuration.yaml', '.../secrets.yaml'] - self.assertDictEqual({ - 'components': {'http': {'api_password': 'abc123', - 'cors_allowed_origins': [], - 'ip_ban_enabled': True, - 'login_attempts_threshold': -1, - 'server_host': '0.0.0.0', - 'server_port': 8123, - 'trusted_networks': [], - 'use_x_forwarded_for': False}}, - 'except': {}, - 'secret_cache': {secrets_path: {'http_pw': 'abc123'}}, - 'secrets': {'http_pw': 'abc123'}, - 'yaml_files': ['.../secret.yaml', '.../secrets.yaml'] - }, res) - - def test_package_invalid(self): \ + @patch('os.path.isfile', return_value=True) + def test_package_invalid(self, isfile_patch): \ # pylint: disable=no-self-use,invalid-name """Test a valid platform setup.""" files = { - 'bad.yaml': BASE_CONFIG + (' packages:\n' - ' p1:\n' - ' group: ["a"]'), + YAML_CONFIG_FILE: BASE_CONFIG + ( + ' packages:\n' + ' p1:\n' + ' group: ["a"]'), } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('bad.yaml')) - change_yaml_files(res) + res = check_config.check(get_test_config_dir()) - err = res['except'].pop('homeassistant.packages.p1') - assert res['except'] == {} - assert err == {'group': ['a']} - assert res['yaml_files'] == ['.../bad.yaml'] - - assert res['components'] == {} + assert res['except'].keys() == {'homeassistant.packages.p1.group'} + assert res['except']['homeassistant.packages.p1.group'][1] == \ + {'group': ['a']} + assert len(res['except']) == 1 + assert res['components'].keys() == {'homeassistant'} + assert len(res['components']) == 1 assert res['secret_cache'] == {} assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 def test_bootstrap_error(self): \ # pylint: disable=no-self-use,invalid-name """Test a valid platform setup.""" files = { - 'badbootstrap.yaml': BASE_CONFIG + 'automation: !include no.yaml', + YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('badbootstrap.yaml')) - change_yaml_files(res) - + res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE)) err = res['except'].pop(check_config.ERROR_STR) assert len(err) == 1 assert res['except'] == {} - assert res['components'] == {} + assert res['components'] == {} # No components, load failed assert res['secret_cache'] == {} assert res['secrets'] == {} + assert res['yaml_files'] == {} diff --git a/tests/test_config.py b/tests/test_config.py index 541eaf4f79e..99c21493711 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -158,11 +158,11 @@ class TestConfig(unittest.TestCase): def test_load_yaml_config_preserves_key_order(self): """Test removal of library.""" with open(YAML_PATH, 'w') as f: - f.write('hello: 0\n') + f.write('hello: 2\n') f.write('world: 1\n') self.assertEqual( - [('hello', 0), ('world', 1)], + [('hello', 2), ('world', 1)], list(config_util.load_yaml_config_file(YAML_PATH).items())) @mock.patch('homeassistant.util.location.detect_location_info', From ebf4be3711426e099f3a1454f0210ff5302a299f Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Fri, 9 Mar 2018 16:50:21 +0000 Subject: [PATCH 188/191] Plex mark devices unavailable if they 'vanish' and clear media (#12811) * Marks Devices unavailable if they 'vanish' and clears media * Fixed PEP8 complaint * Fixed Linting * Lint Fix * Fix redine of id * More lint fixes * Removed redundant loop for setting availability of client Renamed '_is_device_available' to '_available' Renamed 'available_ids' to 'available_client_ids' * removed whitespace per houndCI --- homeassistant/components/media_player/plex.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a63bf8525ed..caa81424377 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -154,11 +154,14 @@ def setup_plexserver( return new_plex_clients = [] + available_client_ids = [] for device in devices: # For now, let's allow all deviceClass types if device.deviceClass in ['badClient']: continue + available_client_ids.append(device.machineIdentifier) + if device.machineIdentifier not in plex_clients: new_client = PlexClient(config, device, None, plex_sessions, update_devices, @@ -186,6 +189,9 @@ def setup_plexserver( if client.session is None: client.force_idle() + client.set_availability(client.machine_identifier + in available_client_ids) + if new_plex_clients: add_devices_callback(new_plex_clients) @@ -259,6 +265,7 @@ class PlexClient(MediaPlayerDevice): """Initialize the Plex device.""" self._app_name = '' self._device = None + self._available = False self._device_protocol_capabilities = None self._is_player_active = False self._is_player_available = False @@ -407,6 +414,12 @@ class PlexClient(MediaPlayerDevice): self._media_image_url = thumb_url + def set_availability(self, available): + """Set the device as available/unavailable noting time.""" + if not available: + self._clear_media_details() + self._available = available + def _set_player_state(self): if self._player_state == 'playing': self._is_player_active = True @@ -468,6 +481,11 @@ class PlexClient(MediaPlayerDevice): """Return the id of this plex client.""" return self.machine_identifier + @property + def available(self): + """Return the availability of the client.""" + return self._available + @property def name(self): """Return the name of the device.""" From efdc7042df2fe95a84a6ff20ea35435c7bbafd5a Mon Sep 17 00:00:00 2001 From: mueslo Date: Fri, 9 Mar 2018 08:57:21 +0100 Subject: [PATCH 189/191] Add consider_home and source_type to device_tracker.see service (#12849) * Add consider_home and source_type to device_tracker.see service * Use schema instead of manual validation * Extend schema to validate all keys * Fix style * Set battery level to int --- .../components/device_tracker/__init__.py | 64 ++++++++++++------- homeassistant/helpers/config_validation.py | 1 + 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 19ab77350f3..196c11a614f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -77,11 +77,14 @@ ATTR_MAC = 'mac' ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' ATTR_VENDOR = 'vendor' +ATTR_CONSIDER_HOME = 'consider_home' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' SOURCE_TYPE_BLUETOOTH = 'bluetooth' SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' +SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, @@ -96,6 +99,19 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA }) +SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), { + ATTR_MAC: cv.string, + ATTR_DEV_ID: cv.string, + ATTR_HOST_NAME: cv.string, + ATTR_LOCATION_NAME: cv.string, + ATTR_GPS: cv.gps, + ATTR_GPS_ACCURACY: cv.positive_int, + ATTR_BATTERY: cv.positive_int, + ATTR_ATTRIBUTES: dict, + ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES), + ATTR_CONSIDER_HOME: cv.time_period, + })) @bind_hass @@ -109,7 +125,7 @@ def is_on(hass: HomeAssistantType, entity_id: str = None): def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, host_name: str = None, location_name: str = None, gps: GPSType = None, gps_accuracy=None, - battery=None, attributes: dict = None): + battery: int = None, attributes: dict = None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -203,12 +219,10 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): @asyncio.coroutine def async_see_service(call): """Service to see a device.""" - args = {key: value for key, value in call.data.items() if key in - (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} - yield from tracker.async_see(**args) + yield from tracker.async_see(**call.data) - hass.services.async_register(DOMAIN, SERVICE_SEE, async_see_service) + hass.services.async_register( + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) # restore yield from tracker.async_setup_tracked_device() @@ -240,23 +254,26 @@ class DeviceTracker(object): dev.mac) def see(self, mac: str = None, dev_id: str = None, host_name: str = None, - location_name: str = None, gps: GPSType = None, gps_accuracy=None, - battery: str = None, attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS, picture: str = None, - icon: str = None): + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device.""" self.hass.add_job( self.async_see(mac, dev_id, host_name, location_name, gps, gps_accuracy, battery, attributes, source_type, - picture, icon) + picture, icon, consider_home) ) @asyncio.coroutine - def async_see(self, mac: str = None, dev_id: str = None, - host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=None, battery: str = None, - attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, icon: str = None): + def async_see( + self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device. This method is a coroutine. @@ -275,7 +292,7 @@ class DeviceTracker(object): if device: yield from device.async_seen( host_name, location_name, gps, gps_accuracy, battery, - attributes, source_type) + attributes, source_type, consider_home) if device.track: yield from device.async_update_ha_state() return @@ -283,7 +300,7 @@ class DeviceTracker(object): # If no device can be found, create it dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) device = Device( - self.hass, self.consider_home, self.track_new, + self.hass, consider_home or self.consider_home, self.track_new, dev_id, mac, (host_name or dev_id).replace('_', ' '), picture=picture, icon=icon, hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) @@ -384,9 +401,10 @@ class Device(Entity): host_name = None # type: str location_name = None # type: str gps = None # type: GPSType - gps_accuracy = 0 + gps_accuracy = 0 # type: int last_seen = None # type: dt_util.dt.datetime - battery = None # type: str + consider_home = None # type: dt_util.dt.timedelta + battery = None # type: int attributes = None # type: dict vendor = None # type: str icon = None # type: str @@ -476,14 +494,16 @@ class Device(Entity): @asyncio.coroutine def async_seen(self, host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=0, battery: str = None, + gps: GPSType = None, gps_accuracy=0, battery: int = None, attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS): + source_type: str = SOURCE_TYPE_GPS, + consider_home: timedelta = None): """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name + self.consider_home = consider_home or self.consider_home if battery: self.battery = battery diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f8f08fd118f..4b7c58f6e66 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -36,6 +36,7 @@ latitude = vol.All(vol.Coerce(float), vol.Range(min=-90, max=90), msg='invalid latitude') longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180), msg='invalid longitude') +gps = vol.ExactSequence([latitude, longitude]) sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)) port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) From 4152ac4aa21d51d3848203956325e9fdba5dcb07 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 9 Mar 2018 15:15:39 +0100 Subject: [PATCH 190/191] Clean up Light Groups (#12962) * Clean up Light Groups * Fix tests * Remove light group from .coveragerc * async_schedule_update_ha_state called anyway --- .coveragerc | 1 - homeassistant/components/light/group.py | 39 ++++++------- tests/components/light/test_group.py | 76 ++++++++++++------------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/.coveragerc b/.coveragerc index 83d143f83cb..07d84523780 100644 --- a/.coveragerc +++ b/.coveragerc @@ -412,7 +412,6 @@ omit = homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py homeassistant/components/light/greenwave.py - homeassistant/components/light/group.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py homeassistant/components/light/iglo.py diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py index 768754ca1af..b4a5e9dddfb 100644 --- a/homeassistant/components/light/group.py +++ b/homeassistant/components/light/group.py @@ -1,5 +1,5 @@ """ -This component allows several lights to be grouped into one light. +This platform allows several lights to be grouped into one light. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.group/ @@ -29,11 +29,11 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Group Light' +DEFAULT_NAME = 'Light Group' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_ENTITIES): cv.entities_domain('light') + vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN) }) SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT @@ -44,15 +44,15 @@ SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_devices, discovery_info=None) -> None: """Initialize light.group platform.""" - async_add_devices([GroupLight(config.get(CONF_NAME), - config[CONF_ENTITIES])], True) + async_add_devices([LightGroup(config.get(CONF_NAME), + config[CONF_ENTITIES])]) -class GroupLight(light.Light): - """Representation of a group light.""" +class LightGroup(light.Light): + """Representation of a light group.""" def __init__(self, name: str, entity_ids: List[str]) -> None: - """Initialize a group light.""" + """Initialize a light group.""" self._name = name # type: str self._entity_ids = entity_ids # type: List[str] self._is_on = False # type: bool @@ -79,10 +79,11 @@ class GroupLight(light.Light): self._async_unsub_state_changed = async_track_state_change( self.hass, self._entity_ids, async_state_changed_listener) + await self.async_update() async def async_will_remove_from_hass(self): """Callback when removed from HASS.""" - if self._async_unsub_state_changed: + if self._async_unsub_state_changed is not None: self._async_unsub_state_changed() self._async_unsub_state_changed = None @@ -93,17 +94,17 @@ class GroupLight(light.Light): @property def is_on(self) -> bool: - """Return the on/off state of the light.""" + """Return the on/off state of the light group.""" return self._is_on @property def available(self) -> bool: - """Return whether the light is available.""" + """Return whether the light group is available.""" return self._available @property def brightness(self) -> Optional[int]: - """Return the brightness of this light between 0..255.""" + """Return the brightness of this light group between 0..255.""" return self._brightness @property @@ -123,17 +124,17 @@ class GroupLight(light.Light): @property def min_mireds(self) -> Optional[int]: - """Return the coldest color_temp that this light supports.""" + """Return the coldest color_temp that this light group supports.""" return self._min_mireds @property def max_mireds(self) -> Optional[int]: - """Return the warmest color_temp that this light supports.""" + """Return the warmest color_temp that this light group supports.""" return self._max_mireds @property def white_value(self) -> Optional[int]: - """Return the white value of this light between 0..255.""" + """Return the white value of this light group between 0..255.""" return self._white_value @property @@ -153,11 +154,11 @@ class GroupLight(light.Light): @property def should_poll(self) -> bool: - """No polling needed for a group light.""" + """No polling needed for a light group.""" return False async def async_turn_on(self, **kwargs): - """Forward the turn_on command to all lights in the group.""" + """Forward the turn_on command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} if ATTR_BRIGHTNESS in kwargs: @@ -188,7 +189,7 @@ class GroupLight(light.Light): light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True) async def async_turn_off(self, **kwargs): - """Forward the turn_off command to all lights in the group.""" + """Forward the turn_off command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} if ATTR_TRANSITION in kwargs: @@ -198,7 +199,7 @@ class GroupLight(light.Light): light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True) async def async_update(self): - """Query all members and determine the group state.""" + """Query all members and determine the light group state.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] states = list(filter(None, all_states)) on_states = [state for state in states if state.state == STATE_ON] diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py index ac19f407066..3c94fa2af3e 100644 --- a/tests/components/light/test_group.py +++ b/tests/components/light/test_group.py @@ -37,22 +37,22 @@ async def test_state_reporting(hass): hass.states.async_set('light.test1', 'on') hass.states.async_set('light.test2', 'unavailable') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'on' + assert hass.states.get('light.light_group').state == 'on' hass.states.async_set('light.test1', 'on') hass.states.async_set('light.test2', 'off') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'on' + assert hass.states.get('light.light_group').state == 'on' hass.states.async_set('light.test1', 'off') hass.states.async_set('light.test2', 'off') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'off' + assert hass.states.get('light.light_group').state == 'off' hass.states.async_set('light.test1', 'unavailable') hass.states.async_set('light.test2', 'unavailable') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'unavailable' + assert hass.states.get('light.light_group').state == 'unavailable' async def test_brightness(hass): @@ -64,7 +64,7 @@ async def test_brightness(hass): hass.states.async_set('light.test1', 'on', {'brightness': 255, 'supported_features': 1}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 1 assert state.attributes['brightness'] == 255 @@ -72,14 +72,14 @@ async def test_brightness(hass): hass.states.async_set('light.test2', 'on', {'brightness': 100, 'supported_features': 1}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['brightness'] == 177 hass.states.async_set('light.test1', 'off', {'brightness': 255, 'supported_features': 1}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 1 assert state.attributes['brightness'] == 100 @@ -94,7 +94,7 @@ async def test_xy_color(hass): hass.states.async_set('light.test1', 'on', {'xy_color': (1.0, 1.0), 'supported_features': 64}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 64 assert state.attributes['xy_color'] == (1.0, 1.0) @@ -102,14 +102,14 @@ async def test_xy_color(hass): hass.states.async_set('light.test2', 'on', {'xy_color': (0.5, 0.5), 'supported_features': 64}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['xy_color'] == (0.75, 0.75) hass.states.async_set('light.test1', 'off', {'xy_color': (1.0, 1.0), 'supported_features': 64}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['xy_color'] == (0.5, 0.5) @@ -123,7 +123,7 @@ async def test_rgb_color(hass): hass.states.async_set('light.test1', 'on', {'rgb_color': (255, 0, 0), 'supported_features': 16}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 16 assert state.attributes['rgb_color'] == (255, 0, 0) @@ -132,13 +132,13 @@ async def test_rgb_color(hass): {'rgb_color': (255, 255, 255), 'supported_features': 16}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['rgb_color'] == (255, 127, 127) hass.states.async_set('light.test1', 'off', {'rgb_color': (255, 0, 0), 'supported_features': 16}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['rgb_color'] == (255, 255, 255) @@ -151,19 +151,19 @@ async def test_white_value(hass): hass.states.async_set('light.test1', 'on', {'white_value': 255, 'supported_features': 128}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['white_value'] == 255 hass.states.async_set('light.test2', 'on', {'white_value': 100, 'supported_features': 128}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['white_value'] == 177 hass.states.async_set('light.test1', 'off', {'white_value': 255, 'supported_features': 128}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['white_value'] == 100 @@ -176,19 +176,19 @@ async def test_color_temp(hass): hass.states.async_set('light.test1', 'on', {'color_temp': 2, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['color_temp'] == 2 hass.states.async_set('light.test2', 'on', {'color_temp': 1000, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['color_temp'] == 501 hass.states.async_set('light.test1', 'off', {'color_temp': 2, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['color_temp'] == 1000 @@ -202,7 +202,7 @@ async def test_min_max_mireds(hass): {'min_mireds': 2, 'max_mireds': 5, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['min_mireds'] == 2 assert state.attributes['max_mireds'] == 5 @@ -210,7 +210,7 @@ async def test_min_max_mireds(hass): {'min_mireds': 7, 'max_mireds': 1234567890, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['min_mireds'] == 2 assert state.attributes['max_mireds'] == 1234567890 @@ -218,7 +218,7 @@ async def test_min_max_mireds(hass): {'min_mireds': 1, 'max_mireds': 2, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['min_mireds'] == 1 assert state.attributes['max_mireds'] == 1234567890 @@ -232,21 +232,21 @@ async def test_effect_list(hass): hass.states.async_set('light.test1', 'on', {'effect_list': ['None', 'Random', 'Colorloop']}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop'} hass.states.async_set('light.test2', 'on', {'effect_list': ['None', 'Random', 'Rainbow']}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop', 'Rainbow'} hass.states.async_set('light.test1', 'off', {'effect_list': ['None', 'Colorloop', 'Seven']}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop', 'Seven', 'Rainbow'} @@ -261,19 +261,19 @@ async def test_effect(hass): hass.states.async_set('light.test1', 'on', {'effect': 'None', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test2', 'on', {'effect': 'None', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test3', 'on', {'effect': 'Random', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test1', 'off', @@ -281,7 +281,7 @@ async def test_effect(hass): hass.states.async_set('light.test2', 'off', {'effect': 'None', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'Random' @@ -294,25 +294,25 @@ async def test_supported_features(hass): hass.states.async_set('light.test1', 'on', {'supported_features': 0}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 0 hass.states.async_set('light.test2', 'on', {'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 2 hass.states.async_set('light.test1', 'off', {'supported_features': 41}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 43 hass.states.async_set('light.test2', 'off', {'supported_features': 256}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 41 @@ -326,29 +326,29 @@ async def test_service_calls(hass): ]}) await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'on' - light.async_toggle(hass, 'light.group_light') + assert hass.states.get('light.light_group').state == 'on' + light.async_toggle(hass, 'light.light_group') await hass.async_block_till_done() assert hass.states.get('light.bed_light').state == 'off' assert hass.states.get('light.ceiling_lights').state == 'off' assert hass.states.get('light.kitchen_lights').state == 'off' - light.async_turn_on(hass, 'light.group_light') + light.async_turn_on(hass, 'light.light_group') await hass.async_block_till_done() assert hass.states.get('light.bed_light').state == 'on' assert hass.states.get('light.ceiling_lights').state == 'on' assert hass.states.get('light.kitchen_lights').state == 'on' - light.async_turn_off(hass, 'light.group_light') + light.async_turn_off(hass, 'light.light_group') await hass.async_block_till_done() assert hass.states.get('light.bed_light').state == 'off' assert hass.states.get('light.ceiling_lights').state == 'off' assert hass.states.get('light.kitchen_lights').state == 'off' - light.async_turn_on(hass, 'light.group_light', brightness=128, + light.async_turn_on(hass, 'light.light_group', brightness=128, effect='Random', rgb_color=(42, 255, 255)) await hass.async_block_till_done() From d19a8ec7da88a55bcff975064638ba5defe406ff Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Fri, 9 Mar 2018 16:50:39 +0000 Subject: [PATCH 191/191] Updated to plexapi 3.0.6 (#13005) --- homeassistant/components/media_player/plex.py | 2 +- homeassistant/components/sensor/plex.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index caa81424377..48e532074f7 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['plexapi==3.0.5'] +REQUIREMENTS = ['plexapi==3.0.6'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index b0c40e8f007..87af51d2bbd 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['plexapi==3.0.5'] +REQUIREMENTS = ['plexapi==3.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b7af19f0d66..c233e528403 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -588,7 +588,7 @@ pizzapi==0.0.3 # homeassistant.components.media_player.plex # homeassistant.components.sensor.plex -plexapi==3.0.5 +plexapi==3.0.6 # homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm