From 6fa8c2afe579728220532e30adb932d9eaee8f4e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 21 Sep 2017 21:18:43 -0700 Subject: [PATCH 001/113] Version bump to 0.54 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a92f0d68c9..7d5c501c426 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 54 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From a3a73b418ae43bffc0074bd171d867a30c1264f6 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Fri, 22 Sep 2017 13:37:16 -0700 Subject: [PATCH 002/113] Update AbodePy to 0.11.8 (#9537) * Update requirements_all.txt * Update abode.py --- homeassistant/components/abode.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 73c4756477b..fe35d7b1b8b 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -21,7 +21,7 @@ from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.11.7'] +REQUIREMENTS = ['abodepy==0.11.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d87abe0de66..b29e2442af8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.11.7 +abodepy==0.11.8 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.3 From 7da8cb225f888dff91cda466258b3d68fe1029da Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Sat, 23 Sep 2017 00:53:16 -0400 Subject: [PATCH 003/113] update usps (#9540) * update usps * fix syntax issue --- homeassistant/components/camera/usps.py | 2 +- homeassistant/components/sensor/usps.py | 6 +++--- homeassistant/components/usps.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/camera/usps.py b/homeassistant/components/camera/usps.py index 545ea9798de..6c76d0d66d8 100644 --- a/homeassistant/components/camera/usps.py +++ b/homeassistant/components/camera/usps.py @@ -77,7 +77,7 @@ class USPSCamera(Camera): def model(self): """Return date of mail as model.""" try: - return 'Date: {}'.format(self._usps.mail[0]['date']) + return 'Date: {}'.format(str(self._usps.mail[0]['date'])) except IndexError: return None diff --git a/homeassistant/components/sensor/usps.py b/homeassistant/components/sensor/usps.py index 322c27e2f37..cf7378186f4 100644 --- a/homeassistant/components/sensor/usps.py +++ b/homeassistant/components/sensor/usps.py @@ -11,7 +11,7 @@ from homeassistant.components.usps import DATA_USPS from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DATE from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -from homeassistant.util.dt import now, parse_datetime +from homeassistant.util.dt import now _LOGGER = logging.getLogger(__name__) @@ -57,7 +57,7 @@ class USPSPackageSensor(Entity): for package in self._usps.packages: status = slugify(package['primary_status']) if status == STATUS_DELIVERED and \ - parse_datetime(package['date']).date() < now().date(): + package['date'] < now().date(): continue status_counts[status] += 1 self._attributes = { @@ -116,7 +116,7 @@ class USPSMailSensor(Entity): attr = {} attr[ATTR_ATTRIBUTION] = self._usps.attribution try: - attr[ATTR_DATE] = self._usps.mail[0]['date'] + attr[ATTR_DATE] = str(self._usps.mail[0]['date']) except IndexError: pass return attr diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py index fdafbbc3587..21a2700cd5c 100644 --- a/homeassistant/components/usps.py +++ b/homeassistant/components/usps.py @@ -15,7 +15,7 @@ from homeassistant.helpers import (config_validation as cv, discovery) from homeassistant.util import Throttle from homeassistant.util.dt import now -REQUIREMENTS = ['myusps==1.1.3'] +REQUIREMENTS = ['myusps==1.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b29e2442af8..62d9cc4189b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ mutagen==1.38 mycroftapi==2.0 # homeassistant.components.usps -myusps==1.1.3 +myusps==1.2.1 # homeassistant.components.media_player.nad # homeassistant.components.media_player.nadtcp From 283cd80a7ff080cf26835cd09640cd6ff069a742 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Fri, 22 Sep 2017 22:00:35 -0400 Subject: [PATCH 004/113] Bump python_openzwave to 0.4.0.35 (#9542) * Bump python_openzwave to 0.4.0.35 * Cleanup --- homeassistant/components/zwave/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index c88c55e258f..0e6e41c63a5 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -35,7 +35,7 @@ from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS from .util import check_node_schema, check_value_schema, node_name -REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.0.31'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.0.35'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 62d9cc4189b..0e1976e89aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -806,7 +806,7 @@ python-wink==1.5.1 python_opendata_transport==0.0.2 # homeassistant.components.zwave -python_openzwave==0.4.0.31 +python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia pythonegardia==1.0.20 From d499c18e6330b824768adc17114f1b0643aea717 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Sep 2017 11:44:32 -0600 Subject: [PATCH 005/113] Fixes UPS MyChoice exception (#9587) * Fixes UPS MyChoice exception * Added unit of measurement * Collaborator-requested changes --- homeassistant/components/sensor/ups.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/ups.py b/homeassistant/components/sensor/ups.py index 40d84fe2618..c51ae67475f 100644 --- a/homeassistant/components/sensor/ups.py +++ b/homeassistant/components/sensor/ups.py @@ -76,17 +76,26 @@ class UPSSensor(Entity): """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 'packages' + def _update(self): """Update device state.""" import upsmychoice status_counts = defaultdict(int) - for package in upsmychoice.get_packages(self._session): - status = slugify(package['status']) - skip = status == STATUS_DELIVERED and \ - parse_date(package['delivery_date']) < now().date() - if skip: - continue - status_counts[status] += 1 + try: + for package in upsmychoice.get_packages(self._session): + status = slugify(package['status']) + skip = status == STATUS_DELIVERED and \ + parse_date(package['delivery_date']) < now().date() + if skip: + continue + status_counts[status] += 1 + except upsmychoice.UPSError: + _LOGGER.error('Could not connect to UPS My Choice account') + self._attributes = { ATTR_ATTRIBUTION: upsmychoice.ATTRIBUTION } From eb2338249fa657de974f62b2b102717851198fb3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Sep 2017 11:44:41 -0600 Subject: [PATCH 006/113] FedEx: Adds "packages" as a unit (#9588) * Adds "packages" as a unit * Collaborator-requested changes --- homeassistant/components/sensor/fedex.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 6b159760b3c..7991a94eb05 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -82,6 +82,11 @@ class FedexSensor(Entity): """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 'packages' + def _update(self): """Update device state.""" import fedexdeliverymanager From 7c8e7d6eb0feca7dfd4a26acc910fe4a0ec6e974 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 28 Sep 2017 01:21:39 +0200 Subject: [PATCH 007/113] Cleanup entity & remove warning (#9606) * Cleanup entity & remove warning * Update comment --- homeassistant/helpers/entity.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b2928e73070..d45c3c6b2f9 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -180,15 +180,6 @@ class Entity(object): # are used to perform a very specific function. Overwriting these may # produce undesirable effects in the entity's operation. - def update_ha_state(self, force_refresh=False): - """Update Home Assistant with current state of entity. - - If force_refresh == True will update entity before setting state. - """ - _LOGGER.warning("'update_ha_state' is deprecated. " - "Use 'schedule_update_ha_state' instead.") - self.schedule_update_ha_state(force_refresh) - @asyncio.coroutine def async_update_ha_state(self, force_refresh=False): """Update Home Assistant with current state of entity. @@ -207,8 +198,7 @@ class Entity(object): # update entity data if force_refresh: if self._update_warn: - _LOGGER.warning("Update for %s is already in progress", - self.entity_id) + # Update is already in progress. return self._update_warn = self.hass.loop.call_later( From 6fb55b363a1dd7f981ece03a9d329a75a8c3bbf4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 Sep 2017 00:49:35 -0700 Subject: [PATCH 008/113] Add OwnTracks over HTTP (#9582) * Add OwnTracks over HTTP * Fix tests --- .../components/device_tracker/owntracks.py | 21 ++++--- .../device_tracker/owntracks_http.py | 54 +++++++++++++++++ homeassistant/components/http/auth.py | 26 ++++++++ requirements_all.txt | 1 + .../device_tracker/test_owntracks_http.py | 60 +++++++++++++++++++ tests/components/http/test_auth.py | 44 ++++++++++++++ 6 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/device_tracker/owntracks_http.py create mode 100644 tests/components/device_tracker/test_owntracks_http.py diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 1c773f97692..07dc9f1ab5c 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -1,5 +1,5 @@ """ -Support the OwnTracks platform. +Device tracker platform that adds support for OwnTracks over MQTT. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ @@ -64,13 +64,7 @@ def get_cipher(): @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an OwnTracks tracker.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - - context = OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist) + context = context_from_config(async_see, config) @asyncio.coroutine def async_handle_mqtt_message(topic, payload, qos): @@ -179,6 +173,17 @@ def _decrypt_payload(secret, topic, ciphertext): return None +def context_from_config(async_see, config): + """Create an async context from Home Assistant config.""" + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) + + return OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist) + + class OwnTracksContext: """Hold the current OwnTracks context.""" diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py new file mode 100644 index 00000000000..dcc3300cc12 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -0,0 +1,54 @@ +""" +Device tracker platform that adds support for OwnTracks over HTTP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.owntracks_http/ +""" +import asyncio + +from aiohttp.web_exceptions import HTTPInternalServerError + +from homeassistant.components.http import HomeAssistantView + +# pylint: disable=unused-import +from .owntracks import ( # NOQA + REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) + + +DEPENDENCIES = ['http'] + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an OwnTracks tracker.""" + context = context_from_config(async_see, config) + + hass.http.register_view(OwnTracksView(context)) + + return True + + +class OwnTracksView(HomeAssistantView): + """View to handle OwnTracks HTTP requests.""" + + url = '/api/owntracks/{user}/{device}' + name = 'api:owntracks' + + def __init__(self, context): + """Initialize OwnTracks URL endpoints.""" + self.context = context + + @asyncio.coroutine + def post(self, request, user, device): + """Handle an OwnTracks message.""" + hass = request.app['hass'] + + message = yield from request.json() + message['topic'] = 'owntracks/{}/{}'.format(user, device) + + try: + yield from async_handle_message(hass, self.context, message) + return self.json([]) + + except ValueError: + raise HTTPInternalServerError diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a00da9ee5b6..4b971c883d3 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,8 +1,11 @@ """Authentication for HTTP component.""" import asyncio +import base64 import hmac import logging +from aiohttp import hdrs + from homeassistant.const import HTTP_HEADER_HA_AUTH from .util import get_real_ip from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED @@ -41,6 +44,10 @@ def auth_middleware(app, handler): validate_password(request, request.query[DATA_API_PASSWORD])): authenticated = True + elif (hdrs.AUTHORIZATION in request.headers and + validate_authorization_header(request)): + authenticated = True + elif is_trusted_ip(request): authenticated = True @@ -64,3 +71,22 @@ def validate_password(request, api_password): """Test if password is valid.""" return hmac.compare_digest( api_password, request.app['hass'].http.api_password) + + +def validate_authorization_header(request): + """Test an authorization header if valid password.""" + if hdrs.AUTHORIZATION not in request.headers: + return False + + auth_type, auth = request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + + if auth_type != 'Basic': + return False + + decoded = base64.b64decode(auth).decode('utf-8') + username, password = decoded.split(':', 1) + + if username != 'homeassistant': + return False + + return validate_password(request, password) diff --git a/requirements_all.txt b/requirements_all.txt index a5aab8adf00..5f28a48ac83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,6 +373,7 @@ jsonrpc-websocket==0.5 keyring>=9.3,<10.0 # homeassistant.components.device_tracker.owntracks +# homeassistant.components.device_tracker.owntracks_http libnacl==1.5.2 # homeassistant.components.dyson diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/device_tracker/test_owntracks_http.py new file mode 100644 index 00000000000..be8bdd94ecc --- /dev/null +++ b/tests/components/device_tracker/test_owntracks_http.py @@ -0,0 +1,60 @@ +"""Test the owntracks_http platform.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro, mock_component + + +@pytest.fixture +def mock_client(hass, test_client): + """Start the Hass HTTP component.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])): + hass.loop.run_until_complete( + async_setup_component(hass, 'device_tracker', { + 'device_tracker': { + 'platform': 'owntracks_http' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture +def mock_handle_message(): + """Mock async_handle_message.""" + with patch('homeassistant.components.device_tracker.' + 'owntracks_http.async_handle_message') as mock: + mock.return_value = mock_coro(None) + yield mock + + +@asyncio.coroutine +def test_forward_message_correctly(mock_client, mock_handle_message): + """Test that we forward messages correctly to OwnTracks handle message.""" + resp = yield from mock_client.post('/api/owntracks/user/device', json={ + '_type': 'test' + }) + assert resp.status == 200 + assert len(mock_handle_message.mock_calls) == 1 + + data = mock_handle_message.mock_calls[0][1][2] + assert data == { + '_type': 'test', + 'topic': 'owntracks/user/device' + } + + +@asyncio.coroutine +def test_handle_value_error(mock_client, mock_handle_message): + """Test that we handle errors from handle message correctly.""" + mock_handle_message.side_effect = ValueError + resp = yield from mock_client.post('/api/owntracks/user/device', json={ + '_type': 'test' + }) + assert resp.status == 500 diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 5db42b01371..ef9c63ad09e 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -4,6 +4,7 @@ import asyncio from ipaddress import ip_address, ip_network from unittest.mock import patch +import aiohttp import pytest from homeassistant import const @@ -149,3 +150,46 @@ def test_access_granted_with_trusted_ip(mock_api_client, caplog, assert resp.status == 200, \ '{} should be trusted'.format(remote_addr) + + +@asyncio.coroutine +def test_basic_auth_works(mock_api_client, caplog): + """Test access with basic authentication.""" + req = yield from mock_api_client.get( + const.URL_API, + auth=aiohttp.BasicAuth('homeassistant', API_PASSWORD)) + + assert req.status == 200 + assert const.URL_API in caplog.text + + +@asyncio.coroutine +def test_basic_auth_username_homeassistant(mock_api_client, caplog): + """Test access with basic auth requires username homeassistant.""" + req = yield from mock_api_client.get( + const.URL_API, + auth=aiohttp.BasicAuth('wrong_username', API_PASSWORD)) + + assert req.status == 401 + + +@asyncio.coroutine +def test_basic_auth_wrong_password(mock_api_client, caplog): + """Test access with basic auth not allowed with wrong password.""" + req = yield from mock_api_client.get( + const.URL_API, + auth=aiohttp.BasicAuth('homeassistant', 'wrong password')) + + assert req.status == 401 + + +@asyncio.coroutine +def test_authorization_header_must_be_basic_type(mock_api_client, caplog): + """Test only basic authorization is allowed for auth header.""" + req = yield from mock_api_client.get( + const.URL_API, + headers={ + 'authorization': 'NotBasic abcdefg' + }) + + assert req.status == 401 From 8b6a5eef4cfa030426a058e17e6e3dc30f7c6e8d Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Thu, 28 Sep 2017 14:38:15 -0400 Subject: [PATCH 009/113] upgrade python-ecobee-api (#9612) --- homeassistant/components/ecobee.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index c4b0f2e9546..0b0c9d1d65a 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -15,7 +15,7 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle -REQUIREMENTS = ['python-ecobee-api==0.0.9'] +REQUIREMENTS = ['python-ecobee-api==0.0.10'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5f28a48ac83..4876d7547b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -739,7 +739,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.12 # homeassistant.components.ecobee -python-ecobee-api==0.0.9 +python-ecobee-api==0.0.10 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.5 From 44838937d1e2810fd99a7c2173f802625aaff6b2 Mon Sep 17 00:00:00 2001 From: Dan Chen Date: Thu, 28 Sep 2017 12:12:02 -0700 Subject: [PATCH 010/113] Change TP-Link Switch power statistics attribute names (#9607) --- homeassistant/components/switch/tplink.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 1b8ef585557..2f695c0bfc1 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -18,11 +18,11 @@ REQUIREMENTS = ['pyHS100==0.2.4.2'] _LOGGER = logging.getLogger(__name__) -ATTR_CURRENT_CONSUMPTION = 'Current consumption' -ATTR_TOTAL_CONSUMPTION = 'Total consumption' -ATTR_DAILY_CONSUMPTION = 'Daily consumption' -ATTR_VOLTAGE = 'Voltage' -ATTR_CURRENT = 'Current' +ATTR_CURRENT_CONSUMPTION = 'current_consumption' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_DAILY_CONSUMPTION = 'daily_consumption' +ATTR_VOLTAGE = 'voltage' +ATTR_CURRENT = 'current' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, From 2df433eb0a11752a8cb7b5bedbe87296539c801f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 Sep 2017 14:26:27 -0500 Subject: [PATCH 011/113] Migrate Alexa smart home to registry (#9616) * Migrate Alexa smart home to registry * Fix tests --- homeassistant/components/alexa/smart_home.py | 24 +++++++------------- tests/components/alexa/test_smart_home.py | 13 ----------- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index ae1ecb87f60..dbf66a63901 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -6,7 +6,9 @@ from uuid import uuid4 from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) from homeassistant.components import switch, light +from homeassistant.util.decorator import Registry +HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) ATTR_HEADER = 'header' @@ -27,27 +29,13 @@ MAPPING_COMPONENT = { } -def mapping_api_function(name): - """Return function pointer to api function for name. - - Async friendly. - """ - mapping = { - 'DiscoverAppliancesRequest': async_api_discovery, - 'TurnOnRequest': async_api_turn_on, - 'TurnOffRequest': async_api_turn_off, - 'SetPercentageRequest': async_api_set_percentage, - } - return mapping.get(name, None) - - @asyncio.coroutine def async_handle_message(hass, message): """Handle incoming API messages.""" assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 # Do we support this API request? - funct_ref = mapping_api_function(message[ATTR_HEADER][ATTR_NAME]) + funct_ref = HANDLERS.get(message[ATTR_HEADER][ATTR_NAME]) if not funct_ref: _LOGGER.warning( "Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) @@ -64,7 +52,7 @@ def api_message(name, namespace, payload=None): payload = payload or {} return { ATTR_HEADER: { - ATTR_MESSAGE_ID: uuid4(), + ATTR_MESSAGE_ID: str(uuid4()), ATTR_NAME: name, ATTR_NAMESPACE: namespace, ATTR_PAYLOAD_VERSION: '2', @@ -81,6 +69,7 @@ def api_error(request, exc='DriverInternalError'): return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) +@HANDLERS.register('DiscoverAppliancesRequest') @asyncio.coroutine def async_api_discovery(hass, request): """Create a API formatted discovery response. @@ -146,6 +135,7 @@ def extract_entity(funct): return async_api_entity_wrapper +@HANDLERS.register('TurnOnRequest') @extract_entity @asyncio.coroutine def async_api_turn_on(hass, request, entity): @@ -157,6 +147,7 @@ def async_api_turn_on(hass, request, entity): return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') +@HANDLERS.register('TurnOffRequest') @extract_entity @asyncio.coroutine def async_api_turn_off(hass, request, entity): @@ -168,6 +159,7 @@ def async_api_turn_off(hass, request, entity): return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') +@HANDLERS.register('SetPercentageRequest') @extract_entity @asyncio.coroutine def async_api_set_percentage(hass, request, entity): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0c2b133bdfb..22cd149009f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -19,19 +19,6 @@ def test_create_api_message(): assert msg['payload'] == {} -def test_mapping_api_funct(): - """Test function ref from mapping function.""" - assert smart_home.mapping_api_function('notExists') is None - assert smart_home.mapping_api_function('DiscoverAppliancesRequest') == \ - smart_home.async_api_discovery - assert smart_home.mapping_api_function('TurnOnRequest') == \ - smart_home.async_api_turn_on - assert smart_home.mapping_api_function('TurnOffRequest') == \ - smart_home.async_api_turn_off - assert smart_home.mapping_api_function('SetPercentageRequest') == \ - smart_home.async_api_set_percentage - - @asyncio.coroutine def test_wrong_version(hass): """Test with wrong version.""" From 236d5f8742140c52789754be8b6e0d0486a837ad Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Thu, 28 Sep 2017 23:57:49 +0200 Subject: [PATCH 012/113] Add an input_datetime (#9313) * Initial proposal for the input_datetime * Linting * Further linting, don't define time validation twice * Make pylint *and* flake8 happy at the same time * Move todos to the PR to make lint happy * Actually validate the type of date/time * First testing * Linting * Address code review issues * Code review: Remove forgotten print()s * Make set_datetime a coroutine * Create contains_at_least_one_key_value CV method, use it * Add timestamp to the attributes * Test and fix corner case where restore data is bogus * Add FIXME * Fix date/time setting * Fix Validation * Merge date / time validation, add tests * Simplify service data validation * No default for initial state, allow 'unknown' as state * cleanup * fix schema --- homeassistant/components/input_datetime.py | 227 +++++++++++++++++++++ homeassistant/helpers/config_validation.py | 60 +++++- tests/components/test_input_datetime.py | 204 ++++++++++++++++++ tests/helpers/test_config_validation.py | 40 ++++ 4 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/input_datetime.py create mode 100644 tests/components/test_input_datetime.py diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py new file mode 100644 index 00000000000..9dd09f2c245 --- /dev/null +++ b/homeassistant/components/input_datetime.py @@ -0,0 +1,227 @@ +""" +Component to offer a way to select a date and / or a time. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_datetime/ +""" +import asyncio +import logging +import datetime + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.util import dt as dt_util + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_datetime' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_HAS_DATE = 'has_date' +CONF_HAS_TIME = 'has_time' +CONF_INITIAL = 'initial' + +ATTR_DATE = 'date' +ATTR_TIME = 'time' + +SERVICE_SET_DATETIME = 'set_datetime' + +SERVICE_SET_DATETIME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_TIME): cv.time, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_HAS_DATE): cv.boolean, + vol.Required(CONF_HAS_TIME): cv.boolean, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL): cv.datetime, + }, cv.has_at_least_one_key_value((CONF_HAS_DATE, True), + (CONF_HAS_TIME, True)))}) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_set_datetime(hass, entity_id, dt_value): + """Set date and / or time of input_datetime.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_DATETIME, { + ATTR_ENTITY_ID: entity_id, + ATTR_DATE: dt_value.date(), + ATTR_TIME: dt_value.time() + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input datetime.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + has_time = cfg.get(CONF_HAS_TIME) + has_date = cfg.get(CONF_HAS_DATE) + icon = cfg.get(CONF_ICON) + initial = cfg.get(CONF_INITIAL) + entities.append(InputDatetime(object_id, name, + has_date, has_time, icon, initial)) + + if not entities: + return False + + @asyncio.coroutine + def async_set_datetime_service(call): + """Handle a call to the input datetime 'set datetime' service.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [] + for input_datetime in target_inputs: + time = call.data.get(ATTR_TIME) + date = call.data.get(ATTR_DATE) + if (input_datetime.has_date() and not date) or \ + (input_datetime.has_time() and not time): + _LOGGER.error("Invalid service data for " + "input_datetime.set_datetime: %s", + str(call.data)) + continue + + tasks.append(input_datetime.async_set_datetime(date, time)) + + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SET_DATETIME, async_set_datetime_service, + schema=SERVICE_SET_DATETIME_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputDatetime(Entity): + """Representation of a datetime input.""" + + def __init__(self, object_id, name, has_date, has_time, icon, initial): + """Initialize a select input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._has_date = has_date + self._has_time = has_time + self._icon = icon + self._initial = initial + self._current_datetime = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added.""" + restore_val = None + + # Priority 1: Initial State + if self._initial is not None: + restore_val = self._initial + + # Priority 2: Old state + if restore_val is None: + old_state = yield from async_get_last_state(self.hass, + self.entity_id) + if old_state is not None: + restore_val = dt_util.parse_datetime(old_state.state) + + if restore_val is not None: + if not self._has_date: + self._current_datetime = restore_val.time() + elif not self._has_time: + self._current_datetime = restore_val.date() + else: + self._current_datetime = restore_val + + def has_date(self): + """Return whether the input datetime carries a date.""" + return self._has_date + + def has_time(self): + """Return whether the input datetime carries a time.""" + return self._has_time + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + if self._current_datetime is None: + return STATE_UNKNOWN + + return self._current_datetime + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = { + 'has_date': self._has_date, + 'has_time': self._has_time, + } + + if self._current_datetime is None: + return attrs + + if self._has_date and self._current_datetime is not None: + attrs['year'] = self._current_datetime.year + attrs['month'] = self._current_datetime.month + attrs['day'] = self._current_datetime.day + + if self._has_time and self._current_datetime is not None: + attrs['hour'] = self._current_datetime.hour + attrs['minute'] = self._current_datetime.minute + attrs['second'] = self._current_datetime.second + + if self._current_datetime is not None: + if not self._has_date: + attrs['timestamp'] = self._current_datetime.hour * 3600 + \ + self._current_datetime.minute * 60 + \ + self._current_datetime.second + elif not self._has_time: + extended = datetime.datetime.combine(self._current_datetime, + datetime.time(0, 0)) + attrs['timestamp'] = extended.timestamp() + else: + attrs['timestamp'] = self._current_datetime.timestamp() + + return attrs + + @asyncio.coroutine + def async_set_datetime(self, date_val, time_val): + """Set a new date / time.""" + if self._has_date and self._has_time and date_val and time_val: + self._current_datetime = datetime.datetime.combine(date_val, + time_val) + elif self._has_date and not self._has_time and date_val: + self._current_datetime = date_val + if self._has_time and not self._has_date and time_val: + self._current_datetime = time_val + + yield from self.async_update_ha_state() diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3378116163f..4c48e685b23 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,5 +1,6 @@ """Helpers for config validation using voluptuous.""" -from datetime import timedelta, datetime as datetime_sys +from datetime import (timedelta, datetime as datetime_sys, + time as time_sys, date as date_sys) import os import re from urllib.parse import urlparse @@ -57,6 +58,21 @@ def has_at_least_one_key(*keys: str) -> Callable: return validate +def has_at_least_one_key_value(*items: list) -> Callable: + """Validate that at least one (key, value) pair exists.""" + def validate(obj: Dict) -> Dict: + """Test (key,value) exist in dict.""" + if not isinstance(obj, dict): + raise vol.Invalid('expected dictionary') + + for item in obj.items(): + if item in items: + return obj + raise vol.Invalid('must contain one of {}.'.format(str(items))) + + return validate + + def boolean(value: Any) -> bool: """Validate and coerce a boolean value.""" if isinstance(value, str): @@ -144,6 +160,38 @@ time_period_dict = vol.All( lambda value: timedelta(**value)) +def time(value) -> time_sys: + """Validate and transform a time.""" + if isinstance(value, time_sys): + return value + + try: + time_val = dt_util.parse_time(value) + except TypeError: + raise vol.Invalid('Not a parseable type') + + if time_val is None: + raise vol.Invalid('Invalid time specified: {}'.format(value)) + + return time_val + + +def date(value) -> date_sys: + """Validate and transform a date.""" + if isinstance(value, date_sys): + return value + + try: + date_val = dt_util.parse_date(value) + except TypeError: + raise vol.Invalid('Not a parseable type') + + if date_val is None: + raise vol.Invalid("Could not parse date") + + return date_val + + def time_period_str(value: str) -> timedelta: """Validate and transform time offset.""" if isinstance(value, int): @@ -297,16 +345,6 @@ def template_complex(value): return template(value) -def time(value): - """Validate time.""" - time_val = dt_util.parse_time(value) - - if time_val is None: - raise vol.Invalid('Invalid time specified: {}'.format(value)) - - return time_val - - def datetime(value): """Validate datetime.""" if isinstance(value, datetime_sys): diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py new file mode 100644 index 00000000000..af664f36a53 --- /dev/null +++ b/tests/components/test_input_datetime.py @@ -0,0 +1,204 @@ +"""Tests for the Input slider component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import datetime + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.input_datetime import ( + DOMAIN, async_set_datetime) + +from tests.common import get_test_home_assistant, mock_restore_cache + + +class TestInputDatetime(unittest.TestCase): + """Test the input datetime component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_invalid_configs(self): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_no_value': { + 'has_time': False, + 'has_date': False + }}, + ] + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + +@asyncio.coroutine +def test_set_datetime(hass): + """Test set_datetime method.""" + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_datetime': { + 'has_time': True, + 'has_date': True + }, + }}) + + entity_id = 'input_datetime.test_datetime' + + dt_obj = datetime.datetime(2017, 9, 7, 19, 46) + + yield from async_set_datetime(hass, entity_id, dt_obj) + yield from hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == str(dt_obj) + assert state.attributes['has_time'] + assert state.attributes['has_date'] + + assert state.attributes['year'] == 2017 + assert state.attributes['month'] == 9 + assert state.attributes['day'] == 7 + assert state.attributes['hour'] == 19 + assert state.attributes['minute'] == 46 + assert state.attributes['timestamp'] == dt_obj.timestamp() + + +@asyncio.coroutine +def test_set_datetime_time(hass): + """Test set_datetime method with only time.""" + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_time': { + 'has_time': True, + 'has_date': False + } + }}) + + entity_id = 'input_datetime.test_time' + + dt_obj = datetime.datetime(2017, 9, 7, 19, 46) + time_portion = dt_obj.time() + + yield from async_set_datetime(hass, entity_id, dt_obj) + yield from hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == str(time_portion) + assert state.attributes['has_time'] + assert not state.attributes['has_date'] + + assert state.attributes['timestamp'] == (19 * 3600) + (46 * 60) + + +@asyncio.coroutine +def test_set_invalid(hass): + """Test set_datetime method with only time.""" + initial = datetime.datetime(2017, 1, 1, 0, 0) + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_date': { + 'has_time': False, + 'has_date': True, + 'initial': initial + } + }}) + + entity_id = 'input_datetime.test_date' + + dt_obj = datetime.datetime(2017, 9, 7, 19, 46) + time_portion = dt_obj.time() + + yield from hass.services.async_call('input_datetime', 'set_datetime', { + 'entity_id': 'test_date', + 'time': time_portion + }) + yield from hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == str(initial.date()) + + +@asyncio.coroutine +def test_set_datetime_date(hass): + """Test set_datetime method with only date.""" + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_date': { + 'has_time': False, + 'has_date': True + } + }}) + + entity_id = 'input_datetime.test_date' + + dt_obj = datetime.datetime(2017, 9, 7, 19, 46) + date_portion = dt_obj.date() + + yield from async_set_datetime(hass, entity_id, dt_obj) + yield from hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == str(date_portion) + assert not state.attributes['has_time'] + assert state.attributes['has_date'] + + date_dt_obj = datetime.datetime(2017, 9, 7) + assert state.attributes['timestamp'] == date_dt_obj.timestamp() + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_datetime.test_time', '2017-09-07 19:46:00'), + State('input_datetime.test_date', '2017-09-07 19:46:00'), + State('input_datetime.test_datetime', '2017-09-07 19:46:00'), + State('input_datetime.test_bogus_data', 'this is not a date'), + )) + + hass.state = CoreState.starting + + initial = datetime.datetime(2017, 1, 1, 23, 42) + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_time': { + 'has_time': True, + 'has_date': False + }, + 'test_date': { + 'has_time': False, + 'has_date': True + }, + 'test_datetime': { + 'has_time': True, + 'has_date': True + }, + 'test_bogus_data': { + 'has_time': True, + 'has_date': True, + 'initial': str(initial) + }, + }}) + + dt_obj = datetime.datetime(2017, 9, 7, 19, 46) + state_time = hass.states.get('input_datetime.test_time') + assert state_time.state == str(dt_obj.time()) + + state_date = hass.states.get('input_datetime.test_date') + assert state_date.state == str(dt_obj.date()) + + state_datetime = hass.states.get('input_datetime.test_datetime') + assert state_datetime.state == str(dt_obj) + + state_bogus = hass.states.get('input_datetime.test_bogus_data') + assert state_bogus.state == str(initial) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index ac652e29833..5a940742e75 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -405,6 +405,31 @@ def test_time_zone(): schema('UTC') +def test_date(): + """Test date validation.""" + schema = vol.Schema(cv.date) + + for value in ['Not a date', '23:42', '2016-11-23T18:59:08']: + with pytest.raises(vol.Invalid): + schema(value) + + schema(datetime.now().date()) + schema('2016-11-23') + + +def test_time(): + """Test date validation.""" + schema = vol.Schema(cv.time) + + for value in ['Not a time', '2016-11-23', '2016-11-23T18:59:08']: + with pytest.raises(vol.Invalid): + schema(value) + + schema(datetime.now().time()) + schema('23:42:00') + schema('23:42') + + def test_datetime(): """Test date time validation.""" schema = vol.Schema(cv.datetime) @@ -447,6 +472,21 @@ def test_has_at_least_one_key(): schema(value) +def test_has_at_least_one_key_value(): + """Test has_at_least_one_key_value validator.""" + schema = vol.Schema(cv.has_at_least_one_key_value(('drink', 'beer'), + ('drink', 'soda'), + ('food', 'maultaschen'))) + + for value in (None, [], {}, {'wine': None}, {'drink': 'water'}): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ({'drink': 'beer'}, {'food': 'maultaschen'}, + {'drink': 'soda', 'food': 'maultaschen'}): + schema(value) + + def test_enum(): """Test enum validator.""" class TestEnum(enum.Enum): From cc5256b8fb5d2631b8c61b50d26c13c0bbc50a11 Mon Sep 17 00:00:00 2001 From: pascal Date: Fri, 29 Sep 2017 00:49:03 +0200 Subject: [PATCH 013/113] Cover component for RFlink (#9432) * second try on rflink / cover * no newline at end of file * changed entity * fixed comments from pvizeli * removed : * removed return 'unknown' * Fixed comments from Rytilahti * removed newline * Reverted to None * cleanup * Cleanup --- homeassistant/components/cover/rflink.py | 116 +++++++++++++++++++++++ homeassistant/components/rflink.py | 15 +++ tests/components/test_rflink.py | 35 ++++++- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/cover/rflink.py diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py new file mode 100644 index 00000000000..45a0b27aa07 --- /dev/null +++ b/homeassistant/components/cover/rflink.py @@ -0,0 +1,116 @@ +""" +Support for Rflink Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.rflink/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.rflink import ( + DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, + DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, RflinkCommand) +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME + + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + + +CONF_ALIASES = 'aliases' +CONF_GROUP_ALIASES = 'group_aliases' +CONF_GROUP = 'group' +CONF_NOGROUP_ALIASES = 'nogroup_aliases' +CONF_DEVICE_DEFAULTS = 'device_defaults' +CONF_DEVICES = 'devices' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_FIRE_EVENT = 'fire_event' +CONF_IGNORE_DEVICES = 'ignore_devices' +CONF_RECONNECT_INTERVAL = 'reconnect_interval' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_WAIT_FOR_ACK = 'wait_for_ack' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): vol.Schema({ + cv.string: { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + }, + }), +}) + + +def devices_from_config(domain_config, hass=None): + """Parse configuration and add Rflink cover devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) + device = RflinkCover(device_id, hass, **device_config) + devices.append(device) + + # Register entity (and aliases) to listen to incoming rflink events + # Device id and normal aliases respond to normal and group command + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + if config[CONF_GROUP]: + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + for _id in config[CONF_ALIASES]: + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + return devices + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Rflink cover platform.""" + async_add_devices(devices_from_config(config, hass)) + + +class RflinkCover(RflinkCommand, CoverDevice): + """Rflink entity which can switch on/stop/off (eg: cover).""" + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event['command'] + if command in ['on', 'allon']: + self._state = True + elif command in ['off', 'alloff']: + self._state = False + + @property + def should_poll(self): + """No polling available in RFlink cover.""" + return False + + def async_close_cover(self, **kwargs): + """Turn the device close.""" + return self._async_handle_command("close_cover") + + def async_open_cover(self, **kwargs): + """Turn the device open.""" + return self._async_handle_command("open_cover") + + def async_stop_cover(self, **kwargs): + """Turn the device stop.""" + return self._async_handle_command("stop_cover") diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 74e533d70ec..5045017790e 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -11,6 +11,7 @@ import logging import os import async_timeout + from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, @@ -22,6 +23,7 @@ from homeassistant.helpers.deprecation import get_deprecated from homeassistant.helpers.entity import Entity import voluptuous as vol + REQUIREMENTS = ['rflink==0.0.34'] _LOGGER = logging.getLogger(__name__) @@ -370,6 +372,19 @@ class RflinkCommand(RflinkDevice): # if the state is true, it gets set as false self._state = self._state in [STATE_UNKNOWN, False] + # Cover options for RFlink + elif command == 'close_cover': + cmd = 'DOWN' + self._state = False + + elif command == 'open_cover': + cmd = 'UP' + self._state = True + + elif command == 'stop_cover': + cmd = 'STOP' + self._state = True + # Send initial command and queue repetitions. # This allows the entity state to be updated quickly and not having to # wait for all repetitions to be sent diff --git a/tests/components/test_rflink.py b/tests/components/test_rflink.py index b4cdd96f817..e7907fc6b54 100644 --- a/tests/components/test_rflink.py +++ b/tests/components/test_rflink.py @@ -6,7 +6,8 @@ from unittest.mock import Mock from homeassistant.bootstrap import async_setup_component from homeassistant.components.rflink import ( CONF_RECONNECT_INTERVAL, SERVICE_SEND_COMMAND) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_STOP_COVER) from tests.common import assert_setup_component @@ -119,6 +120,38 @@ def test_send_no_wait(hass, monkeypatch): assert protocol.send_command.call_args_list[0][0][1] == 'off' +@asyncio.coroutine +def test_cover_send_no_wait(hass, monkeypatch): + """Test command sending to a cover device without ack.""" + domain = 'cover' + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + 'wait_for_ack': False, + }, + domain: { + 'platform': 'rflink', + 'devices': { + 'RTS_0100F2_0': { + 'name': 'test', + 'aliases': ['test_alias_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + _, _, protocol, _ = yield from mock_rflink( + hass, config, domain, monkeypatch) + + hass.async_add_job( + hass.services.async_call(domain, SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: 'cover.test'})) + yield from hass.async_block_till_done() + assert protocol.send_command.call_args_list[0][0][0] == 'RTS_0100F2_0' + assert protocol.send_command.call_args_list[0][0][1] == 'STOP' + + @asyncio.coroutine def test_send_command(hass, monkeypatch): """Test send_command service.""" From 19932bce532279837f5e60b4f38d82307f3e70da Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Fri, 29 Sep 2017 04:08:41 -0400 Subject: [PATCH 014/113] Introducing support to Melnor RainCloud sprinkler systems (#9287) * Introducing support to Melnor RainCloud sprinkler systems * Make monitored_conditions optional for sub-components * Part 1/2 - Modified attributes, added DATA_ constant and using battery helper * Part 2/2 - Refactored self-update hub * Fixed change requested: - Dispatcher signal connection - Don't send raincloud object via dispatcher_send() - Honoring the dynamic scan_interval value on track_time_interval() * Inherents async_added_to_hass() on all device classes * Makes lint happy * * Refactored RainCloud code to incorporate suggestions. Many thanks to @pvizelli and @martinhjelmare!! * Removed Entity from RainCloud sensor and fixed docstrings * Update raincloud.py * Update raincloud.py * fix lint --- .coveragerc | 3 + .../components/binary_sensor/raincloud.py | 70 +++++++ homeassistant/components/raincloud.py | 179 ++++++++++++++++++ homeassistant/components/sensor/raincloud.py | 69 +++++++ homeassistant/components/switch/raincloud.py | 94 +++++++++ requirements_all.txt | 3 + 6 files changed, 418 insertions(+) create mode 100644 homeassistant/components/binary_sensor/raincloud.py create mode 100644 homeassistant/components/raincloud.py create mode 100644 homeassistant/components/sensor/raincloud.py create mode 100644 homeassistant/components/switch/raincloud.py diff --git a/.coveragerc b/.coveragerc index 52ffc3da56a..2d3c64a79cd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -149,6 +149,9 @@ omit = homeassistant/components/rachio.py homeassistant/components/*/rachio.py + homeassistant/components/raincloud.py + homeassistant/components/*/raincloud.py + homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py diff --git a/homeassistant/components/binary_sensor/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py new file mode 100644 index 00000000000..874f7a81a17 --- /dev/null +++ b/homeassistant/components/binary_sensor/raincloud.py @@ -0,0 +1,70 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.raincloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.raincloud import ( + BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['raincloud'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a raincloud device.""" + raincloud = hass.data[DATA_RAINCLOUD].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'status': + sensors.append( + RainCloudBinarySensor(raincloud.controller, sensor_type)) + sensors.append( + RainCloudBinarySensor(raincloud.controller.faucet, + sensor_type)) + + else: + # create an sensor for each zone managed by faucet + for zone in raincloud.controller.faucet.zones: + sensors.append(RainCloudBinarySensor(zone, sensor_type)) + + add_devices(sensors, True) + return True + + +class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice): + """A sensor implementation for raincloud device.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Updating RainCloud sensor: %s", self._name) + self._state = getattr(self.data, self._sensor_type) + + @property + def icon(self): + """Return the icon of this device.""" + if self._sensor_type == 'is_watering': + return 'mdi:water' if self.is_on else 'mdi:water-off' + elif self._sensor_type == 'status': + return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected' + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/raincloud.py b/homeassistant/components/raincloud.py new file mode 100644 index 00000000000..0cc91576dae --- /dev/null +++ b/homeassistant/components/raincloud.py @@ -0,0 +1,179 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/raincloud/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) + +from requests.exceptions import HTTPError, ConnectTimeout + +REQUIREMENTS = ['raincloudy==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] + +CONF_ATTRIBUTION = "Data provided by Melnor Aquatimer.com" +CONF_WATERING_TIME = 'watering_minutes' + +NOTIFICATION_ID = 'raincloud_notification' +NOTIFICATION_TITLE = 'Rain Cloud Setup' + +DATA_RAINCLOUD = 'raincloud' +DOMAIN = 'raincloud' +DEFAULT_WATERING_TIME = 15 + +KEY_MAP = { + 'auto_watering': 'Automatic Watering', + 'battery': 'Battery', + 'is_watering': 'Watering', + 'manual_watering': 'Manual Watering', + 'next_cycle': 'Next Cycle', + 'rain_delay': 'Rain Delay', + 'status': 'Status', + 'watering_time': 'Remaining Watering Time', +} + +ICON_MAP = { + 'auto_watering': 'mdi:autorenew', + 'battery': '', + 'is_watering': '', + 'manual_watering': 'mdi:water-pump', + 'next_cycle': 'mdi:calendar-clock', + 'rain_delay': 'mdi:weather-rainy', + 'status': '', + 'watering_time': 'mdi:water-pump', +} + +UNIT_OF_MEASUREMENT_MAP = { + 'auto_watering': '', + 'battery': '%', + 'is_watering': '', + 'manual_watering': '', + 'next_cycle': '', + 'rain_delay': 'days', + 'status': '', + 'watering_time': 'min', +} + +BINARY_SENSORS = ['is_watering', 'status'] + +SENSORS = ['battery', 'next_cycle', 'rain_delay', 'watering_time'] + +SWITCHES = ['auto_watering', 'manual_watering'] + +SCAN_INTERVAL = timedelta(seconds=20) + +SIGNAL_UPDATE_RAINCLOUD = "raincloud_update" + +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 Melnor RainCloud component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + try: + from raincloudy.core import RainCloudy + + raincloud = RainCloudy(username=username, password=password) + if not raincloud.is_connected: + raise HTTPError + hass.data[DATA_RAINCLOUD] = RainCloudHub(raincloud) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Rain Cloud service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + def hub_refresh(event_time): + """Call Raincloud hub to refresh information.""" + _LOGGER.debug("Updating RainCloud Hub component.") + hass.data[DATA_RAINCLOUD].data.update() + dispatcher_send(hass, SIGNAL_UPDATE_RAINCLOUD) + + # Call the Raincloud API to refresh updates + track_time_interval(hass, hub_refresh, scan_interval) + + return True + + +class RainCloudHub(object): + """Representation of a base RainCloud device.""" + + def __init__(self, data): + """Initialize the entity.""" + self.data = data + + +class RainCloudEntity(Entity): + """Entity class for RainCloud devices.""" + + def __init__(self, data, sensor_type): + """Initialize the RainCloud entity.""" + self.data = data + self._sensor_type = sensor_type + self._name = "{0} {1}".format( + self.data.name, KEY_MAP.get(self._sensor_type)) + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_RAINCLOUD, self._update_callback) + + def _update_callback(self): + """Callback update method.""" + self.schedule_update_ha_state(True) + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return UNIT_OF_MEASUREMENT_MAP.get(self._sensor_type) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'current_time': self.data.current_time, + 'identifier': self.data.serial, + } + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/sensor/raincloud.py b/homeassistant/components/sensor/raincloud.py new file mode 100644 index 00000000000..ab073917e8e --- /dev/null +++ b/homeassistant/components/sensor/raincloud.py @@ -0,0 +1,69 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.raincloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.raincloud import ( + DATA_RAINCLOUD, ICON_MAP, RainCloudEntity, SENSORS) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.util.icon import icon_for_battery_level + +DEPENDENCIES = ['raincloud'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a raincloud device.""" + raincloud = hass.data[DATA_RAINCLOUD].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'battery': + sensors.append( + RainCloudSensor(raincloud.controller.faucet, + sensor_type)) + else: + # create an sensor for each zone managed by a faucet + for zone in raincloud.controller.faucet.zones: + sensors.append(RainCloudSensor(zone, sensor_type)) + + add_devices(sensors, True) + return True + + +class RainCloudSensor(RainCloudEntity): + """A sensor implementation for raincloud device.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data and updates the states.""" + _LOGGER.debug("Updating RainCloud sensor: %s", self._name) + if self._sensor_type == 'battery': + self._state = self.data.battery.strip('%') + else: + self._state = getattr(self.data, self._sensor_type) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._sensor_type == 'battery' and self._state is not None: + return icon_for_battery_level(battery_level=int(self._state), + charging=False) + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/switch/raincloud.py b/homeassistant/components/switch/raincloud.py new file mode 100644 index 00000000000..f373a6aad84 --- /dev/null +++ b/homeassistant/components/switch/raincloud.py @@ -0,0 +1,94 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.raincloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.raincloud import ( + ALLOWED_WATERING_TIME, CONF_ATTRIBUTION, CONF_WATERING_TIME, + DATA_RAINCLOUD, DEFAULT_WATERING_TIME, RainCloudEntity, SWITCHES) +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, ATTR_ATTRIBUTION) + +DEPENDENCIES = ['raincloud'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCHES)): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): + vol.All(vol.In(ALLOWED_WATERING_TIME)), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a raincloud device.""" + raincloud = hass.data[DATA_RAINCLOUD].data + default_watering_timer = config.get(CONF_WATERING_TIME) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + # create an sensor for each zone managed by faucet + for zone in raincloud.controller.faucet.zones: + sensors.append( + RainCloudSwitch(default_watering_timer, + zone, + sensor_type)) + + add_devices(sensors, True) + return True + + +class RainCloudSwitch(RainCloudEntity, SwitchDevice): + """A switch implementation for raincloud device.""" + + def __init__(self, default_watering_timer, *args): + """Initialize a switch for raincloud device.""" + super().__init__(*args) + self._default_watering_timer = default_watering_timer + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self): + """Turn the device on.""" + if self._sensor_type == 'manual_watering': + self.data.watering_time = self._default_watering_timer + elif self._sensor_type == 'auto_watering': + self.data.auto_watering = True + self._state = True + + def turn_off(self): + """Turn the device off.""" + if self._sensor_type == 'manual_watering': + self.data.watering_time = 'off' + elif self._sensor_type == 'auto_watering': + self.data.auto_watering = False + self._state = False + + def update(self): + """Update device state.""" + _LOGGER.debug("Updating RainCloud switch: %s", self._name) + if self._sensor_type == 'manual_watering': + self._state = bool(self.data.watering_time) + elif self._sensor_type == 'auto_watering': + self._state = self.data.auto_watering + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'current_time': self.data.current_time, + 'default_manual_timer': self._default_watering_timer, + 'identifier': self.data.serial + } diff --git a/requirements_all.txt b/requirements_all.txt index 4876d7547b4..7308df47833 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -858,6 +858,9 @@ rachiopy==0.1.2 # homeassistant.components.climate.radiotherm radiotherm==1.3 +# homeassistant.components.raincloud +raincloudy==0.0.1 + # homeassistant.components.raspihats # raspihats==2.2.1 From 445b0f6f940fc8bb96eba8ef7302529b29debf4f Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Fri, 29 Sep 2017 03:02:48 -0700 Subject: [PATCH 015/113] Rewrite synology camera by using py-synology package (#9583) * - Rewrite synology camera by intruducing Api and SurveillanceStation classes to get cameras, motion settings, enable/disable motion detection, etc ... - Synology camera now shows correct state based on is_recording and is_streaming flag. Also it now supports enable / disable motion detection and show the correct motion detection status - Newly added Api and SurveillanceStation classes will be moved to a lib but it's here just for review * - Updated how payload are merged with kwargs so it works with python <3.5 * - Fixed class name conflict * - Addressed flake8 error * - Addressed pylint error * - Moved synology API related code to py-synology lib - Added py-synology==0.1.1 requirement - Removed hass from SynologyCamera constructor * - Updated requirements_all.txt * - renamed variable back to original * - Sync call to retrieve camera image should be done in camera_image() instead * - Sync call to update camera info should be done in update() instead * - Removed unused import --- homeassistant/components/camera/synology.py | 231 +++++--------------- requirements_all.txt | 3 + 2 files changed, 59 insertions(+), 175 deletions(-) diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index 90dfa58d8c5..be01a7fc90d 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -7,44 +7,25 @@ https://home-assistant.io/components/camera.synology/ import asyncio import logging +import requests import voluptuous as vol -import aiohttp -import async_timeout - from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) from homeassistant.components.camera import ( Camera, PLATFORM_SCHEMA) from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_create_clientsession, + async_create_clientsession, async_aiohttp_proxy_web) import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe + +REQUIREMENTS = ['py-synology==0.1.1'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Synology Camera' -DEFAULT_STREAM_ID = '0' DEFAULT_TIMEOUT = 5 -CONF_CAMERA_NAME = 'camera_name' -CONF_STREAM_ID = 'stream_id' - -QUERY_CGI = 'query.cgi' -QUERY_API = 'SYNO.API.Info' -AUTH_API = 'SYNO.API.Auth' -CAMERA_API = 'SYNO.SurveillanceStation.Camera' -STREAMING_API = 'SYNO.SurveillanceStation.VideoStream' -SESSION_ID = '0' - -WEBAPI_PATH = '/webapi/' -AUTH_PATH = 'auth.cgi' -CAMERA_PATH = 'camera.cgi' -STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi' -CONTENT_TYPE_HEADER = 'Content-Type' - -SYNO_API_URL = '{0}{1}{2}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -62,189 +43,89 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a Synology IP Camera.""" verify_ssl = config.get(CONF_VERIFY_SSL) timeout = config.get(CONF_TIMEOUT) - websession_init = async_get_clientsession(hass, verify_ssl) - # Determine API to use for authentication - syno_api_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI) - - query_payload = { - 'api': QUERY_API, - 'method': 'Query', - 'version': '1', - 'query': 'SYNO.' - } try: - with async_timeout.timeout(timeout, loop=hass.loop): - query_req = yield from websession_init.get( - syno_api_url, - params=query_payload - ) - - # Skip content type check because Synology doesn't return JSON with - # right content type - query_resp = yield from query_req.json(content_type=None) - auth_path = query_resp['data'][AUTH_API]['path'] - camera_api = query_resp['data'][CAMERA_API]['path'] - camera_path = query_resp['data'][CAMERA_API]['path'] - streaming_path = query_resp['data'][STREAMING_API]['path'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_api_url) + from synology.surveillance_station import SurveillanceStation + surveillance = SurveillanceStation( + config.get(CONF_URL), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + verify_ssl=verify_ssl, + timeout=timeout + ) + except (requests.exceptions.RequestException, ValueError): + _LOGGER.exception("Error when initializing SurveillanceStation") return False - # Authticate to NAS to get a session id - syno_auth_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, auth_path) - - session_id = yield from get_session_id( - hass, - websession_init, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - syno_auth_url, - timeout - ) - - # init websession - websession = async_create_clientsession( - hass, verify_ssl, cookies={'id': session_id}) - - # Use SessionID to get cameras in system - syno_camera_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, camera_api) - - camera_payload = { - 'api': CAMERA_API, - 'method': 'List', - 'version': '1' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - camera_req = yield from websession.get( - syno_camera_url, - params=camera_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_camera_url) - return False - - camera_resp = yield from camera_req.json(content_type=None) - cameras = camera_resp['data']['cameras'] + cameras = surveillance.get_all_cameras() + websession = async_create_clientsession(hass, verify_ssl) # add cameras devices = [] for camera in cameras: if not config.get(CONF_WHITELIST): - camera_id = camera['id'] - snapshot_path = camera['snapshot_path'] - - device = SynologyCamera( - hass, websession, config, camera_id, camera['name'], - snapshot_path, streaming_path, camera_path, auth_path, timeout - ) + device = SynologyCamera(websession, surveillance, camera.camera_id) devices.append(device) async_add_devices(devices) -@asyncio.coroutine -def get_session_id(hass, websession, username, password, login_url, timeout): - """Get a session id.""" - auth_payload = { - 'api': AUTH_API, - 'method': 'Login', - 'version': '2', - 'account': username, - 'passwd': password, - 'session': 'SurveillanceStation', - 'format': 'sid' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - auth_req = yield from websession.get( - login_url, - params=auth_payload - ) - auth_resp = yield from auth_req.json(content_type=None) - return auth_resp['data']['sid'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", login_url) - return False - - class SynologyCamera(Camera): """An implementation of a Synology NAS based IP camera.""" - def __init__(self, hass, websession, config, camera_id, - camera_name, snapshot_path, streaming_path, camera_path, - auth_path, timeout): + def __init__(self, websession, surveillance, camera_id): """Initialize a Synology Surveillance Station camera.""" super().__init__() - self.hass = hass self._websession = websession - self._name = camera_name - self._synology_url = config.get(CONF_URL) - self._camera_name = config.get(CONF_CAMERA_NAME) - self._stream_id = config.get(CONF_STREAM_ID) + self._surveillance = surveillance self._camera_id = camera_id - self._snapshot_path = snapshot_path - self._streaming_path = streaming_path - self._camera_path = camera_path - self._auth_path = auth_path - self._timeout = timeout + self._camera = self._surveillance.get_camera(camera_id) + self._motion_setting = self._surveillance.get_motion_setting(camera_id) + self.is_streaming = self._camera.is_enabled def camera_image(self): """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - image_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._camera_path) - - image_payload = { - 'api': CAMERA_API, - 'method': 'GetSnapshot', - 'version': '1', - 'cameraId': self._camera_id - } - try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - response = yield from self._websession.get( - image_url, - params=image_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error fetching %s", image_url) - return None - - image = yield from response.read() - - return image + return self._surveillance.get_camera_image(self._camera_id) @asyncio.coroutine def handle_async_mjpeg_stream(self, request): """Return a MJPEG stream image response directly from the camera.""" - streaming_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._streaming_path) - - streaming_payload = { - 'api': STREAMING_API, - 'method': 'Stream', - 'version': '1', - 'cameraId': self._camera_id, - 'format': 'mjpeg' - } - stream_coro = self._websession.get( - streaming_url, params=streaming_payload) + streaming_url = self._camera.video_stream_url + stream_coro = self._websession.get(streaming_url) yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) @property def name(self): """Return the name of this device.""" - return self._name + return self._camera.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._camera.is_recording + + def should_poll(self): + """Update the recording state periodically.""" + return True + + def update(self): + """Update the status of the camera.""" + self._surveillance.update() + self._camera = self._surveillance.get_camera(self._camera.camera_id) + self._motion_setting = self._surveillance.get_motion_setting( + self._camera.camera_id) + self.is_streaming = self._camera.is_enabled + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_setting.is_enabled + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._surveillance.enable_motion_detection(self._camera_id) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._surveillance.disable_motion_detection(self._camera_id) diff --git a/requirements_all.txt b/requirements_all.txt index 7308df47833..d74edac0c9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,6 +539,9 @@ pwmled==1.2.1 # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 +# homeassistant.components.camera.synology +py-synology==0.1.1 + # homeassistant.components.hdmi_cec pyCEC==0.4.13 From 9381f187a41c92bc780b7c715bee38fb6e603f9c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 29 Sep 2017 12:04:22 +0200 Subject: [PATCH 016/113] yeelight: allow turn_off transitions, fixes #9602 (#9605) --- homeassistant/components/light/yeelight.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 82436334072..96d51984568 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -434,7 +434,10 @@ class YeelightLight(Light): def turn_off(self, **kwargs) -> None: """Turn off.""" import yeelight + duration = int(self.config[CONF_TRANSITION]) # in ms + if ATTR_TRANSITION in kwargs: # passed kwarg overrides config + duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s try: - self._bulb.turn_off() + self._bulb.turn_off(duration=duration) except yeelight.BulbException as ex: _LOGGER.error("Unable to turn the bulb off: %s", ex) From 52561d4f7ce1481510a2897fa88c66dbb9e79934 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Sep 2017 12:05:02 +0200 Subject: [PATCH 017/113] Move 'voltage' to const (#9621) --- homeassistant/components/sensor/pvoutput.py | 4 ++-- homeassistant/components/switch/tplink.py | 3 +-- homeassistant/const.py | 3 +++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/pvoutput.py b/homeassistant/components/sensor/pvoutput.py index baad452b629..26c3e27bba5 100644 --- a/homeassistant/components/sensor/pvoutput.py +++ b/homeassistant/components/sensor/pvoutput.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.sensor.rest import RestData from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_API_KEY, CONF_NAME, ATTR_DATE, ATTR_TIME) + ATTR_TEMPERATURE, CONF_API_KEY, CONF_NAME, ATTR_DATE, ATTR_TIME, + ATTR_VOLTAGE) _LOGGER = logging.getLogger(__name__) _ENDPOINT = 'http://pvoutput.org/service/r2/getstatus.jsp' @@ -25,7 +26,6 @@ ATTR_POWER_GENERATION = 'power_generation' ATTR_ENERGY_CONSUMPTION = 'energy_consumption' ATTR_POWER_CONSUMPTION = 'power_consumption' ATTR_EFFICIENCY = 'efficiency' -ATTR_VOLTAGE = 'voltage' CONF_SYSTEM_ID = 'system_id' diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 2f695c0bfc1..4b83cedc4c1 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -11,7 +11,7 @@ import time import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_HOST, CONF_NAME) +from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_VOLTAGE) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyHS100==0.2.4.2'] @@ -21,7 +21,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_CURRENT_CONSUMPTION = 'current_consumption' ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_DAILY_CONSUMPTION = 'daily_consumption' -ATTR_VOLTAGE = 'voltage' ATTR_CURRENT = 'current' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/const.py b/homeassistant/const.py index b6937e9a0a6..01a28493e5e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -246,6 +246,9 @@ ATTR_UNIT_OF_MEASUREMENT = 'unit_of_measurement' CONF_UNIT_SYSTEM_METRIC = 'metric' # type: str CONF_UNIT_SYSTEM_IMPERIAL = 'imperial' # type: str +# Electrical attributes +ATTR_VOLTAGE = 'voltage' + # Temperature attribute ATTR_TEMPERATURE = 'temperature' TEMP_CELSIUS = '°C' From 94370eda546e79fc3809aad031b9212657ff194c Mon Sep 17 00:00:00 2001 From: Jan Almeroth Date: Fri, 29 Sep 2017 16:45:25 +0200 Subject: [PATCH 018/113] Yamaha MusicCast: check known_hosts (#9580) * Yamaha MusicCast: check known_hosts - pymusiccast: Version bump * Update requirements --- .../media_player/yamaha_musiccast.py | 42 +++++++++++++++++-- requirements_all.txt | 2 +- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index 88d17b4d627..3e12b3bf7fa 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -33,7 +33,9 @@ SUPPORTED_FEATURES = ( SUPPORT_SELECT_SOURCE ) -REQUIREMENTS = ['pymusiccast==0.1.0'] +KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' + +REQUIREMENTS = ['pymusiccast==0.1.2'] DEFAULT_NAME = "Yamaha Receiver" DEFAULT_PORT = 5005 @@ -47,16 +49,48 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Yamaha MusicCast platform.""" + import socket import pymusiccast + known_hosts = hass.data.get(KNOWN_HOSTS_KEY) + if known_hosts is None: + known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] + _LOGGER.debug("known_hosts: %s", known_hosts) + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) - receiver = pymusiccast.McDevice(host, udp_port=port) - _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + # Get IP of host to prevent duplicates + try: + ipaddr = socket.gethostbyname(host) + except (OSError) as error: + _LOGGER.error( + "Could not communicate with %s:%d: %s", host, port, error) + return - add_devices([YamahaDevice(receiver, name)], True) + if [item for item in known_hosts if item[0] == ipaddr]: + _LOGGER.warning("Host %s:%d already registered.", host, port) + return + + if [item for item in known_hosts if item[1] == port]: + _LOGGER.warning("Port %s:%d already registered.", host, port) + return + + reg_host = (ipaddr, port) + known_hosts.append(reg_host) + + try: + receiver = pymusiccast.McDevice(ipaddr, udp_port=port) + except pymusiccast.exceptions.YMCInitError as err: + _LOGGER.error(err) + receiver = None + + if receiver: + _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + add_devices([YamahaDevice(receiver, name)], True) + else: + known_hosts.remove(reg_host) class YamahaDevice(MediaPlayerDevice): diff --git a/requirements_all.txt b/requirements_all.txt index d74edac0c9f..966c7432302 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -675,7 +675,7 @@ pymochad==0.1.1 pymodbus==1.3.1 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.0 +pymusiccast==0.1.2 # homeassistant.components.cover.myq pymyq==0.0.8 From 9232fa06e44814cab1db668c317278e425870162 Mon Sep 17 00:00:00 2001 From: Egor Tsinko Date: Fri, 29 Sep 2017 08:57:31 -0600 Subject: [PATCH 019/113] Fixed away_mode for Ecobee thermostat. (#9559) * Fixed away_mode for Ecobee thermostat. Now away mode is properly turned on using indefinite away hold. * fixed lint warnings * fixed lint warnings * - now it is possible to use float values for ecobee temperature holds - fixed a bug that caused an exception when temperature hold was set in away mode - added unit tests for ecobee thermostat * fixed lint errors * fixed lint errors --- homeassistant/components/climate/ecobee.py | 55 +-- tests/components/climate/test_ecobee.py | 452 +++++++++++++++++++++ 2 files changed, 483 insertions(+), 24 deletions(-) create mode 100644 tests/components/climate/test_ecobee.py diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 6780d3745f0..d6d92432730 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -27,6 +27,7 @@ ATTR_RESUME_ALL = 'resume_all' DEFAULT_RESUME_ALL = False TEMPERATURE_HOLD = 'temp' VACATION_HOLD = 'vacation' +AWAY_MODE = 'awayMode' DEPENDENCIES = ['ecobee'] @@ -144,20 +145,20 @@ class Thermostat(ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self.thermostat['runtime']['actualTemperature'] / 10 + return self.thermostat['runtime']['actualTemperature'] / 10.0 @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 return None @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -166,9 +167,9 @@ class Thermostat(ClimateDevice): if self.current_operation == STATE_AUTO: return None if self.current_operation == STATE_HEAT: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 elif self.current_operation == STATE_COOL: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -186,6 +187,11 @@ class Thermostat(ClimateDevice): @property def current_hold_mode(self): """Return current hold mode.""" + mode = self._current_hold_mode + return None if mode == AWAY_MODE else mode + + @property + def _current_hold_mode(self): events = self.thermostat['events'] for event in events: if event['running']: @@ -195,8 +201,8 @@ class Thermostat(ClimateDevice): int(event['startDate'][0:4]) <= 1: # A temporary hold from away climate is a hold return 'away' - # A permanent hold from away climate is away_mode - return None + # A permanent hold from away climate + return AWAY_MODE elif event['holdClimateRef'] != "": # Any other hold based on climate return event['holdClimateRef'] @@ -269,7 +275,7 @@ class Thermostat(ClimateDevice): @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self.current_hold_mode == 'away' + return self._current_hold_mode == AWAY_MODE @property def is_aux_heat_on(self): @@ -277,12 +283,17 @@ class Thermostat(ClimateDevice): return 'auxHeat' in self.thermostat['equipmentStatus'] def turn_away_mode_on(self): - """Turn away on.""" - self.set_hold_mode('away') + """Turn away mode on by setting it on away hold indefinitely.""" + if self._current_hold_mode != AWAY_MODE: + self.data.ecobee.set_climate_hold(self.thermostat_index, 'away', + 'indefinite') + self.update_without_throttle = True def turn_away_mode_off(self): """Turn away off.""" - self.set_hold_mode(None) + if self._current_hold_mode == AWAY_MODE: + self.data.ecobee.resume_program(self.thermostat_index) + self.update_without_throttle = True def set_hold_mode(self, hold_mode): """Set hold mode (away, home, temp, sleep, etc.).""" @@ -299,7 +310,7 @@ class Thermostat(ClimateDevice): self.data.ecobee.resume_program(self.thermostat_index) else: if hold_mode == TEMPERATURE_HOLD: - self.set_temp_hold(int(self.current_temperature)) + self.set_temp_hold(self.current_temperature) else: self.data.ecobee.set_climate_hold( self.thermostat_index, hold_mode, self.hold_preference()) @@ -325,15 +336,11 @@ class Thermostat(ClimateDevice): elif self.current_operation == STATE_COOL: heat_temp = temp - 20 cool_temp = temp - - self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, - heat_temp, self.hold_preference()) - _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " - "cool=%s, is=%s", heat_temp, isinstance( - heat_temp, (int, float)), cool_temp, - isinstance(cool_temp, (int, float))) - - self.update_without_throttle = True + else: + # In auto mode set temperature between + heat_temp = temp - 10 + cool_temp = temp + 10 + self.set_auto_temp_hold(heat_temp, cool_temp) def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -343,9 +350,9 @@ class Thermostat(ClimateDevice): if self.current_operation == STATE_AUTO and low_temp is not None \ and high_temp is not None: - self.set_auto_temp_hold(int(low_temp), int(high_temp)) + self.set_auto_temp_hold(low_temp, high_temp) elif temp is not None: - self.set_temp_hold(int(temp)) + self.set_temp_hold(temp) else: _LOGGER.error( "Missing valid arguments for set_temperature in %s", kwargs) @@ -364,7 +371,7 @@ class Thermostat(ClimateDevice): def resume_program(self, resume_all): """Resume the thermostat schedule program.""" self.data.ecobee.resume_program( - self.thermostat_index, str(resume_all).lower()) + self.thermostat_index, 'true' if resume_all else 'false') self.update_without_throttle = True def hold_preference(self): diff --git a/tests/components/climate/test_ecobee.py b/tests/components/climate/test_ecobee.py new file mode 100644 index 00000000000..4732376fceb --- /dev/null +++ b/tests/components/climate/test_ecobee.py @@ -0,0 +1,452 @@ +"""The test for the Ecobee thermostat module.""" +import unittest +from unittest import mock +import homeassistant.const as const +import homeassistant.components.climate.ecobee as ecobee + + +class TestEcobee(unittest.TestCase): + """Tests for Ecobee climate.""" + + def setUp(self): + """Set up test variables.""" + vals = {'name': 'Ecobee', + 'program': {'climates': [{'name': 'Climate1', + 'climateRef': 'c1'}, + {'name': 'Climate2', + 'climateRef': 'c2'}], + 'currentClimateRef': 'c1'}, + 'runtime': {'actualTemperature': 300, + 'actualHumidity': 15, + 'desiredHeat': 400, + 'desiredCool': 200, + 'desiredFanMode': 'on'}, + 'settings': {'hvacMode': 'auto', + 'fanMinOnTime': 10, + 'holdAction': 'nextTransition'}, + 'equipmentStatus': 'fan', + 'events': [{'name': 'Event1', + 'running': True, + 'type': 'hold', + 'holdClimateRef': 'away', + 'endDate': '2017-01-01 10:00:00', + 'startDate': '2017-02-02 11:00:00'}]} + + self.ecobee = mock.Mock() + self.ecobee.__getitem__ = mock.Mock(side_effect=vals.__getitem__) + self.ecobee.__setitem__ = mock.Mock(side_effect=vals.__setitem__) + + self.data = mock.Mock() + self.data.ecobee.get_thermostat.return_value = self.ecobee + self.thermostat = ecobee.Thermostat(self.data, 1, False) + + def test_name(self): + """Test name property.""" + self.assertEqual('Ecobee', self.thermostat.name) + + def test_temperature_unit(self): + """Test temperature unit property.""" + self.assertEqual(const.TEMP_FAHRENHEIT, + self.thermostat.temperature_unit) + + def test_current_temperature(self): + """Test current temperature.""" + self.assertEqual(30, self.thermostat.current_temperature) + self.ecobee['runtime']['actualTemperature'] = 404 + self.assertEqual(40.4, self.thermostat.current_temperature) + + def test_target_temperature_low(self): + """Test target low temperature.""" + self.assertEqual(40, self.thermostat.target_temperature_low) + self.ecobee['runtime']['desiredHeat'] = 502 + self.assertEqual(50.2, self.thermostat.target_temperature_low) + + def test_target_temperature_high(self): + """Test target high temperature.""" + self.assertEqual(20, self.thermostat.target_temperature_high) + self.ecobee['runtime']['desiredCool'] = 103 + self.assertEqual(10.3, self.thermostat.target_temperature_high) + + def test_target_temperature(self): + """Test target temperature.""" + self.assertIsNone(self.thermostat.target_temperature) + self.ecobee['settings']['hvacMode'] = 'heat' + self.assertEqual(40, self.thermostat.target_temperature) + self.ecobee['settings']['hvacMode'] = 'cool' + self.assertEqual(20, self.thermostat.target_temperature) + self.ecobee['settings']['hvacMode'] = 'auxHeatOnly' + self.assertEqual(40, self.thermostat.target_temperature) + self.ecobee['settings']['hvacMode'] = 'off' + self.assertIsNone(self.thermostat.target_temperature) + + def test_desired_fan_mode(self): + """Test desired fan mode property.""" + self.assertEqual('on', self.thermostat.desired_fan_mode) + self.ecobee['runtime']['desiredFanMode'] = 'auto' + self.assertEqual('auto', self.thermostat.desired_fan_mode) + + def test_fan(self): + """Test fan property.""" + self.assertEqual(const.STATE_ON, self.thermostat.fan) + self.ecobee['equipmentStatus'] = '' + self.assertEqual(const.STATE_OFF, self.thermostat.fan) + self.ecobee['equipmentStatus'] = 'heatPump, heatPump2' + self.assertEqual(const.STATE_OFF, self.thermostat.fan) + + def test_current_hold_mode_away_temporary(self): + """Test current hold mode when away.""" + # Temporary away hold + self.assertEqual('away', self.thermostat.current_hold_mode) + self.ecobee['events'][0]['endDate'] = '2018-01-01 09:49:00' + self.assertEqual('away', self.thermostat.current_hold_mode) + + def test_current_hold_mode_away_permanent(self): + """Test current hold mode when away permanently.""" + # Permanent away hold + self.ecobee['events'][0]['endDate'] = '2019-01-01 10:17:00' + self.assertIsNone(self.thermostat.current_hold_mode) + + def test_current_hold_mode_no_running_events(self): + """Test current hold mode when no running events.""" + # No running events + self.ecobee['events'][0]['running'] = False + self.assertIsNone(self.thermostat.current_hold_mode) + + def test_current_hold_mode_vacation(self): + """Test current hold mode when on vacation.""" + # Vacation Hold + self.ecobee['events'][0]['type'] = 'vacation' + self.assertEqual('vacation', self.thermostat.current_hold_mode) + + def test_current_hold_mode_climate(self): + """Test current hold mode when heat climate is set.""" + # Preset climate hold + self.ecobee['events'][0]['type'] = 'hold' + self.ecobee['events'][0]['holdClimateRef'] = 'heatClimate' + self.assertEqual('heatClimate', self.thermostat.current_hold_mode) + + def test_current_hold_mode_temperature_hold(self): + """Test current hold mode when temperature hold is set.""" + # Temperature hold + self.ecobee['events'][0]['type'] = 'hold' + self.ecobee['events'][0]['holdClimateRef'] = '' + self.assertEqual('temp', self.thermostat.current_hold_mode) + + def test_current_hold_mode_auto_hold(self): + """Test current hold mode when auto heat is set.""" + # auto Hold + self.ecobee['events'][0]['type'] = 'autoHeat' + self.assertEqual('heat', self.thermostat.current_hold_mode) + + def test_current_operation(self): + """Test current operation property.""" + self.assertEqual('auto', self.thermostat.current_operation) + self.ecobee['settings']['hvacMode'] = 'heat' + self.assertEqual('heat', self.thermostat.current_operation) + self.ecobee['settings']['hvacMode'] = 'cool' + self.assertEqual('cool', self.thermostat.current_operation) + self.ecobee['settings']['hvacMode'] = 'auxHeatOnly' + self.assertEqual('heat', self.thermostat.current_operation) + self.ecobee['settings']['hvacMode'] = 'off' + self.assertEqual('off', self.thermostat.current_operation) + + def test_operation_list(self): + """Test operation list property.""" + self.assertEqual(['auto', 'auxHeatOnly', 'cool', + 'heat', 'off'], self.thermostat.operation_list) + + def test_operation_mode(self): + """Test operation mode property.""" + self.assertEqual('auto', self.thermostat.operation_mode) + self.ecobee['settings']['hvacMode'] = 'heat' + self.assertEqual('heat', self.thermostat.operation_mode) + + def test_mode(self): + """Test mode property.""" + self.assertEqual('Climate1', self.thermostat.mode) + self.ecobee['program']['currentClimateRef'] = 'c2' + self.assertEqual('Climate2', self.thermostat.mode) + + def test_fan_min_on_time(self): + """Test fan min on time property.""" + self.assertEqual(10, self.thermostat.fan_min_on_time) + self.ecobee['settings']['fanMinOnTime'] = 100 + self.assertEqual(100, self.thermostat.fan_min_on_time) + + def test_device_state_attributes(self): + """Test device state attributes property.""" + self.ecobee['equipmentStatus'] = 'heatPump2' + self.assertEqual({'actual_humidity': 15, + 'climate_list': ['Climate1', 'Climate2'], + 'fan': 'off', + 'fan_min_on_time': 10, + 'mode': 'Climate1', + 'operation': 'heat'}, + self.thermostat.device_state_attributes) + + self.ecobee['equipmentStatus'] = 'auxHeat2' + self.assertEqual({'actual_humidity': 15, + 'climate_list': ['Climate1', 'Climate2'], + 'fan': 'off', + 'fan_min_on_time': 10, + 'mode': 'Climate1', + 'operation': 'heat'}, + self.thermostat.device_state_attributes) + self.ecobee['equipmentStatus'] = 'compCool1' + self.assertEqual({'actual_humidity': 15, + 'climate_list': ['Climate1', 'Climate2'], + 'fan': 'off', + 'fan_min_on_time': 10, + 'mode': 'Climate1', + 'operation': 'cool'}, + self.thermostat.device_state_attributes) + self.ecobee['equipmentStatus'] = '' + self.assertEqual({'actual_humidity': 15, + 'climate_list': ['Climate1', 'Climate2'], + 'fan': 'off', + 'fan_min_on_time': 10, + 'mode': 'Climate1', + 'operation': 'idle'}, + self.thermostat.device_state_attributes) + + self.ecobee['equipmentStatus'] = 'Unknown' + self.assertEqual({'actual_humidity': 15, + 'climate_list': ['Climate1', 'Climate2'], + 'fan': 'off', + 'fan_min_on_time': 10, + 'mode': 'Climate1', + 'operation': 'Unknown'}, + self.thermostat.device_state_attributes) + + def test_is_away_mode_on(self): + """Test away mode property.""" + self.assertFalse(self.thermostat.is_away_mode_on) + # Temporary away hold + self.ecobee['events'][0]['endDate'] = '2018-01-01 11:12:12' + self.assertFalse(self.thermostat.is_away_mode_on) + # Permanent away hold + self.ecobee['events'][0]['endDate'] = '2019-01-01 13:12:12' + self.assertTrue(self.thermostat.is_away_mode_on) + # No running events + self.ecobee['events'][0]['running'] = False + self.assertFalse(self.thermostat.is_away_mode_on) + # Vacation Hold + self.ecobee['events'][0]['type'] = 'vacation' + self.assertFalse(self.thermostat.is_away_mode_on) + # Preset climate hold + self.ecobee['events'][0]['type'] = 'hold' + self.ecobee['events'][0]['holdClimateRef'] = 'heatClimate' + self.assertFalse(self.thermostat.is_away_mode_on) + # Temperature hold + self.ecobee['events'][0]['type'] = 'hold' + self.ecobee['events'][0]['holdClimateRef'] = '' + self.assertFalse(self.thermostat.is_away_mode_on) + # auto Hold + self.ecobee['events'][0]['type'] = 'autoHeat' + self.assertFalse(self.thermostat.is_away_mode_on) + + def test_is_aux_heat_on(self): + """Test aux heat property.""" + self.assertFalse(self.thermostat.is_aux_heat_on) + self.ecobee['equipmentStatus'] = 'fan, auxHeat' + self.assertTrue(self.thermostat.is_aux_heat_on) + + def test_turn_away_mode_on_off(self): + """Test turn away mode setter.""" + self.data.reset_mock() + # Turn on first while the current hold mode is not away hold + self.thermostat.turn_away_mode_on() + self.data.ecobee.set_climate_hold.assert_has_calls( + [mock.call(1, 'away', 'indefinite')]) + + # Try with away hold + self.data.reset_mock() + self.ecobee['events'][0]['endDate'] = '2019-01-01 11:12:12' + # Should not call set_climate_hold() + self.assertFalse(self.data.ecobee.set_climate_hold.called) + + # Try turning off while hold mode is away hold + self.data.reset_mock() + self.thermostat.turn_away_mode_off() + self.data.ecobee.resume_program.assert_has_calls([mock.call(1)]) + + # Try turning off when it has already been turned off + self.data.reset_mock() + self.ecobee['events'][0]['endDate'] = '2017-01-01 14:00:00' + self.thermostat.turn_away_mode_off() + self.assertFalse(self.data.ecobee.resume_program.called) + + def test_set_hold_mode(self): + """Test hold mode setter.""" + # Test same hold mode + # Away->Away + self.data.reset_mock() + self.thermostat.set_hold_mode('away') + self.assertFalse(self.data.ecobee.delete_vacation.called) + self.assertFalse(self.data.ecobee.resume_program.called) + self.assertFalse(self.data.ecobee.set_hold_temp.called) + self.assertFalse(self.data.ecobee.set_climate_hold.called) + + # Away->'None' + self.data.reset_mock() + self.thermostat.set_hold_mode('None') + self.assertFalse(self.data.ecobee.delete_vacation.called) + self.data.ecobee.resume_program.assert_has_calls([mock.call(1)]) + self.assertFalse(self.data.ecobee.set_hold_temp.called) + self.assertFalse(self.data.ecobee.set_climate_hold.called) + + # Vacation Hold -> None + self.ecobee['events'][0]['type'] = 'vacation' + self.data.reset_mock() + self.thermostat.set_hold_mode(None) + self.data.ecobee.delete_vacation.assert_has_calls( + [mock.call(1, 'Event1')]) + self.assertFalse(self.data.ecobee.resume_program.called) + self.assertFalse(self.data.ecobee.set_hold_temp.called) + self.assertFalse(self.data.ecobee.set_climate_hold.called) + + # Away -> home, sleep + for hold in ['home', 'sleep']: + self.data.reset_mock() + self.thermostat.set_hold_mode(hold) + self.assertFalse(self.data.ecobee.delete_vacation.called) + self.assertFalse(self.data.ecobee.resume_program.called) + self.assertFalse(self.data.ecobee.set_hold_temp.called) + self.data.ecobee.set_climate_hold.assert_has_calls( + [mock.call(1, hold, 'nextTransition')]) + + # Away -> temp + self.data.reset_mock() + self.thermostat.set_hold_mode('temp') + self.assertFalse(self.data.ecobee.delete_vacation.called) + self.assertFalse(self.data.ecobee.resume_program.called) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 40.0, 20.0, 'nextTransition')]) + self.assertFalse(self.data.ecobee.set_climate_hold.called) + + def test_set_auto_temp_hold(self): + """Test auto temp hold setter.""" + self.data.reset_mock() + self.thermostat.set_auto_temp_hold(20.0, 30) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 30, 20.0, 'nextTransition')]) + + def test_set_temp_hold(self): + """Test temp hold setter.""" + # Away mode or any mode other than heat or cool + self.data.reset_mock() + self.thermostat.set_temp_hold(30.0) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 40.0, 20.0, 'nextTransition')]) + + # Heat mode + self.data.reset_mock() + self.ecobee['settings']['hvacMode'] = 'heat' + self.thermostat.set_temp_hold(30) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 50, 30, 'nextTransition')]) + + # Cool mode + self.data.reset_mock() + self.ecobee['settings']['hvacMode'] = 'cool' + self.thermostat.set_temp_hold(30) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 30, 10, 'nextTransition')]) + + def test_set_temperature(self): + """Test set temperature.""" + # Auto -> Auto + self.data.reset_mock() + self.thermostat.set_temperature(target_temp_low=20, + target_temp_high=30) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 30, 20, 'nextTransition')]) + + # Auto -> Hold + self.data.reset_mock() + self.thermostat.set_temperature(temperature=20) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 30, 10, 'nextTransition')]) + + # Cool -> Hold + self.data.reset_mock() + self.ecobee['settings']['hvacMode'] = 'cool' + self.thermostat.set_temperature(temperature=20.5) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 20.5, 0.5, 'nextTransition')]) + + # Heat -> Hold + self.data.reset_mock() + self.ecobee['settings']['hvacMode'] = 'heat' + self.thermostat.set_temperature(temperature=20) + self.data.ecobee.set_hold_temp.assert_has_calls( + [mock.call(1, 40, 20, 'nextTransition')]) + + # Heat -> Auto + self.data.reset_mock() + self.ecobee['settings']['hvacMode'] = 'heat' + self.thermostat.set_temperature(target_temp_low=20, + target_temp_high=30) + self.assertFalse(self.data.ecobee.set_hold_temp.called) + + def test_set_operation_mode(self): + """Test operation mode setter.""" + self.data.reset_mock() + self.thermostat.set_operation_mode('auto') + self.data.ecobee.set_hvac_mode.assert_has_calls( + [mock.call(1, 'auto')]) + self.data.reset_mock() + self.thermostat.set_operation_mode('heat') + self.data.ecobee.set_hvac_mode.assert_has_calls( + [mock.call(1, 'heat')]) + + def test_set_fan_min_on_time(self): + """Test fan min on time setter.""" + self.data.reset_mock() + self.thermostat.set_fan_min_on_time(15) + self.data.ecobee.set_fan_min_on_time.assert_has_calls( + [mock.call(1, 15)]) + self.data.reset_mock() + self.thermostat.set_fan_min_on_time(20) + self.data.ecobee.set_fan_min_on_time.assert_has_calls( + [mock.call(1, 20)]) + + def test_resume_program(self): + """Test resume program.""" + # False + self.data.reset_mock() + self.thermostat.resume_program(False) + self.data.ecobee.resume_program.assert_has_calls( + [mock.call(1, 'false')]) + self.data.reset_mock() + self.thermostat.resume_program(None) + self.data.ecobee.resume_program.assert_has_calls( + [mock.call(1, 'false')]) + self.data.reset_mock() + self.thermostat.resume_program(0) + self.data.ecobee.resume_program.assert_has_calls( + [mock.call(1, 'false')]) + + # True + self.data.reset_mock() + self.thermostat.resume_program(True) + self.data.ecobee.resume_program.assert_has_calls( + [mock.call(1, 'true')]) + self.data.reset_mock() + self.thermostat.resume_program(1) + self.data.ecobee.resume_program.assert_has_calls( + [mock.call(1, 'true')]) + + def test_hold_preference(self): + """Test hold preference.""" + self.assertEqual('nextTransition', self.thermostat.hold_preference()) + for action in ['useEndTime4hour', 'useEndTime2hour', + 'nextPeriod', 'indefinite', 'askMe']: + self.ecobee['settings']['holdAction'] = action + self.assertEqual('nextTransition', + self.thermostat.hold_preference()) + + def test_climate_list(self): + """Test climate list property.""" + self.assertEqual(['Climate1', 'Climate2'], + self.thermostat.climate_list) From e406c57ec945515f20f977cf732ffe972e8d6aa4 Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Fri, 29 Sep 2017 15:34:14 -0600 Subject: [PATCH 020/113] Switched VeraSensor to use category ids (#9624) --- homeassistant/components/sensor/vera.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index 5cb528219a5..aba889fcffd 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -48,18 +48,20 @@ class VeraSensor(VeraDevice, Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self.vera_device.category == "Temperature Sensor": + import pyvera as veraApi + if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: return self._temperature_units - elif self.vera_device.category == "Light Sensor": + elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: return 'lux' - elif self.vera_device.category == "Humidity Sensor": + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: return '%' - elif self.vera_device.category == "Power meter": + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: return 'watts' def update(self): """Update the state.""" - if self.vera_device.category == "Temperature Sensor": + import pyvera as veraApi + if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: self.current_value = self.vera_device.temperature vera_temp_units = ( @@ -70,11 +72,11 @@ class VeraSensor(VeraDevice, Entity): else: self._temperature_units = TEMP_CELSIUS - elif self.vera_device.category == "Light Sensor": + elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: self.current_value = self.vera_device.light - elif self.vera_device.category == "Humidity Sensor": + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: self.current_value = self.vera_device.humidity - elif self.vera_device.category == "Scene Controller": + elif self.vera_device.category == veraApi.CATEGORY_SCENE_CONTROLLER: value = self.vera_device.get_last_scene_id(True) time = self.vera_device.get_last_scene_time(True) if time == self.last_changed_time: @@ -82,10 +84,10 @@ class VeraSensor(VeraDevice, Entity): else: self.current_value = value self.last_changed_time = time - elif self.vera_device.category == "Power meter": + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: power = convert(self.vera_device.power, float, 0) self.current_value = int(round(power, 0)) - elif self.vera_device.category == "Sensor": + elif self.vera_device.is_trippable: tripped = self.vera_device.is_tripped self.current_value = 'Tripped' if tripped else 'Not Tripped' else: From 80a15977ff391df2cfb1a8cb58405750b7d888c6 Mon Sep 17 00:00:00 2001 From: Phil Kates Date: Sat, 30 Sep 2017 00:35:25 -0700 Subject: [PATCH 021/113] splunk: Handle datetime objects in event payload (#9628) If an event contained a datetime.datetime object it would cause an exception in the Splunk component. Most of the media_player components do this in their `media_position_updated_at` attribute. Use the JSONEncoder from homeassistant.remote instead of just using the standard json.dumps encoder. Fixes #9590 --- homeassistant/components/splunk.py | 4 +++- tests/components/test_splunk.py | 34 +++++++++++++++++++----------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py index 2b4ea862d2d..38f8a91a917 100644 --- a/homeassistant/components/splunk.py +++ b/homeassistant/components/splunk.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, EVENT_STATE_CHANGED) from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv +from homeassistant.remote import JSONEncoder _LOGGER = logging.getLogger(__name__) @@ -81,7 +82,8 @@ def setup(hass, config): "host": event_collector, "event": json_body, } - requests.post(event_collector, data=json.dumps(payload), + requests.post(event_collector, + data=json.dumps(payload, cls=JSONEncoder), headers=headers, timeout=10) except requests.exceptions.RequestException as error: _LOGGER.exception("Error saving event to Splunk: %s", error) diff --git a/tests/components/test_splunk.py b/tests/components/test_splunk.py index d0c46c5f8ea..38143119112 100644 --- a/tests/components/test_splunk.py +++ b/tests/components/test_splunk.py @@ -1,10 +1,13 @@ """The tests for the Splunk component.""" +import json import unittest from unittest import mock from homeassistant.setup import setup_component import homeassistant.components.splunk as splunk from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED +from homeassistant.helpers import state as state_helper +import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant @@ -71,30 +74,37 @@ class TestSplunk(unittest.TestCase): self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] @mock.patch.object(splunk, 'requests') - @mock.patch('json.dumps') - def test_event_listener(self, mock_dump, mock_requests): + def test_event_listener(self, mock_requests): """Test event listener.""" - mock_dump.side_effect = lambda x: x self._setup(mock_requests) - valid = {'1': 1, - '1.0': 1.0, - STATE_ON: 1, - STATE_OFF: 0, - 'foo': 'foo', - } + now = dt_util.now() + valid = { + '1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0, + 'foo': 'foo', + } for in_, out in valid.items(): state = mock.MagicMock(state=in_, domain='fake', object_id='entity', - attributes={}) + attributes={'datetime_attr': now}) event = mock.MagicMock(data={'new_state': state}, time_fired=12345) + try: + out = state_helper.state_as_number(state) + except ValueError: + out = state.state + body = [{ 'domain': 'fake', 'entity_id': 'entity', - 'attributes': {}, + 'attributes': { + 'datetime_attr': now.isoformat() + }, 'time': '12345', 'value': out, 'host': 'HASS', @@ -107,7 +117,7 @@ class TestSplunk(unittest.TestCase): self.assertEqual( self.mock_post.call_args, mock.call( - payload['host'], data=payload, + payload['host'], data=json.dumps(payload), headers={'Authorization': 'Splunk secret'}, timeout=10 ) From 29c40622d34643f58fac1df3648cf54de4ab4a7d Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Sat, 30 Sep 2017 16:29:40 +0200 Subject: [PATCH 022/113] MQTT climate platform [continuation of #8750] (#9589) * New climate platform with MQTT * Use STATE_OFF * Basic tests for climate.mqtt * lint * actualy collect coverage * First tests and fixes * Add possibility to receive temperature via MQTT * Require only either sensor or mqtt topic * Add mqtt publishing for away mode, hold mode and aux heat. * Use configurabe on/off payloads * Add pessimistic mode * Initialize aux and away with False instead of None * Remove Sensor * Use correct scheduling method * Move all methods to coroutines --- homeassistant/components/climate/mqtt.py | 483 +++++++++++++++++++++++ tests/components/climate/test_mqtt.py | 420 ++++++++++++++++++++ 2 files changed, 903 insertions(+) create mode 100644 homeassistant/components/climate/mqtt.py create mode 100644 tests/components/climate/test_mqtt.py diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py new file mode 100644 index 00000000000..2f7bba74185 --- /dev/null +++ b/homeassistant/components/climate/mqtt.py @@ -0,0 +1,483 @@ +""" +Support for MQTT climate devices. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.mqtt/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.components.mqtt as mqtt + +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice, + PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, + ATTR_OPERATION_MODE) +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME) +from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, + SPEED_HIGH) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +DEFAULT_NAME = 'MQTT HVAC' + +CONF_POWER_COMMAND_TOPIC = 'power_command_topic' +CONF_POWER_STATE_TOPIC = 'power_state_topic' +CONF_MODE_COMMAND_TOPIC = 'mode_command_topic' +CONF_MODE_STATE_TOPIC = 'mode_state_topic' +CONF_TEMPERATURE_COMMAND_TOPIC = 'temperature_command_topic' +CONF_TEMPERATURE_STATE_TOPIC = 'temperature_state_topic' +CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic' +CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic' +CONF_SWING_MODE_COMMAND_TOPIC = 'swing_mode_command_topic' +CONF_SWING_MODE_STATE_TOPIC = 'swing_mode_state_topic' +CONF_AWAY_MODE_COMMAND_TOPIC = 'away_mode_command_topic' +CONF_AWAY_MODE_STATE_TOPIC = 'away_mode_state_topic' +CONF_HOLD_COMMAND_TOPIC = 'hold_command_topic' +CONF_HOLD_STATE_TOPIC = 'hold_state_topic' +CONF_AUX_COMMAND_TOPIC = 'aux_command_topic' +CONF_AUX_STATE_TOPIC = 'aux_state_topic' + +CONF_CURRENT_TEMPERATURE_TOPIC = 'current_temperature_topic' + +CONF_PAYLOAD_ON = 'payload_on' +CONF_PAYLOAD_OFF = 'payload_off' + +CONF_FAN_MODE_LIST = 'fan_modes' +CONF_MODE_LIST = 'modes' +CONF_SWING_MODE_LIST = 'swing_modes' +CONF_INITIAL = 'initial' +CONF_SEND_IF_OFF = 'send_if_off' + +PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_CURRENT_TEMPERATURE_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_LIST, + default=[STATE_AUTO, SPEED_LOW, + SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, + vol.Optional(CONF_SWING_MODE_LIST, + default=[STATE_ON, STATE_OFF]): cv.ensure_list, + vol.Optional(CONF_MODE_LIST, + default=[STATE_AUTO, STATE_OFF, STATE_COOL, STATE_HEAT, + STATE_DRY, STATE_FAN_ONLY]): cv.ensure_list, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=21): cv.positive_int, + vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, + vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the MQTT climate devices.""" + async_add_devices([ + MqttClimate( + hass, + config.get(CONF_NAME), + { + key: config.get(key) for key in ( + CONF_POWER_COMMAND_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_HOLD_COMMAND_TOPIC, + CONF_AUX_COMMAND_TOPIC, + CONF_POWER_STATE_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMPERATURE_STATE_TOPIC, + CONF_FAN_MODE_STATE_TOPIC, + CONF_SWING_MODE_STATE_TOPIC, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_STATE_TOPIC, + CONF_AUX_STATE_TOPIC, + CONF_CURRENT_TEMPERATURE_TOPIC + ) + }, + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_MODE_LIST), + config.get(CONF_FAN_MODE_LIST), + config.get(CONF_SWING_MODE_LIST), + config.get(CONF_INITIAL), + False, None, SPEED_LOW, + STATE_OFF, STATE_OFF, False, + config.get(CONF_SEND_IF_OFF), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF)) + ]) + + +class MqttClimate(ClimateDevice): + """Representation of a demo climate device.""" + + def __init__(self, hass, name, topic, qos, retain, mode_list, + fan_mode_list, swing_mode_list, target_temperature, away, + hold, current_fan_mode, current_swing_mode, + current_operation, aux, send_if_off, payload_on, + payload_off): + """Initialize the climate device.""" + self.hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._retain = retain + self._target_temperature = target_temperature + self._unit_of_measurement = hass.config.units.temperature_unit + self._away = away + self._hold = hold + self._current_temperature = None + self._current_fan_mode = current_fan_mode + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = fan_mode_list + self._operation_list = mode_list + self._swing_list = swing_mode_list + self._target_temperature_step = 1 + self._send_if_off = send_if_off + self._payload_on = payload_on + self._payload_off = payload_off + + def async_added_to_hass(self): + """Handle being added to home assistant.""" + @callback + def handle_current_temp_received(topic, payload, qos): + """Handle current temperature coming via MQTT.""" + try: + self._current_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], + handle_current_temp_received, self._qos) + + @callback + def handle_mode_received(topic, payload, qos): + """Handle receiving mode via MQTT.""" + if payload not in self._operation_list: + _LOGGER.error("Invalid mode: %s", payload) + else: + self._current_operation = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_MODE_STATE_TOPIC], + handle_mode_received, self._qos) + + @callback + def handle_temperature_received(topic, payload, qos): + """Handle target temperature coming via MQTT.""" + try: + self._target_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC], + handle_temperature_received, self._qos) + + @callback + def handle_fan_mode_received(topic, payload, qos): + """Handle receiving fan mode via MQTT.""" + if payload not in self._fan_list: + _LOGGER.error("Invalid fan mode: %s", payload) + else: + self._current_fan_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC], + handle_fan_mode_received, self._qos) + + @callback + def handle_swing_mode_received(topic, payload, qos): + """Handle receiving swing mode via MQTT.""" + if payload not in self._swing_list: + _LOGGER.error("Invalid swing mode: %s", payload) + else: + self._current_swing_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC], + handle_swing_mode_received, self._qos) + + @callback + def handle_away_mode_received(topic, payload, qos): + """Handle receiving away mode via MQTT.""" + if payload == self._payload_on: + self._away = True + elif payload == self._payload_off: + self._away = False + else: + _LOGGER.error("Invalid away mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC], + handle_away_mode_received, self._qos) + + @callback + def handle_aux_mode_received(topic, payload, qos): + """Handle receiving aux mode via MQTT.""" + if payload == self._payload_on: + self._aux = True + elif payload == self._payload_off: + self._aux = False + else: + _LOGGER.error("Invalid aux mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AUX_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AUX_STATE_TOPIC], + handle_aux_mode_received, self._qos) + + @callback + def handle_hold_mode_received(topic, payload, qos): + """Handle receiving hold mode via MQTT.""" + self._hold = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_HOLD_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_HOLD_STATE_TOPIC], + handle_hold_mode_received, self._qos) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._target_temperature_step + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + @property + def current_hold_mode(self): + """Return hold mode setting.""" + return self._hold + + @property + def is_aux_heat_on(self): + """Return true if away mode is on.""" + return self._aux + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._fan_list + + @asyncio.coroutine + def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_OPERATION_MODE) is not None: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + yield from self.async_set_operation_mode(operation_mode) + + if kwargs.get(ATTR_TEMPERATURE) is not None: + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: + # optimistic mode + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC], + kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain) + + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_swing_mode(self, swing_mode): + """Set new swing mode.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC], + swing_mode, self._qos, self._retain) + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: + self._current_swing_mode = swing_mode + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_fan_mode(self, fan): + """Set new target temperature.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], + fan, self._qos, self._retain) + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: + self._current_fan_mode = fan + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode) -> None: + """Set new operation mode.""" + if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: + if (self._current_operation == STATE_OFF and + operation_mode != STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + elif (self._current_operation != STATE_OFF and + operation_mode == STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish( + self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], + operation_mode, self._qos, self._retain) + + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = operation_mode + self.async_schedule_update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + @asyncio.coroutine + def async_turn_away_mode_on(self): + """Turn away mode on.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_away_mode_off(self): + """Turn away mode off.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = False + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_hold_mode(self, hold): + """Update hold mode on.""" + if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_HOLD_COMMAND_TOPIC], + hold, self._qos, self._retain) + + if self._topic[CONF_HOLD_STATE_TOPIC] is None: + self._hold = hold + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_on(self): + """Turn auxillary heater on.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_off(self): + """Turn auxillary heater off.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = False + self.async_schedule_update_ha_state() diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py new file mode 100644 index 00000000000..9b70138908d --- /dev/null +++ b/tests/components/climate/test_mqtt.py @@ -0,0 +1,420 @@ +"""The tests for the mqtt climate component.""" +import unittest +import copy + +from homeassistant.util.unit_system import ( + METRIC_SYSTEM +) +from homeassistant.setup import setup_component +from homeassistant.components import climate +from homeassistant.const import STATE_OFF + +from tests.common import (get_test_home_assistant, mock_mqtt_component, + fire_mqtt_message, mock_component) + +ENTITY_CLIMATE = 'climate.test' + +DEFAULT_CONFIG = { + 'climate': { + 'platform': 'mqtt', + 'name': 'test', + 'mode_command_topic': 'mode-topic', + 'temperature_command_topic': 'temperature-topic', + 'fan_mode_command_topic': 'fan-mode-topic', + 'swing_mode_command_topic': 'swing-mode-topic', + 'away_mode_command_topic': 'away-mode-topic', + 'hold_command_topic': 'hold-topic', + 'aux_command_topic': 'aux-topic' + }} + + +class TestMQTTClimate(unittest.TestCase): + """Test the mqtt climate hvac.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + self.hass.config.units = METRIC_SYSTEM + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_params(self): + """Test the initial parameters.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + self.assertEqual("low", state.attributes.get('fan_mode')) + self.assertEqual("off", state.attributes.get('swing_mode')) + self.assertEqual("off", state.attributes.get('operation_mode')) + + def test_get_operation_modes(self): + """Test that the operation list returns the correct modes.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + modes = state.attributes.get('operation_list') + self.assertEqual([ + climate.STATE_AUTO, STATE_OFF, climate.STATE_COOL, + climate.STATE_HEAT, climate.STATE_DRY, climate.STATE_FAN_ONLY + ], modes) + + def test_set_operation_bad_attr_and_state(self): + """Test setting operation mode without required attribute. + + Also check the state. + """ + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + climate.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + + def test_set_operation(self): + """Test setting of new operation mode.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + climate.set_operation_mode(self.hass, "cool", ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) + self.assertEqual(('mode-topic', 'cool', 0, False), + self.mock_publish.mock_calls[-2][1]) + + def test_set_operation_pessimistic(self): + """Test setting operation mode in pessimistic mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['mode_state_topic'] = 'mode-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + + climate.set_operation_mode(self.hass, "cool", ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + + fire_mqtt_message(self.hass, 'mode-state', 'cool') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) + + fire_mqtt_message(self.hass, 'mode-state', 'bogus mode') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) + + def test_set_fan_mode_bad_attr(self): + """Test setting fan mode without required attribute.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("low", state.attributes.get('fan_mode')) + climate.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("low", state.attributes.get('fan_mode')) + + def test_set_fan_mode_pessimistic(self): + """Test setting of new fan mode in pessimistic mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['fan_mode_state_topic'] = 'fan-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("low", state.attributes.get('fan_mode')) + + climate.set_fan_mode(self.hass, 'high', ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("low", state.attributes.get('fan_mode')) + + fire_mqtt_message(self.hass, 'fan-state', 'high') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('high', state.attributes.get('fan_mode')) + + fire_mqtt_message(self.hass, 'fan-state', 'bogus mode') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('high', state.attributes.get('fan_mode')) + + def test_set_fan_mode(self): + """Test setting of new fan mode.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("low", state.attributes.get('fan_mode')) + climate.set_fan_mode(self.hass, 'high', ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('fan-mode-topic', 'high', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('high', state.attributes.get('fan_mode')) + + def test_set_swing_mode_bad_attr(self): + """Test setting swing mode without required attribute.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('swing_mode')) + climate.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('swing_mode')) + + def test_set_swing_pessimistic(self): + """Test setting swing mode in pessimistic mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['swing_mode_state_topic'] = 'swing-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('swing_mode')) + + climate.set_swing_mode(self.hass, 'on', ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('swing_mode')) + + fire_mqtt_message(self.hass, 'swing-state', 'on') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("on", state.attributes.get('swing_mode')) + + fire_mqtt_message(self.hass, 'swing-state', 'bogus state') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("on", state.attributes.get('swing_mode')) + + def test_set_swing(self): + """Test setting of new swing mode.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('swing_mode')) + climate.set_swing_mode(self.hass, 'on', ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('swing-mode-topic', 'on', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("on", state.attributes.get('swing_mode')) + + def test_set_target_temperature(self): + """Test setting the target temperature.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + climate.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('mode-topic', 'heat', 0, False), + self.mock_publish.mock_calls[-2][1]) + climate.set_temperature(self.hass, temperature=47, + entity_id=ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(47, state.attributes.get('temperature')) + self.assertEqual(('temperature-topic', 47, 0, False), + self.mock_publish.mock_calls[-2][1]) + + def test_set_target_temperature_pessimistic(self): + """Test setting the target temperature.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['temperature_state_topic'] = 'temperature-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + climate.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) + self.hass.block_till_done() + climate.set_temperature(self.hass, temperature=47, + entity_id=ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + + fire_mqtt_message(self.hass, 'temperature-state', '1701') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(1701, state.attributes.get('temperature')) + + fire_mqtt_message(self.hass, 'temperature-state', 'not a number') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(1701, state.attributes.get('temperature')) + + def test_receive_mqtt_temperature(self): + """Test getting the current temperature via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['current_temperature_topic'] = 'current_temperature' + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, climate.DOMAIN, config) + + fire_mqtt_message(self.hass, 'current_temperature', '47') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(47, state.attributes.get('current_temperature')) + + def test_set_away_mode_pessimistic(self): + """Test setting of the away mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['away_mode_state_topic'] = 'away-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + climate.set_away_mode(self.hass, True, ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + fire_mqtt_message(self.hass, 'away-state', 'ON') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + + fire_mqtt_message(self.hass, 'away-state', 'OFF') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + fire_mqtt_message(self.hass, 'away-state', 'nonsense') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_away_mode(self): + """Test setting of the away mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['payload_on'] = 'AN' + config['climate']['payload_off'] = 'AUS' + + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + climate.set_away_mode(self.hass, True, ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('away-mode-topic', 'AN', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + + climate.set_away_mode(self.hass, False, ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('away-mode-topic', 'AUS', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_hold_pessimistic(self): + """Test setting the hold mode in pessimistic mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['hold_state_topic'] = 'hold-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(None, state.attributes.get('hold_mode')) + + climate.set_hold_mode(self.hass, 'on', ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(None, state.attributes.get('hold_mode')) + + fire_mqtt_message(self.hass, 'hold-state', 'on') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('hold_mode')) + + fire_mqtt_message(self.hass, 'hold-state', 'off') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('hold_mode')) + + def test_set_hold(self): + """Test setting the hold mode.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(None, state.attributes.get('hold_mode')) + climate.set_hold_mode(self.hass, 'on', ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('hold-topic', 'on', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('hold_mode')) + + climate.set_hold_mode(self.hass, 'off', ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('hold-topic', 'off', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('hold_mode')) + + def test_set_aux_pessimistic(self): + """Test setting of the aux heating in pessimistic mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['aux_state_topic'] = 'aux-state' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + + climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + + fire_mqtt_message(self.hass, 'aux-state', 'ON') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('aux_heat')) + + fire_mqtt_message(self.hass, 'aux-state', 'OFF') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + + fire_mqtt_message(self.hass, 'aux-state', 'nonsense') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_set_aux(self): + """Test setting of the aux heating.""" + assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('aux-topic', 'ON', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('aux_heat')) + + climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) + self.hass.block_till_done() + self.assertEqual(('aux-topic', 'OFF', 0, False), + self.mock_publish.mock_calls[-2][1]) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) From fa32411ab1f2b34b53abeb1964ee18d275d7aa24 Mon Sep 17 00:00:00 2001 From: Gabor SZOLLOSI Date: Sun, 1 Oct 2017 12:41:21 +0200 Subject: [PATCH 023/113] wunderground: fix supported language codes #9631 (#9633) * removed PU, added TR language code (https://www.wunderground.com/weather/api/d/docs?d=language-support&MR=1), fixes #9631 --- homeassistant/components/sensor/wunderground.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index b68ef67bf37..2fcb13e13dd 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -607,10 +607,10 @@ LANG_CODES = [ 'KR', 'KU', 'LA', 'LV', 'LT', 'ND', 'MK', 'MT', 'GM', 'MI', 'MR', 'MN', 'NO', 'OC', 'PS', 'GN', 'PL', 'BR', - 'PA', 'PU', 'RO', 'RU', 'SR', 'SK', - 'SL', 'SP', 'SI', 'SW', 'CH', 'TL', - 'TT', 'TH', 'UA', 'UZ', 'VU', 'CY', - 'SN', 'JI', 'YI', + 'PA', 'RO', 'RU', 'SR', 'SK', 'SL', + 'SP', 'SI', 'SW', 'CH', 'TL', 'TT', + 'TH', 'TR', 'TK', 'UA', 'UZ', 'VU', + 'CY', 'SN', 'JI', 'YI', ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ From 70c8970555a0add075b33affbe0afc5a1fcf2750 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 2 Oct 2017 08:04:33 +0200 Subject: [PATCH 024/113] add myself to codeowners (#9642) --- CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 3c975ca3862..ad9345c3ab6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -39,3 +39,7 @@ homeassistant/components/*/zwave.py @home-assistant/z-wave homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/automatic.py @armills homeassistant/components/media_player/kodi.py @armills +homeassistant/components/light/tplink.py @rytilahti +homeassistant/components/switch/tplink.py @rytilahti +homeassistant/components/climate/eq3btsmart.py @rytilahti +homeassistant/components/*/xiaomi_miio.py @rytilahti From fc4a21e491f615569b70aad03fd04027b99adfa1 Mon Sep 17 00:00:00 2001 From: Gabor SZOLLOSI Date: Mon, 2 Oct 2017 08:05:24 +0200 Subject: [PATCH 025/113] raspihats: unmet dependency fix (#9638) * raspihats: update to 2.2.3 (deps fix) Raspihats platform update, upstream fixed enum34 requirements, added smbus dependency Fixes #9547 * raspihats: update to 2.2.3, smbus-cffi dependency Raspihats platform update, upstream fixed enum34 requirements, added smbus dependency Fixes #9547 * raspihats: update to 2.2.3 * raspihats: update to 2.2.3, smbus-cffi dependency * raspihats: update to 2.2.3, smbus-cffi dependency * raspihats: update to 2.2.3 (deps fix) Raspihats platform update, upstream fixed enum34 requirements, added smbus dependency Fixes #9547 * raspihats: update to 2.2.3, smbus-cffi dependency --- homeassistant/components/raspihats.py | 3 ++- requirements_all.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py index 3ab433f4b91..e9d65b85c81 100644 --- a/homeassistant/components/raspihats.py +++ b/homeassistant/components/raspihats.py @@ -12,7 +12,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP ) -REQUIREMENTS = ['raspihats==2.2.1'] +REQUIREMENTS = ['raspihats==2.2.3', + 'smbus-cffi==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 966c7432302..911261115f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -865,7 +865,7 @@ radiotherm==1.3 raincloudy==0.0.1 # homeassistant.components.raspihats -# raspihats==2.2.1 +# raspihats==2.2.3 # homeassistant.components.switch.rainmachine regenmaschine==0.4.1 @@ -934,6 +934,7 @@ sleekxmpp==1.3.2 # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.raspihats # homeassistant.components.sensor.bh1750 # homeassistant.components.sensor.bme280 # homeassistant.components.sensor.envirophat From 52671842d509dbc728c8640e1f61b1493459cc9f Mon Sep 17 00:00:00 2001 From: David Byrne Date: Mon, 2 Oct 2017 07:10:01 +0100 Subject: [PATCH 026/113] Fixes broken source links in API docs (#9636) * Fixes broken source links in API docs * Removes illegal blank line --- docs/source/conf.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index bcb2699f57b..8ca22e1a126 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -117,7 +117,11 @@ def linkcode_resolve(domain, info): linespec = "#L%d" % (lineno + 1) else: linespec = "" - fn = relpath(fn, start='../') + index = fn.find("/homeassistant/") + if index == -1: + index = 0 + + fn = fn[index:] return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) From f7609e9cb15795a434de3b622604387e2af840cd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 1 Oct 2017 23:18:10 -0700 Subject: [PATCH 027/113] Move group services into their own YAML (#9597) * Move group services into their own YAML * Fix lint * Move persistent notification to package --- .../{group.py => group/__init__.py} | 8 +- homeassistant/components/group/services.yaml | 59 +++++++++++++ .../__init__.py} | 4 +- .../persistent_notification/services.yaml | 23 +++++ homeassistant/components/services.yaml | 85 ------------------- tests/components/group/__init__.py | 1 + .../{test_group.py => group/test_init.py} | 0 .../persistent_notification/__init__.py | 1 + .../test_init.py} | 0 9 files changed, 90 insertions(+), 91 deletions(-) rename homeassistant/components/{group.py => group/__init__.py} (98%) create mode 100644 homeassistant/components/group/services.yaml rename homeassistant/components/{persistent_notification.py => persistent_notification/__init__.py} (96%) create mode 100644 homeassistant/components/persistent_notification/services.yaml create mode 100644 tests/components/group/__init__.py rename tests/components/{test_group.py => group/test_init.py} (100%) create mode 100644 tests/components/persistent_notification/__init__.py rename tests/components/{test_persistent_notification.py => persistent_notification/test_init.py} (100%) diff --git a/homeassistant/components/group.py b/homeassistant/components/group/__init__.py similarity index 98% rename from homeassistant/components/group.py rename to homeassistant/components/group/__init__.py index fb910109d7c..0bc1fa46c4c 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group/__init__.py @@ -269,7 +269,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, - descriptions[DOMAIN][SERVICE_RELOAD], schema=RELOAD_SERVICE_SCHEMA) + descriptions[SERVICE_RELOAD], schema=RELOAD_SERVICE_SCHEMA) @asyncio.coroutine def groups_service_handler(service): @@ -346,11 +346,11 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET, groups_service_handler, - descriptions[DOMAIN][SERVICE_SET], schema=SET_SERVICE_SCHEMA) + descriptions[SERVICE_SET], schema=SET_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_REMOVE, groups_service_handler, - descriptions[DOMAIN][SERVICE_REMOVE], schema=REMOVE_SERVICE_SCHEMA) + descriptions[SERVICE_REMOVE], schema=REMOVE_SERVICE_SCHEMA) @asyncio.coroutine def visibility_service_handler(service): @@ -368,7 +368,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler, - descriptions[DOMAIN][SERVICE_SET_VISIBILITY], + descriptions[SERVICE_SET_VISIBILITY], schema=SET_VISIBILITY_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml new file mode 100644 index 00000000000..2447392c3b7 --- /dev/null +++ b/homeassistant/components/group/services.yaml @@ -0,0 +1,59 @@ +reload: + description: "Reload group configuration." + +set_visibility: + description: Hide or show a group + + fields: + entity_id: + description: Name(s) of entities to set value + example: 'group.travel' + + visible: + description: True if group should be shown or False if it should be hidden. + example: True + +set: + description: Create/Update a user group + + fields: + object_id: + description: Group id and part of entity id + example: 'test_group' + + name: + description: Name of group + example: 'My test group' + + view: + description: Boolean for if the group is a view + example: True + + icon: + description: Name of icon for the group + example: 'mdi:camera' + + control: + description: Value for control the group control + example: 'hidden' + + visible: + description: If the group is visible on UI + example: True + + entities: + description: List of all members in the group. Not compatible with 'delta' + example: domain.entity_id1, domain.entity_id2 + + add_entities: + description: List of members they will change on group listening. + example: domain.entity_id1, domain.entity_id2 + +remove: + description: Remove a user group + + fields: + object_id: + description: Group id and part of entity id + example: 'test_group' + diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification/__init__.py similarity index 96% rename from homeassistant/components/persistent_notification.py rename to homeassistant/components/persistent_notification/__init__.py index 5e68aeee3ab..0c4674f89cc 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -129,11 +129,11 @@ def async_setup(hass, config): ) hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service, - descriptions[DOMAIN][SERVICE_CREATE], + descriptions[SERVICE_CREATE], SCHEMA_SERVICE_CREATE) hass.services.async_register(DOMAIN, SERVICE_DISMISS, dismiss_service, - descriptions[DOMAIN][SERVICE_DISMISS], + descriptions[SERVICE_DISMISS], SCHEMA_SERVICE_DISMISS) return True diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml new file mode 100644 index 00000000000..2a10f9c8499 --- /dev/null +++ b/homeassistant/components/persistent_notification/services.yaml @@ -0,0 +1,23 @@ +create: + description: Show a notification in the frontend + + fields: + message: + description: Message body of the notification. [Templates accepted] + example: Please check your configuration.yaml. + + title: + description: Optional title for your notification. [Optional, Templates accepted] + example: Test notification + + notification_id: + description: Target ID of the notification, will replace a notification with the same Id. [Optional] + example: 1234 + +dismiss: + description: Remove a notification from the frontend + + fields: + notification_id: + description: Target ID of the notification, which should be removed. [Required] + example: 1234 diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 69a5982caeb..9fd47d84fa0 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -40,91 +40,6 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 -group: - reload: - description: "Reload group configuration." - - set_visibility: - description: Hide or show a group - - fields: - entity_id: - description: Name(s) of entities to set value - example: 'group.travel' - - visible: - description: True if group should be shown or False if it should be hidden. - example: True - - set: - description: Create/Update a user group - - fields: - object_id: - description: Group id and part of entity id - example: 'test_group' - - name: - description: Name of group - example: 'My test group' - - view: - description: Boolean for if the group is a view - example: True - - icon: - description: Name of icon for the group - example: 'mdi:camera' - - control: - description: Value for control the group control - example: 'hidden' - - visible: - description: If the group is visible on UI - example: True - - entities: - description: List of all members in the group. Not compatible with 'delta' - example: domain.entity_id1, domain.entity_id2 - - add_entities: - description: List of members they will change on group listening. - example: domain.entity_id1, domain.entity_id2 - - remove: - description: Remove a user group - - fields: - object_id: - description: Group id and part of entity id - example: 'test_group' - -persistent_notification: - create: - description: Show a notification in the frontend - - fields: - message: - description: Message body of the notification. [Templates accepted] - example: Please check your configuration.yaml. - - title: - description: Optional title for your notification. [Optional, Templates accepted] - example: Test notification - - notification_id: - description: Target ID of the notification, will replace a notification with the same Id. [Optional] - example: 1234 - - dismiss: - description: Remove a notification from the frontend - - fields: - notification_id: - description: Target ID of the notification, which should be removed. [Required] - example: 1234 - homematic: virtualkey: description: Press a virtual key from CCU/Homegear or simulate keypress diff --git a/tests/components/group/__init__.py b/tests/components/group/__init__.py new file mode 100644 index 00000000000..d69449d3c75 --- /dev/null +++ b/tests/components/group/__init__.py @@ -0,0 +1 @@ +"""Tests for the group component.""" diff --git a/tests/components/test_group.py b/tests/components/group/test_init.py similarity index 100% rename from tests/components/test_group.py rename to tests/components/group/test_init.py diff --git a/tests/components/persistent_notification/__init__.py b/tests/components/persistent_notification/__init__.py new file mode 100644 index 00000000000..667002b5ed4 --- /dev/null +++ b/tests/components/persistent_notification/__init__.py @@ -0,0 +1 @@ +"""Test the persistent notification component.""" diff --git a/tests/components/test_persistent_notification.py b/tests/components/persistent_notification/test_init.py similarity index 100% rename from tests/components/test_persistent_notification.py rename to tests/components/persistent_notification/test_init.py From 3337107e79d8bfa623ba49c82e05b581b5bfde8d Mon Sep 17 00:00:00 2001 From: Michel Weimerskirch Date: Mon, 2 Oct 2017 11:29:31 +0200 Subject: [PATCH 028/113] Facebook Messenger notify component: add support for sending messages to specific page user IDs (#9643) --- homeassistant/components/notify/facebook.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index ef85450ca63..db175c6b0a6 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -56,8 +56,15 @@ class FacebookNotificationService(BaseNotificationService): return for target in targets: + # If the target starts with a "+", we suppose it's a phone number, + # otherwise it's a user id. + if target.startswith('+'): + recipient = {"phone_number": target} + else: + recipient = {"id": target} + body = { - "recipient": {"phone_number": target}, + "recipient": recipient, "message": body_message } import json From b4551cc1273d0660eac6f048f247e19e699b86f0 Mon Sep 17 00:00:00 2001 From: Vignesh Venkat Date: Mon, 2 Oct 2017 03:38:55 -0700 Subject: [PATCH 029/113] arlo: Add battery level sensor (#9637) * arlo: Add battery level sensor Adds a battery level sensor that monitors the battery level on Arlo cameras. * Fix lint issue --- homeassistant/components/sensor/arlo.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index dd36dac7eec..5e1f1274160 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -14,7 +14,7 @@ from homeassistant.components.arlo import ( CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity @@ -27,6 +27,7 @@ SENSOR_TYPES = { 'last_capture': ['Last', None, 'run-fast'], 'total_cameras': ['Arlo Cameras', None, 'video'], 'captured_today': ['Captured Today', None, 'file-video'], + 'battery_level': ['Battery Level', '%', 'battery-50'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -109,7 +110,13 @@ class ArloSensor(Entity): video = self._data.videos()[0] self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") except (AttributeError, IndexError): - self._state = STATE_UNKNOWN + self._state = None + + elif self._sensor_type == 'battery_level': + try: + self._state = self._data.get_battery_level + except TypeError: + self._state = None @property def device_state_attributes(self): @@ -120,7 +127,8 @@ class ArloSensor(Entity): attrs['brand'] = DEFAULT_BRAND if self._sensor_type == 'last_capture' or \ - self._sensor_type == 'captured_today': + self._sensor_type == 'captured_today' or \ + self._sensor_type == 'battery_level': attrs['model'] = self._data.model_id return attrs From da4048a9ec8542c4917fc05d8c4d61b848eae9d6 Mon Sep 17 00:00:00 2001 From: Sam Birch Date: Tue, 3 Oct 2017 04:15:19 +1300 Subject: [PATCH 030/113] Add hysteresis attribute to threshold binary sensor (#9596) * Added hysteresis attribute to threshold binary sensor * Added threshold binary sensor hysteresis test case * Changed threshold binary sensor property name to be more self explanatory * Pulled default hysteresis value into top level declaration * Fixed linter errors * Fixed additional linter errors * Move comment to docs --- .../components/binary_sensor/threshold.py | 34 +++++++++---- .../binary_sensor/test_threshold.py | 50 +++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 866e16ecbe2..5ca037767f2 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -20,15 +20,18 @@ from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) +ATTR_HYSTERESIS = 'hysteresis' ATTR_SENSOR_VALUE = 'sensor_value' ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' +DEFAULT_HYSTERESIS = 0.0 SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] @@ -36,6 +39,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_THRESHOLD): vol.Coerce(float), vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), + vol.Optional( + CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, }) @@ -47,28 +52,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) threshold = config.get(CONF_THRESHOLD) + hysteresis = config.get(CONF_HYSTERESIS) limit_type = config.get(CONF_TYPE) device_class = config.get(CONF_DEVICE_CLASS) - async_add_devices( - [ThresholdSensor(hass, entity_id, name, threshold, limit_type, - device_class)], True) + async_add_devices([ThresholdSensor( + hass, entity_id, name, threshold, + hysteresis, limit_type, device_class) + ], True) + return True class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, limit_type, - device_class): + def __init__(self, hass, entity_id, name, threshold, + hysteresis, limit_type, device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id self.is_upper = limit_type == 'upper' self._name = name self._threshold = threshold + self._hysteresis = hysteresis self._device_class = device_class - self._deviation = False + self._state = False self.sensor_value = 0 @callback @@ -97,7 +106,7 @@ class ThresholdSensor(BinarySensorDevice): @property def is_on(self): """Return true if sensor is on.""" - return self._deviation + return self._state @property def should_poll(self): @@ -116,13 +125,16 @@ class ThresholdSensor(BinarySensorDevice): ATTR_ENTITY_ID: self._entity_id, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_THRESHOLD: self._threshold, + ATTR_HYSTERESIS: self._hysteresis, ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self.is_upper: - self._deviation = bool(self.sensor_value > self._threshold) - else: - self._deviation = bool(self.sensor_value < self._threshold) + if self._hysteresis == 0 and self.sensor_value == self._threshold: + self._state = False + elif self.sensor_value > (self._threshold + self._hysteresis): + self._state = self.is_upper + elif self.sensor_value < (self._threshold - self._hysteresis): + self._state = not self.is_upper diff --git a/tests/components/binary_sensor/test_threshold.py b/tests/components/binary_sensor/test_threshold.py index 5bc62654a1f..d8c49de1cc0 100644 --- a/tests/components/binary_sensor/test_threshold.py +++ b/tests/components/binary_sensor/test_threshold.py @@ -96,3 +96,53 @@ class TestThresholdSensor(unittest.TestCase): state = self.hass.states.get('binary_sensor.test_threshold') assert state.state == 'off' + + def test_sensor_hysteresis(self): + """Test if source is above threshold using hysteresis.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'threshold': '15', + 'hysteresis': '2.5', + 'name': 'Test_threshold', + 'type': 'upper', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 20) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_threshold') + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 13) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_threshold') + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 12) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_threshold') + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 17) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_threshold') + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 18) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_threshold') + + assert state.state == 'on' From 5327d2dd1af7e17bbdbe2943ebb02d0c92bdf172 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Oct 2017 17:15:50 +0200 Subject: [PATCH 031/113] Upgrade numpy to 1.13.3 (#9646) --- homeassistant/components/image_processing/opencv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 9cf3749de6b..3264fc5c96c 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -17,7 +17,7 @@ from homeassistant.components.image_processing import ( ImageProcessingEntity) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.13.1'] +REQUIREMENTS = ['numpy==1.13.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 911261115f3..c6081d798a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -449,7 +449,7 @@ netdisco==1.2.0 neurio==0.3.1 # homeassistant.components.image_processing.opencv -numpy==1.13.1 +numpy==1.13.3 # homeassistant.components.google oauth2client==4.0.0 From 13fe5857b393f7b967c4dbec09331a922e6a887e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Oct 2017 17:16:09 +0200 Subject: [PATCH 032/113] Upgrade youtube_dl to 2017.10.01 (#9647) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 188330de1c6..2b9bcc30d4c 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.9.24'] +REQUIREMENTS = ['youtube_dl==2017.10.01'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c6081d798a8..0b7f785cebb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1068,7 +1068,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.9.24 +youtube_dl==2017.10.01 # homeassistant.components.light.zengge zengge==0.2 From 3f19be9717ab5f6e420ad7e43256e924216a7f71 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Oct 2017 17:16:37 +0200 Subject: [PATCH 033/113] Upgrade discord.py to 0.16.12 (#9648) --- homeassistant/components/notify/discord.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 90212bca025..07b13c60d1e 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.11'] +REQUIREMENTS = ['discord.py==0.16.12'] CONF_TOKEN = 'token' diff --git a/requirements_all.txt b/requirements_all.txt index 0b7f785cebb..15681d9c9d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,7 +181,7 @@ denonavr==0.5.3 directpy==0.1 # homeassistant.components.notify.discord -discord.py==0.16.11 +discord.py==0.16.12 # homeassistant.components.updater distro==1.0.4 From 8a90ad9e288edc1d9ec7edc2420143a2a2480efa Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Oct 2017 17:16:50 +0200 Subject: [PATCH 034/113] Upgrade netdisco to 1.2.2 (#9649) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 439b6258bcd..50cc771ffd3 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.2.0'] +REQUIREMENTS = ['netdisco==1.2.2'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 15681d9c9d0..4bf2c13d511 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -443,7 +443,7 @@ myusps==1.2.2 nad_receiver==0.0.6 # homeassistant.components.discovery -netdisco==1.2.0 +netdisco==1.2.2 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From 25e00556d07cf5d533a5d2f7796fbbba7c32bb78 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Oct 2017 17:17:08 +0200 Subject: [PATCH 035/113] Upgrade influxdb to 4.1.1 (#9652) * Upgrade influxdb to 4.1.1 * Upgrade influxdb to 4.1.1 --- homeassistant/components/influxdb.py | 2 +- homeassistant/components/sensor/influxdb.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 36a58fa8165..1c261d5ec3e 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -18,7 +18,7 @@ from homeassistant.helpers import state as state_helper from homeassistant.helpers.entity_values import EntityValues import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['influxdb==3.0.0'] +REQUIREMENTS = ['influxdb==4.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index 7c7ce3ec3da..8adf85f0a2e 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -22,6 +22,8 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['influxdb==4.1.1'] + DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8086 DEFAULT_DATABASE = 'home_assistant' @@ -37,7 +39,6 @@ CONF_FIELD = 'field' CONF_MEASUREMENT_NAME = 'measurement' CONF_WHERE = 'where' -REQUIREMENTS = ['influxdb==3.0.0'] _QUERY_SCHEME = vol.Schema({ vol.Required(CONF_NAME): cv.string, diff --git a/requirements_all.txt b/requirements_all.txt index 4bf2c13d511..da0f1f86a31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -351,7 +351,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb -influxdb==3.0.0 +influxdb==4.1.1 # homeassistant.components.insteon_local insteonlocal==0.52 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a6cbacd6e1..8c079d4555e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -69,7 +69,7 @@ holidays==0.8.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb -influxdb==3.0.0 +influxdb==4.1.1 # homeassistant.components.dyson libpurecoollink==0.4.2 From 3bd31b91fb9d7bcf39dc1ad1f4478c98f2bb5eb6 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 2 Oct 2017 17:17:22 +0200 Subject: [PATCH 036/113] Upgrade googlemaps to 2.5.1 (#9653) --- homeassistant/components/sensor/google_travel_time.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 07c46b1a3d2..fe0db29eb92 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv import homeassistant.helpers.location as location import homeassistant.util.dt as dt_util -REQUIREMENTS = ['googlemaps==2.4.6'] +REQUIREMENTS = ['googlemaps==2.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index da0f1f86a31..4c6a1d91558 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ gntp==1.0.3 google-api-python-client==1.6.2 # homeassistant.components.sensor.google_travel_time -googlemaps==2.4.6 +googlemaps==2.5.1 # homeassistant.components.sensor.gpsd gps3==0.33.3 From 755a2a8291442e36b12c047716978e1fd9ad74a9 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 2 Oct 2017 09:41:07 -0600 Subject: [PATCH 037/113] mqtt_statestream: Add options to publish attributes/timestamps (#9645) --- homeassistant/components/mqtt_statestream.py | 33 ++++++- tests/components/test_mqtt_statestream.py | 91 ++++++++++++++++++-- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index 2b68394b160..8469cb3b334 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -12,14 +12,19 @@ from homeassistant.const import MATCH_ALL from homeassistant.core import callback from homeassistant.components.mqtt import valid_publish_topic from homeassistant.helpers.event import async_track_state_change +import homeassistant.helpers.config_validation as cv CONF_BASE_TOPIC = 'base_topic' +CONF_PUBLISH_ATTRIBUTES = 'publish_attributes' +CONF_PUBLISH_TIMESTAMPS = 'publish_timestamps' DEPENDENCIES = ['mqtt'] DOMAIN = 'mqtt_statestream' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_BASE_TOPIC): valid_publish_topic + vol.Required(CONF_BASE_TOPIC): valid_publish_topic, + vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean, + vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean }) }, extra=vol.ALLOW_EXTRA) @@ -29,6 +34,8 @@ def async_setup(hass, config): """Set up the MQTT state feed.""" conf = config.get(DOMAIN, {}) base_topic = conf.get(CONF_BASE_TOPIC) + publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) + publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) if not base_topic.endswith('/'): base_topic = base_topic + '/' @@ -38,8 +45,28 @@ def async_setup(hass, config): return payload = new_state.state - topic = base_topic + entity_id.replace('.', '/') + '/state' - hass.components.mqtt.async_publish(topic, payload, 1, True) + mybase = base_topic + entity_id.replace('.', '/') + '/' + hass.components.mqtt.async_publish(mybase + 'state', payload, 1, True) + + if publish_timestamps: + if new_state.last_updated: + hass.components.mqtt.async_publish( + mybase + 'last_updated', + new_state.last_updated.isoformat(), + 1, + True) + if new_state.last_changed: + hass.components.mqtt.async_publish( + mybase + 'last_changed', + new_state.last_changed.isoformat(), + 1, + True) + + if publish_attributes: + for key, val in new_state.attributes.items(): + if val: + hass.components.mqtt.async_publish(mybase + key, + val, 1, True) async_track_state_change(hass, MATCH_ALL, _state_publisher) return True diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index cbd7838effe..802d62bfdd1 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -1,5 +1,5 @@ """The tests for the MQTT statestream component.""" -from unittest.mock import patch +from unittest.mock import ANY, call, patch from homeassistant.setup import setup_component import homeassistant.components.mqtt_statestream as statestream @@ -24,11 +24,17 @@ class TestMqttStateStream(object): """Stop everything that was started.""" self.hass.stop() - def add_statestream(self, base_topic=None): + def add_statestream(self, base_topic=None, publish_attributes=None, + publish_timestamps=None): """Add a mqtt_statestream component.""" config = {} if base_topic: config['base_topic'] = base_topic + if publish_attributes: + config['publish_attributes'] = publish_attributes + if publish_timestamps: + config['publish_timestamps'] = publish_timestamps + print("Publishing timestamps") return setup_component(self.hass, statestream.DOMAIN, { statestream.DOMAIN: config}) @@ -36,10 +42,14 @@ class TestMqttStateStream(object): """Setup should fail if no base_topic is set.""" assert self.add_statestream() is False - def test_setup_succeeds(self): + def test_setup_succeeds_without_attributes(self): """"Test the success of the setup with a valid base_topic.""" assert self.add_statestream(base_topic='pub') + def test_setup_succeeds_with_attributes(self): + """"Test setup with a valid base_topic and publish_attributes.""" + assert self.add_statestream(base_topic='pub', publish_attributes=True) + @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): @@ -60,6 +70,77 @@ class TestMqttStateStream(object): self.hass.block_till_done() # Make sure 'on' was published to pub/fake/entity/state - mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', - 'on', 1, True) + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_sends_message_and_timestamp( + self, + mock_utcnow, + mock_pub): + """"Test the sending of a message and timestamps if event changed.""" + e_id = 'another.entity' + base_topic = 'pub' + + # Add the statestream component for publishing state updates + assert self.add_statestream(base_topic=base_topic, + publish_attributes=None, + publish_timestamps=True) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + calls = [ + call.async_publish(self.hass, 'pub/another/entity/state', 'on', 1, + True), + call.async_publish(self.hass, 'pub/another/entity/last_changed', + ANY, 1, True), + call.async_publish(self.hass, 'pub/another/entity/last_updated', + ANY, 1, True), + ] + + mock_pub.assert_has_calls(calls, any_order=True) + assert mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_attr_sends_message(self, mock_utcnow, mock_pub): + """"Test the sending of a new message if attribute changed.""" + e_id = 'fake.entity' + base_topic = 'pub' + + # Add the statestream component for publishing state updates + assert self.add_statestream(base_topic=base_topic, + publish_attributes=True) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + test_attributes = {"testing": "YES"} + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'off', + attributes=test_attributes)) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + calls = [ + call.async_publish(self.hass, 'pub/fake/entity/state', 'off', 1, + True), + call.async_publish(self.hass, 'pub/fake/entity/testing', 'YES', + 1, True) + ] + + mock_pub.assert_has_calls(calls, any_order=True) assert mock_pub.called From 48037211207ebb63fd0a5e99d545628391c4ccf6 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Mon, 2 Oct 2017 22:41:46 +0200 Subject: [PATCH 038/113] Fixed bugs related to exception handling in pythonegardia. Updating package requirement accordingly (#9663) --- homeassistant/components/alarm_control_panel/egardia.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index fbafe061334..4acf253e3a7 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.20'] +REQUIREMENTS = ['pythonegardia==1.0.21'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4c6a1d91558..fe782df4bb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -820,7 +820,7 @@ python_opendata_transport==0.0.2 python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.20 +pythonegardia==1.0.21 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 670bd0ce48a9097a8335b4eaecf6d6c57d4d9094 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 2 Oct 2017 16:42:23 -0400 Subject: [PATCH 039/113] Update google-api-python-client to 1.6.4 (#9658) --- homeassistant/components/google.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index e99c4095f22..78b6675ab79 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -24,7 +24,7 @@ from homeassistant.helpers.event import track_time_change from homeassistant.util import convert, dt REQUIREMENTS = [ - 'google-api-python-client==1.6.2', + 'google-api-python-client==1.6.4', 'oauth2client==4.0.0', ] diff --git a/requirements_all.txt b/requirements_all.txt index fe782df4bb0..ca4ba5b6d14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ gitterpy==0.1.5 gntp==1.0.3 # homeassistant.components.google -google-api-python-client==1.6.2 +google-api-python-client==1.6.4 # homeassistant.components.sensor.google_travel_time googlemaps==2.5.1 From 0aa22d9d91bb59a15b37c41019b5bd167d16f529 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Mon, 2 Oct 2017 13:55:26 -0700 Subject: [PATCH 040/113] Bump abode to 0.11.9 (#9660) --- homeassistant/components/abode.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index fe35d7b1b8b..d1c1a2b84c2 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -21,7 +21,7 @@ from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.11.8'] +REQUIREMENTS = ['abodepy==0.11.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ca4ba5b6d14..769e7608bd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.11.8 +abodepy==0.11.9 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.3 From c4810da82f74f3fdeaa150000f81aa3dc735e62e Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Mon, 2 Oct 2017 23:25:04 -0400 Subject: [PATCH 041/113] Unit tests to improve core coverage (#9659) * Code coverage of logging util * Improve async util coverage * Add test coverage for restore_state * get_random_string test --- homeassistant/util/logging.py | 2 +- tests/helpers/test_restore_state.py | 79 +++++++++++++++++++++ tests/util/test_async.py | 102 +++++++++++++++++++++++----- tests/util/test_init.py | 16 ++++- tests/util/test_logging.py | 68 +++++++++++++++++++ 5 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 tests/util/test_logging.py diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 16d5c750172..7daaf937975 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -116,6 +116,6 @@ class AsyncHandler(object): return self.handler.get_name() @name.setter - def set_name(self, name): + def name(self, name): """Wrap property get_name to handler.""" self.handler.name = name diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 5027e36a7f2..15dda24a529 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -51,6 +51,85 @@ def test_caching_data(hass): assert DATA_RESTORE_CACHE not in hass.data +@asyncio.coroutine +def test_hass_running(hass): + """Test that cache cannot be accessed while hass is running.""" + mock_component(hass, 'recorder') + + states = [ + State('input_boolean.b0', 'on'), + State('input_boolean.b1', 'on'), + State('input_boolean.b2', 'on'), + ] + + with patch('homeassistant.helpers.restore_state.last_recorder_run', + return_value=MagicMock(end=dt_util.utcnow())), \ + patch('homeassistant.helpers.restore_state.get_states', + return_value=states), \ + patch('homeassistant.helpers.restore_state.wait_connection_ready', + return_value=mock_coro(True)): + state = yield from async_get_last_state(hass, 'input_boolean.b1') + assert state is None + + +@asyncio.coroutine +def test_not_connected(hass): + """Test that cache cannot be accessed if db connection times out.""" + mock_component(hass, 'recorder') + hass.state = CoreState.starting + + states = [State('input_boolean.b1', 'on')] + + with patch('homeassistant.helpers.restore_state.last_recorder_run', + return_value=MagicMock(end=dt_util.utcnow())), \ + patch('homeassistant.helpers.restore_state.get_states', + return_value=states), \ + patch('homeassistant.helpers.restore_state.wait_connection_ready', + return_value=mock_coro(False)): + state = yield from async_get_last_state(hass, 'input_boolean.b1') + assert state is None + + +@asyncio.coroutine +def test_no_last_run_found(hass): + """Test that cache cannot be accessed if no last run found.""" + mock_component(hass, 'recorder') + hass.state = CoreState.starting + + states = [State('input_boolean.b1', 'on')] + + with patch('homeassistant.helpers.restore_state.last_recorder_run', + return_value=None), \ + patch('homeassistant.helpers.restore_state.get_states', + return_value=states), \ + patch('homeassistant.helpers.restore_state.wait_connection_ready', + return_value=mock_coro(True)): + state = yield from async_get_last_state(hass, 'input_boolean.b1') + assert state is None + + +@asyncio.coroutine +def test_cache_timeout(hass): + """Test that cache timeout returns none.""" + mock_component(hass, 'recorder') + hass.state = CoreState.starting + + states = [State('input_boolean.b1', 'on')] + + @asyncio.coroutine + def timeout_coro(): + raise asyncio.TimeoutError() + + with patch('homeassistant.helpers.restore_state.last_recorder_run', + return_value=MagicMock(end=dt_util.utcnow())), \ + patch('homeassistant.helpers.restore_state.get_states', + return_value=states), \ + patch('homeassistant.helpers.restore_state.wait_connection_ready', + return_value=timeout_coro()): + state = yield from async_get_last_state(hass, 'input_boolean.b1') + assert state is None + + def _add_data_in_last_run(hass, entities): """Add test data in the last recorder_run.""" # pylint: disable=protected-access diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 1d6e669e1d6..b7a18d00fae 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -8,52 +8,74 @@ import pytest from homeassistant.util import async as hasync -@patch('asyncio.coroutines.iscoroutine', return_value=True) +@patch('asyncio.coroutines.iscoroutine') @patch('concurrent.futures.Future') @patch('threading.get_ident') -def test_run_coroutine_threadsafe_from_inside_event_loop(mock_ident, _, __): +def test_run_coroutine_threadsafe_from_inside_event_loop( + mock_ident, _, mock_iscoroutine): """Testing calling run_coroutine_threadsafe from inside an event loop.""" coro = MagicMock() loop = MagicMock() loop._thread_ident = None mock_ident.return_value = 5 + mock_iscoroutine.return_value = True hasync.run_coroutine_threadsafe(coro, loop) assert len(loop.call_soon_threadsafe.mock_calls) == 1 loop._thread_ident = 5 mock_ident.return_value = 5 + mock_iscoroutine.return_value = True with pytest.raises(RuntimeError): hasync.run_coroutine_threadsafe(coro, loop) assert len(loop.call_soon_threadsafe.mock_calls) == 1 loop._thread_ident = 1 mock_ident.return_value = 5 + mock_iscoroutine.return_value = False + with pytest.raises(TypeError): + hasync.run_coroutine_threadsafe(coro, loop) + assert len(loop.call_soon_threadsafe.mock_calls) == 1 + + loop._thread_ident = 1 + mock_ident.return_value = 5 + mock_iscoroutine.return_value = True hasync.run_coroutine_threadsafe(coro, loop) assert len(loop.call_soon_threadsafe.mock_calls) == 2 -@patch('asyncio.coroutines.iscoroutine', return_value=True) +@patch('asyncio.coroutines.iscoroutine') @patch('concurrent.futures.Future') @patch('threading.get_ident') -def test_fire_coroutine_threadsafe_from_inside_event_loop(mock_ident, _, __): +def test_fire_coroutine_threadsafe_from_inside_event_loop( + mock_ident, _, mock_iscoroutine): """Testing calling fire_coroutine_threadsafe from inside an event loop.""" coro = MagicMock() loop = MagicMock() loop._thread_ident = None mock_ident.return_value = 5 + mock_iscoroutine.return_value = True hasync.fire_coroutine_threadsafe(coro, loop) assert len(loop.call_soon_threadsafe.mock_calls) == 1 loop._thread_ident = 5 mock_ident.return_value = 5 + mock_iscoroutine.return_value = True with pytest.raises(RuntimeError): hasync.fire_coroutine_threadsafe(coro, loop) assert len(loop.call_soon_threadsafe.mock_calls) == 1 loop._thread_ident = 1 mock_ident.return_value = 5 + mock_iscoroutine.return_value = False + with pytest.raises(TypeError): + hasync.fire_coroutine_threadsafe(coro, loop) + assert len(loop.call_soon_threadsafe.mock_calls) == 1 + + loop._thread_ident = 1 + mock_ident.return_value = 5 + mock_iscoroutine.return_value = True hasync.fire_coroutine_threadsafe(coro, loop) assert len(loop.call_soon_threadsafe.mock_calls) == 2 @@ -82,7 +104,7 @@ def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _): assert len(loop.call_soon_threadsafe.mock_calls) == 2 -class RunCoroutineThreadsafeTests(test_utils.TestCase): +class RunThreadsafeTests(test_utils.TestCase): """Test case for asyncio.run_coroutine_threadsafe.""" def setUp(self): @@ -91,26 +113,41 @@ class RunCoroutineThreadsafeTests(test_utils.TestCase): self.loop = asyncio.new_event_loop() self.set_event_loop(self.loop) # Will cleanup properly - @asyncio.coroutine - def add(self, a, b, fail=False, cancel=False): - """Wait 0.05 second and return a + b.""" - yield from asyncio.sleep(0.05, loop=self.loop) + def add_callback(self, a, b, fail, invalid): + """Return a + b.""" if fail: raise RuntimeError("Fail!") + if invalid: + raise ValueError("Invalid!") + return a + b + + @asyncio.coroutine + def add_coroutine(self, a, b, fail, invalid, cancel): + """Wait 0.05 second and return a + b.""" + yield from asyncio.sleep(0.05, loop=self.loop) if cancel: asyncio.tasks.Task.current_task(self.loop).cancel() yield - return a + b + return self.add_callback(a, b, fail, invalid) - def target(self, fail=False, cancel=False, timeout=None, - advance_coro=False): + def target_callback(self, fail=False, invalid=False): + """Run add callback in the event loop.""" + future = hasync.run_callback_threadsafe( + self.loop, self.add_callback, 1, 2, fail, invalid) + try: + return future.result() + finally: + future.done() or future.cancel() + + def target_coroutine(self, fail=False, invalid=False, cancel=False, + timeout=None, advance_coro=False): """Run add coroutine in the event loop.""" - coro = self.add(1, 2, fail=fail, cancel=cancel) + coro = self.add_coroutine(1, 2, fail, invalid, cancel) future = hasync.run_coroutine_threadsafe(coro, self.loop) if advance_coro: # this is for test_run_coroutine_threadsafe_task_factory_exception; # otherwise it spills errors and breaks **other** unittests, since - # 'target' is interacting with threads. + # 'target_coroutine' is interacting with threads. # With this call, `coro` will be advanced, so that # CoroWrapper.__del__ won't do anything when asyncio tests run @@ -123,20 +160,28 @@ class RunCoroutineThreadsafeTests(test_utils.TestCase): def test_run_coroutine_threadsafe(self): """Test coroutine submission from a thread to an event loop.""" - future = self.loop.run_in_executor(None, self.target) + future = self.loop.run_in_executor(None, self.target_coroutine) result = self.loop.run_until_complete(future) self.assertEqual(result, 3) def test_run_coroutine_threadsafe_with_exception(self): """Test coroutine submission from thread to event loop on exception.""" - future = self.loop.run_in_executor(None, self.target, True) + future = self.loop.run_in_executor(None, self.target_coroutine, True) with self.assertRaises(RuntimeError) as exc_context: self.loop.run_until_complete(future) self.assertIn("Fail!", exc_context.exception.args) + def test_run_coroutine_threadsafe_with_invalid(self): + """Test coroutine submission from thread to event loop on invalid.""" + callback = lambda: self.target_coroutine(invalid=True) # noqa + future = self.loop.run_in_executor(None, callback) + with self.assertRaises(ValueError) as exc_context: + self.loop.run_until_complete(future) + self.assertIn("Invalid!", exc_context.exception.args) + def test_run_coroutine_threadsafe_with_timeout(self): """Test coroutine submission from thread to event loop on timeout.""" - callback = lambda: self.target(timeout=0) # noqa + callback = lambda: self.target_coroutine(timeout=0) # noqa future = self.loop.run_in_executor(None, callback) with self.assertRaises(asyncio.TimeoutError): self.loop.run_until_complete(future) @@ -147,7 +192,28 @@ class RunCoroutineThreadsafeTests(test_utils.TestCase): def test_run_coroutine_threadsafe_task_cancelled(self): """Test coroutine submission from tread to event loop on cancel.""" - callback = lambda: self.target(cancel=True) # noqa + callback = lambda: self.target_coroutine(cancel=True) # noqa future = self.loop.run_in_executor(None, callback) with self.assertRaises(asyncio.CancelledError): self.loop.run_until_complete(future) + + def test_run_callback_threadsafe(self): + """Test callback submission from a thread to an event loop.""" + future = self.loop.run_in_executor(None, self.target_callback) + result = self.loop.run_until_complete(future) + self.assertEqual(result, 3) + + def test_run_callback_threadsafe_with_exception(self): + """Test callback submission from thread to event loop on exception.""" + future = self.loop.run_in_executor(None, self.target_callback, True) + with self.assertRaises(RuntimeError) as exc_context: + self.loop.run_until_complete(future) + self.assertIn("Fail!", exc_context.exception.args) + + def test_run_callback_threadsafe_with_invalid(self): + """Test callback submission from thread to event loop on invalid.""" + callback = lambda: self.target_callback(invalid=True) # noqa + future = self.loop.run_in_executor(None, callback) + with self.assertRaises(ValueError) as exc_context: + self.loop.run_until_complete(future) + self.assertIn("Invalid!", exc_context.exception.args) diff --git a/tests/util/test_init.py b/tests/util/test_init.py index ba8415d597f..2902cb62517 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -1,6 +1,6 @@ """Test Home Assistant util methods.""" import unittest -from unittest.mock import patch +from unittest.mock import patch, MagicMock from datetime import datetime, timedelta from homeassistant import util @@ -266,3 +266,17 @@ class TestUtil(unittest.TestCase): self.assertTrue(tester.hello()) self.assertTrue(tester.goodbye()) + + @patch.object(util, 'random') + def test_get_random_string(self, mock_random): + """Test get random string.""" + results = ['A', 'B', 'C'] + + def mock_choice(choices): + return results.pop(0) + + generator = MagicMock() + generator.choice.side_effect = mock_choice + mock_random.SystemRandom.return_value = generator + + assert util.get_random_string(length=3) == 'ABC' diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py new file mode 100644 index 00000000000..94c8568dc47 --- /dev/null +++ b/tests/util/test_logging.py @@ -0,0 +1,68 @@ +"""Test Home Assistant logging util methods.""" +import asyncio +import logging +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') + + clean_record = logging.makeLogRecord({'msg': "clean log data"}) + log_filter.filter(clean_record) + assert clean_record.msg == "clean log data" + + sensitive_record = logging.makeLogRecord({'msg': "mock_sensitive log"}) + log_filter.filter(sensitive_record) + assert sensitive_record.msg == "******* log" + + +@asyncio.coroutine +def test_async_handler_loop_log(loop): + """Test the logging sensitive data filter.""" + loop._thread_ident = threading.get_ident() + + queue = asyncio.Queue(loop=loop) + base_handler = logging.handlers.QueueHandler(queue) + handler = logging_util.AsyncHandler(loop, base_handler) + + # Test passthrough props and noop functions + assert handler.createLock() is None + assert handler.acquire() is None + assert handler.release() is None + assert handler.formatter is base_handler.formatter + assert handler.name is base_handler.get_name() + handler.name = 'mock_name' + assert base_handler.get_name() == 'mock_name' + + log_record = logging.makeLogRecord({'msg': "Test Log Record"}) + handler.emit(log_record) + yield from handler.async_close(True) + assert queue.get_nowait() == log_record + assert queue.empty() + + +@asyncio.coroutine +def test_async_handler_thread_log(loop): + """Test the logging sensitive data filter.""" + loop._thread_ident = threading.get_ident() + + queue = asyncio.Queue(loop=loop) + base_handler = logging.handlers.QueueHandler(queue) + handler = logging_util.AsyncHandler(loop, base_handler) + + log_record = logging.makeLogRecord({'msg': "Test Log Record"}) + + def add_log(): + """Emit a mock log.""" + handler.emit(log_record) + handler.close() + + yield from loop.run_in_executor(None, add_log) + yield from handler.async_close(True) + + assert queue.get_nowait() == log_record + assert queue.empty() From 12b2cfa9b5141a2198cbc8b519e06727cc9d363a Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Tue, 3 Oct 2017 00:17:36 -0600 Subject: [PATCH 042/113] Upgrade pyitachip2ir to 0.0.7 (#9669) --- homeassistant/components/remote/itach.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote/itach.py b/homeassistant/components/remote/itach.py index eefa1ed79af..8b91e5356b4 100644 --- a/homeassistant/components/remote/itach.py +++ b/homeassistant/components/remote/itach.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_DEVICES) from homeassistant.components.remote import PLATFORM_SCHEMA -REQUIREMENTS = ['pyitachip2ir==0.0.6'] +REQUIREMENTS = ['pyitachip2ir==0.0.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 769e7608bd9..4956ea4a194 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -638,7 +638,7 @@ pyicloud==0.9.1 pyiss==1.0.1 # homeassistant.components.remote.itach -pyitachip2ir==0.0.6 +pyitachip2ir==0.0.7 # homeassistant.components.kira pykira==0.1.1 From 29e973d060c18ad119bbc4404d4668e6af54f302 Mon Sep 17 00:00:00 2001 From: FletcherAU Date: Tue, 3 Oct 2017 21:24:59 +0800 Subject: [PATCH 043/113] Fix typo in cancel_command description (#9671) "wasn't going to use it" --- homeassistant/components/zwave/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 92b5fa25d20..911a583afc0 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -22,7 +22,7 @@ add_node_secure: description: Add a new node to the Z-Wave network with secure communications. Secure network key must be set, this process will fallback to add_node (unsecure) for unsupported devices. Note that unsecure devices can't directly talk to secure devices. Refer to OZW.log for progress. cancel_command: - description: Cancel a running Z-Wave controller command. Use this to exit add_node, if you wasn't going to use it but activated it. + description: Cancel a running Z-Wave controller command. Use this to exit add_node, if you weren't going to use it but activated it. heal_network: description: Start a Z-Wave network heal. This might take a while and will slow down the Z-Wave network greatly while it is being processed. Refer to OZW.log for progress. From 3c0d02f057277a37dd9529bd613ccf244ff024ff Mon Sep 17 00:00:00 2001 From: BioSehnsucht Date: Tue, 3 Oct 2017 14:34:13 -0500 Subject: [PATCH 044/113] Rename input_slider to input_number and add numeric text box option (#9494) * * Rename input_slider to input_number * Update input_number to optionally display slider, input box, or both * input_number support either input box or slider mode, but not both * input_number : change service from select_value to set_value * input_number : add test for mode setting to tests --- homeassistant/components/demo.py | 6 +- .../{input_slider.py => input_number.py} | 63 ++++++++++------- tests/components/cover/test_template.py | 12 ++-- .../components/media_player/test_universal.py | 4 +- ...t_input_slider.py => test_input_number.py} | 70 ++++++++++++++----- tests/helpers/test_template.py | 4 +- 6 files changed, 101 insertions(+), 58 deletions(-) rename homeassistant/components/{input_slider.py => input_number.py} (77%) rename tests/components/{test_input_slider.py => test_input_number.py} (62%) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 2f1dde05bab..b85c2d9a53b 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -87,8 +87,8 @@ def async_setup(hass, config): # Set up input boolean tasks.append(bootstrap.async_setup_component( - hass, 'input_slider', - {'input_slider': { + hass, 'input_number', + {'input_number': { 'noise_allowance': {'icon': 'mdi:bell-ring', 'min': 0, 'max': 10, @@ -163,7 +163,7 @@ def async_setup(hass, config): 'scene.romantic_lights'])) tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ lights[0], switches[1], media_players[0], - 'input_slider.noise_allowance'])) + 'input_number.noise_allowance'])) tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [ lights[2], 'cover.kitchen_window', 'lock.kitchen_door'])) tasks2.append(group.Group.async_create_group(hass, 'Doors', [ diff --git a/homeassistant/components/input_slider.py b/homeassistant/components/input_number.py similarity index 77% rename from homeassistant/components/input_slider.py rename to homeassistant/components/input_number.py index 5357878a0ce..598fb573904 100644 --- a/homeassistant/components/input_slider.py +++ b/homeassistant/components/input_number.py @@ -1,8 +1,8 @@ """ -Component to offer a way to select a value from a slider. +Component to offer a way to set a numeric value from a slider or text box. For more details about this component, please refer to the documentation -at https://home-assistant.io/components/input_slider/ +at https://home-assistant.io/components/input_number/ """ import asyncio import logging @@ -19,29 +19,34 @@ from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) -DOMAIN = 'input_slider' +DOMAIN = 'input_number' ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_INITIAL = 'initial' CONF_MIN = 'min' CONF_MAX = 'max' +CONF_MODE = 'mode' CONF_STEP = 'step' +MODE_SLIDER = 'slider' +MODE_BOX = 'box' + ATTR_VALUE = 'value' ATTR_MIN = 'min' ATTR_MAX = 'max' ATTR_STEP = 'step' +ATTR_MODE = 'mode' -SERVICE_SELECT_VALUE = 'select_value' +SERVICE_SET_VALUE = 'set_value' -SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_VALUE): vol.Coerce(float), }) -def _cv_input_slider(cfg): - """Configure validation helper for input slider (voluptuous).""" +def _cv_input_number(cfg): + """Configure validation helper for input number (voluptuous).""" minimum = cfg.get(CONF_MIN) maximum = cfg.get(CONF_MAX) if minimum >= maximum: @@ -64,16 +69,18 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float), vol.Range(min=1e-3)), vol.Optional(CONF_ICON): cv.icon, - vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string - }, _cv_input_slider) + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_MODE, default=MODE_SLIDER): + vol.In([MODE_BOX, MODE_SLIDER]), + }, _cv_input_number) }) }, required=True, extra=vol.ALLOW_EXTRA) @bind_hass -def select_value(hass, entity_id, value): - """Set input_slider to value.""" - hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { +def set_value(hass, entity_id, value): + """Set input_number to value.""" + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, }) @@ -94,37 +101,39 @@ def async_setup(hass, config): step = cfg.get(CONF_STEP) icon = cfg.get(CONF_ICON) unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + mode = cfg.get(CONF_MODE) - entities.append(InputSlider( - object_id, name, initial, minimum, maximum, step, icon, unit)) + entities.append(InputNumber( + object_id, name, initial, minimum, maximum, step, icon, unit, + mode)) if not entities: return False @asyncio.coroutine - def async_select_value_service(call): + def async_set_value_service(call): """Handle a calls to the input slider services.""" target_inputs = component.async_extract_from_service(call) - tasks = [input_slider.async_select_value(call.data[ATTR_VALUE]) - for input_slider in target_inputs] + tasks = [input_number.async_set_value(call.data[ATTR_VALUE]) + for input_number in target_inputs] if tasks: yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, - schema=SERVICE_SELECT_VALUE_SCHEMA) + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) yield from component.async_add_entities(entities) return True -class InputSlider(Entity): +class InputNumber(Entity): """Represent an slider.""" def __init__(self, object_id, name, initial, minimum, maximum, step, icon, - unit): - """Initialize a select input.""" + unit, mode): + """Initialize an input number.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name self._current_value = initial @@ -133,6 +142,7 @@ class InputSlider(Entity): self._step = step self._icon = icon self._unit = unit + self._mode = mode @property def should_poll(self): @@ -141,7 +151,7 @@ class InputSlider(Entity): @property def name(self): - """Return the name of the select input slider.""" + """Return the name of the input slider.""" return self._name @property @@ -165,7 +175,8 @@ class InputSlider(Entity): return { ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, - ATTR_STEP: self._step + ATTR_STEP: self._step, + ATTR_MODE: self._mode, } @asyncio.coroutine @@ -184,8 +195,8 @@ class InputSlider(Entity): self._current_value = self._minimum @asyncio.coroutine - def async_select_value(self, value): - """Select new value.""" + def async_set_value(self, value): + """Set new value.""" num_value = float(value) if num_value < self._minimum or num_value > self._maximum: _LOGGER.warning("Invalid value: %s (range %s - %s)", diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index 3c574bbf497..495508203b3 100644 --- a/tests/components/cover/test_template.py +++ b/tests/components/cover/test_template.py @@ -409,8 +409,8 @@ class TestTemplateCover(unittest.TestCase): def test_set_position(self): """Test the set_position command.""" with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'input_slider', { - 'input_slider': { + assert setup.setup_component(self.hass, 'input_number', { + 'input_number': { 'test': { 'min': '0', 'max': '100', @@ -424,10 +424,10 @@ class TestTemplateCover(unittest.TestCase): 'covers': { 'test_template_cover': { 'position_template': - "{{ states.input_slider.test.state | int }}", + "{{ states.input_number.test.state | int }}", 'set_cover_position': { - 'service': 'input_slider.select_value', - 'entity_id': 'input_slider.test', + 'service': 'input_number.set_value', + 'entity_id': 'input_number.test', 'data_template': { 'value': '{{ position }}' }, @@ -440,7 +440,7 @@ class TestTemplateCover(unittest.TestCase): self.hass.start() self.hass.block_till_done() - state = self.hass.states.set('input_slider.test', 42) + state = self.hass.states.set('input_number.test', 42) self.hass.block_till_done() state = self.hass.states.get('cover.test_template_cover') assert state.state == STATE_OPEN diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index d2cc874a541..01281d189b4 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -5,7 +5,7 @@ import unittest from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, STATE_PLAYING, STATE_PAUSED) import homeassistant.components.switch as switch -import homeassistant.components.input_slider as input_slider +import homeassistant.components.input_number as input_number import homeassistant.components.input_select as input_select import homeassistant.components.media_player as media_player import homeassistant.components.media_player.universal as universal @@ -166,7 +166,7 @@ class TestMediaPlayer(unittest.TestCase): self.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format('state') self.hass.states.set(self.mock_state_switch_id, STATE_OFF) - self.mock_volume_id = input_slider.ENTITY_ID_FORMAT.format( + self.mock_volume_id = input_number.ENTITY_ID_FORMAT.format( 'volume_level') self.hass.states.set(self.mock_volume_id, 0) diff --git a/tests/components/test_input_slider.py b/tests/components/test_input_number.py similarity index 62% rename from tests/components/test_input_slider.py rename to tests/components/test_input_number.py index f550091e31f..7d11325dabb 100644 --- a/tests/components/test_input_slider.py +++ b/tests/components/test_input_number.py @@ -1,17 +1,17 @@ -"""The tests for the Input slider component.""" +"""The tests for the Input number component.""" # pylint: disable=protected-access import asyncio import unittest from homeassistant.core import CoreState, State from homeassistant.setup import setup_component, async_setup_component -from homeassistant.components.input_slider import (DOMAIN, select_value) +from homeassistant.components.input_number import (DOMAIN, set_value) from tests.common import get_test_home_assistant, mock_restore_cache -class TestInputSlider(unittest.TestCase): - """Test the input slider component.""" +class TestInputNumber(unittest.TestCase): + """Test the input number component.""" # pylint: disable=invalid-name def setUp(self): @@ -38,8 +38,8 @@ class TestInputSlider(unittest.TestCase): self.assertFalse( setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) - def test_select_value(self): - """Test select_value method.""" + def test_set_value(self): + """Test set_value method.""" self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { 'test_1': { 'initial': 50, @@ -47,36 +47,68 @@ class TestInputSlider(unittest.TestCase): 'max': 100, }, }})) - entity_id = 'input_slider.test_1' + entity_id = 'input_number.test_1' state = self.hass.states.get(entity_id) self.assertEqual(50, float(state.state)) - select_value(self.hass, entity_id, '30.4') + set_value(self.hass, entity_id, '30.4') self.hass.block_till_done() state = self.hass.states.get(entity_id) self.assertEqual(30.4, float(state.state)) - select_value(self.hass, entity_id, '70') + set_value(self.hass, entity_id, '70') self.hass.block_till_done() state = self.hass.states.get(entity_id) self.assertEqual(70, float(state.state)) - select_value(self.hass, entity_id, '110') + set_value(self.hass, entity_id, '110') self.hass.block_till_done() state = self.hass.states.get(entity_id) self.assertEqual(70, float(state.state)) + def test_mode(self): + """Test mode settings.""" + self.assertTrue( + setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_default_slider': { + 'min': 0, + 'max': 100, + }, + 'test_explicit_box': { + 'min': 0, + 'max': 100, + 'mode': 'box', + }, + 'test_explicit_slider': { + 'min': 0, + 'max': 100, + 'mode': 'slider', + }, + }})) + + state = self.hass.states.get('input_number.test_default_slider') + assert state + self.assertEqual('slider', state.attributes['mode']) + + state = self.hass.states.get('input_number.test_explicit_box') + assert state + self.assertEqual('box', state.attributes['mode']) + + state = self.hass.states.get('input_number.test_explicit_slider') + assert state + self.assertEqual('slider', state.attributes['mode']) + @asyncio.coroutine def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( - State('input_slider.b1', '70'), - State('input_slider.b2', '200'), + State('input_number.b1', '70'), + State('input_number.b2', '200'), )) hass.state = CoreState.starting @@ -93,11 +125,11 @@ def test_restore_state(hass): }, }}) - state = hass.states.get('input_slider.b1') + state = hass.states.get('input_number.b1') assert state assert float(state.state) == 70 - state = hass.states.get('input_slider.b2') + state = hass.states.get('input_number.b2') assert state assert float(state.state) == 10 @@ -106,8 +138,8 @@ def test_restore_state(hass): def test_initial_state_overrules_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( - State('input_slider.b1', '70'), - State('input_slider.b2', '200'), + State('input_number.b1', '70'), + State('input_number.b2', '200'), )) hass.state = CoreState.starting @@ -126,11 +158,11 @@ def test_initial_state_overrules_restore_state(hass): }, }}) - state = hass.states.get('input_slider.b1') + state = hass.states.get('input_number.b1') assert state assert float(state.state) == 50 - state = hass.states.get('input_slider.b2') + state = hass.states.get('input_number.b2') assert state assert float(state.state) == 60 @@ -148,6 +180,6 @@ def test_no_initial_state_and_no_restore_state(hass): }, }}) - state = hass.states.get('input_slider.b1') + state = hass.states.get('input_number.b1') assert state assert float(state.state) == 0 diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index e668bd5b6cd..a32b2dc13a1 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -745,11 +745,11 @@ is_state_attr('device_tracker.phone_2', 'battery', 40) self.assertListEqual( sorted([ 'sensor.luftfeuchtigkeit_mean', - 'input_slider.luftfeuchtigkeit', + 'input_number.luftfeuchtigkeit', ]), sorted(template.extract_entities( "{% if (states('sensor.luftfeuchtigkeit_mean') | int)" - " > (states('input_slider.luftfeuchtigkeit') | int +1.5)" + " > (states('input_number.luftfeuchtigkeit') | int +1.5)" " %}true{% endif %}" ))) From a4b64dec391bdb9aa71d3aacdb6db46fa88acd40 Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Tue, 3 Oct 2017 23:51:08 -0600 Subject: [PATCH 045/113] Properly handle an invalid end_time (#9675) --- homeassistant/components/history.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 9863e823e06..5904a99e43c 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -283,9 +283,10 @@ class HistoryPeriodView(HomeAssistantView): end_time = request.query.get('end_time') if end_time: - end_time = dt_util.as_utc( - dt_util.parse_datetime(end_time)) - if end_time is None: + end_time = dt_util.parse_datetime(end_time) + if end_time: + end_time = dt_util.as_utc(end_time) + else: return self.json_message('Invalid end_time', HTTP_BAD_REQUEST) else: end_time = start_time + one_day From 4be91a103d1bafadbcd0532b14c3b1c7d93430ca Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 4 Oct 2017 07:52:45 +0200 Subject: [PATCH 046/113] Support new feature to push API data to hassio (#9679) * Support new featuer to push API data to hassio * Add tests & services --- homeassistant/components/hassio.py | 99 +++++++++++++++++++++++++----- tests/components/test_hassio.py | 96 +++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 1ba599c72b4..4bcb762cbd3 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -14,9 +14,13 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPBadGateway from aiohttp.hdrs import CONTENT_TYPE import async_timeout +import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN -from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.components.http import ( + HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.components.frontend import register_built_in_panel @@ -25,16 +29,42 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'hassio' DEPENDENCIES = ['http'] +SERVICE_ADDON_START = 'addon_start' +SERVICE_ADDON_STOP = 'addon_stop' +SERVICE_ADDON_RESTART = 'addon_restart' +SERVICE_ADDON_STDIN = 'addon_stdin' + +ATTR_ADDON = 'addon' +ATTR_INPUT = 'input' + NO_TIMEOUT = { - re.compile(r'^homeassistant/update$'), re.compile(r'^host/update$'), - re.compile(r'^supervisor/update$'), re.compile(r'^addons/[^/]*/update$'), - re.compile(r'^addons/[^/]*/install$') + re.compile(r'^homeassistant/update$'), + re.compile(r'^host/update$'), + re.compile(r'^supervisor/update$'), + re.compile(r'^addons/[^/]*/update$'), + re.compile(r'^addons/[^/]*/install$'), + re.compile(r'^addons/[^/]*/rebuild$') } NO_AUTH = { re.compile(r'^panel$'), re.compile(r'^addons/[^/]*/logo$') } +SCHEMA_ADDON = vol.Schema({ + vol.Required(ATTR_ADDON): cv.slug, +}) + +SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ + vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) +}) + +MAP_SERVICE_API = { + SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON), + SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON), + SERVICE_ADDON_RESTART: ('/addons/{addon}/restart', SCHEMA_ADDON), + SERVICE_ADDON_STDIN: ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN), +} + @asyncio.coroutine def async_setup(hass, config): @@ -48,8 +78,7 @@ def async_setup(hass, config): websession = async_get_clientsession(hass) hassio = HassIO(hass.loop, websession, host) - api_ok = yield from hassio.is_connected() - if not api_ok: + if not (yield from hassio.is_connected()): _LOGGER.error("Not connected with HassIO!") return False @@ -59,6 +88,23 @@ def async_setup(hass, config): register_built_in_panel(hass, 'hassio', 'Hass.io', 'mdi:access-point-network') + if 'http' in config: + yield from hassio.update_hass_api(config.get('http')) + + @asyncio.coroutine + def async_service_handler(service): + """Handle service calls for HassIO.""" + api_command = MAP_SERVICE_API[service.service][0] + addon = service.data[ATTR_ADDON] + data = service.data[ATTR_INPUT] if ATTR_INPUT in service.data else None + + yield from hassio.send_command( + api_command.format(addon=addon), payload=data, timeout=60) + + for service, settings in MAP_SERVICE_API.items(): + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=settings[1]) + return True @@ -71,30 +117,55 @@ class HassIO(object): self.websession = websession self._ip = ip - @asyncio.coroutine def is_connected(self): """Return True if it connected to HassIO supervisor. + This method return a coroutine. + """ + return self.send_command("/supervisor/ping", method="get") + + def update_hass_api(self, http_config): + """Update Home-Assistant API data on HassIO. + + This method return a coroutine. + """ + options = { + 'ssl': CONF_SSL_CERTIFICATE in http_config, + } + + if http_config.get(CONF_SERVER_PORT): + options['port'] = http_config[CONF_SERVER_PORT] + + if http_config.get(CONF_API_PASSWORD): + options['password'] = http_config[CONF_API_PASSWORD] + + return self.send_command("/homeassistant/options", payload=options) + + @asyncio.coroutine + def send_command(self, command, method="post", payload=None, timeout=10): + """Send API command to HassIO. + This method is a coroutine. """ try: - with async_timeout.timeout(10, loop=self.loop): - request = yield from self.websession.get( - "http://{}{}".format(self._ip, "/supervisor/ping") - ) + with async_timeout.timeout(timeout, loop=self.loop): + request = yield from self.websession.request( + method, "http://{}{}".format(self._ip, command), + json=payload) if request.status != 200: - _LOGGER.error("Ping return code %d.", request.status) + _LOGGER.error( + "%s return code %d.", command, request.status) return False answer = yield from request.json() return answer and answer['result'] == 'ok' except asyncio.TimeoutError: - _LOGGER.error("Timeout on ping request") + _LOGGER.error("Timeout on %s request", command) except aiohttp.ClientError as err: - _LOGGER.error("Client error on ping request %s", err) + _LOGGER.error("Client error on %s request %s", command, err) return False diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index ccb56891495..26a8372352f 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -51,6 +51,102 @@ def test_fail_setup_cannot_connect(hass): assert not result +@asyncio.coroutine +def test_setup_api_ping(hass, aioclient_mock): + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', {}) + assert result + + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_setup_api_push_api_data(hass, aioclient_mock): + """Test setup with API push.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': "123456", + 'server_port': 9999 + }, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 2 + assert not aioclient_mock.mock_calls[-1][2]['ssl'] + assert aioclient_mock.mock_calls[-1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[-1][2]['port'] == 9999 + + +@asyncio.coroutine +def test_setup_api_push_api_data_default(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 2 + assert not aioclient_mock.mock_calls[-1][2]['ssl'] + assert 'password' not in aioclient_mock.mock_calls[-1][2] + assert 'port' not in aioclient_mock.mock_calls[-1][2] + + +@asyncio.coroutine +def test_service_register(hassio_env, hass): + """Check if service will be settup.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + assert hass.services.has_service('hassio', 'addon_start') + assert hass.services.has_service('hassio', 'addon_stop') + assert hass.services.has_service('hassio', 'addon_restart') + assert hass.services.has_service('hassio', 'addon_stdin') + + +@asyncio.coroutine +def test_service_calls(hassio_env, hass, aioclient_mock): + """Call service and check the API calls behind that.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/addons/test/start", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/addons/test/stop", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/addons/test/restart", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/addons/test/stdin", json={'result': 'ok'}) + + yield from hass.services.async_call( + 'hassio', 'addon_start', {'addon': 'test'}) + yield from hass.services.async_call( + 'hassio', 'addon_stop', {'addon': 'test'}) + yield from hass.services.async_call( + 'hassio', 'addon_restart', {'addon': 'test'}) + yield from hass.services.async_call( + 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 4 + assert aioclient_mock.mock_calls[-1][2] == 'test' + + @asyncio.coroutine def test_forward_request(hassio_client): """Test fetching normal path.""" From 7759ae26fd451fb24c2a2ed1cf09f67433cd3310 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Wed, 4 Oct 2017 09:59:38 +0200 Subject: [PATCH 047/113] Adding ignore capability to Egardia component (#9676) --- .../components/alarm_control_panel/egardia.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 4acf253e3a7..7e976296b16 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -25,6 +25,7 @@ _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' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 @@ -148,9 +149,15 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def parsestatus(self, status): """Parse the status.""" - newstatus = ([v for k, v in STATES.items() - if status.upper() == k][0]) - self._status = newstatus + _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]) + self._status = newstatus + else: + _LOGGER.error("Ignoring status") def update(self): """Update the alarm status.""" From 3a282702d9548f46149f02e60fdc60b98fb70b6e Mon Sep 17 00:00:00 2001 From: Martin Berg Date: Wed, 4 Oct 2017 10:01:20 +0200 Subject: [PATCH 048/113] Fix Google Calendar/oauth2client warning (#9677) * Fixes oauth2client warning. * Fix permission. --- homeassistant/components/google.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 78b6675ab79..889c905613f 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -99,10 +99,10 @@ def do_authentication(hass, config): from oauth2client.file import Storage oauth = OAuth2WebServerFlow( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - 'https://www.googleapis.com/auth/calendar.readonly', - 'Home-Assistant.io', + client_id=config[CONF_CLIENT_ID], + client_secret=config[CONF_CLIENT_SECRET], + scope='https://www.googleapis.com/auth/calendar.readonly', + redirect_uri='Home-Assistant.io', ) try: From e0de52138868ac8ced4c07c960e06f37936d5219 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Wed, 4 Oct 2017 10:20:08 +0200 Subject: [PATCH 049/113] Implement DSMR5 support. (#9686) * Allow configuring DSMR5 protocol. * Give good example. * Using dev branch until released upstream. * Update to dsmr_parser supporting v5 arguments. * Update to latest dmsr parser, preventing exceptions thrown where warnings would suffice. * Update even more * Update requirements. * Update requirements --- homeassistant/components/sensor/dsmr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 2b303ac3c71..5b20ac0f4d0 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -40,7 +40,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, vol.Optional(CONF_HOST, default=None): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(['4', '2.2'])), + cv.string, vol.In(['5', '4', '2.2'])), vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, }) @@ -73,7 +73,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices = [DSMREntity(name, obis) for name, obis in obis_mapping] # Protocol version specific obis - if dsmr_version == '4': + if dsmr_version in ('4', '5'): gas_obis = obis_ref.HOURLY_GAS_METER_READING else: gas_obis = obis_ref.GAS_METER_READING From 4314dc251f5b9b190a2700d5a1cd36edc0b795a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 4 Oct 2017 10:31:42 +0200 Subject: [PATCH 050/113] Add Tibber sensor (#9661) * Add Tibber sensor * remove extra space --- .coveragerc | 1 + homeassistant/components/sensor/tibber.py | 99 +++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 103 insertions(+) create mode 100644 homeassistant/components/sensor/tibber.py diff --git a/.coveragerc b/.coveragerc index 2d3c64a79cd..c1cde971606 100644 --- a/.coveragerc +++ b/.coveragerc @@ -541,6 +541,7 @@ omit = homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py + homeassistant/components/sensor/tibber.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/torque.py homeassistant/components/sensor/transmission.py diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py new file mode 100644 index 00000000000..f1edaa37f77 --- /dev/null +++ b/homeassistant/components/sensor/tibber.py @@ -0,0 +1,99 @@ +""" +Support for Tibber. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tibber/ +""" +import asyncio + +import logging + +from datetime import timedelta +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.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt as dt_util + +REQUIREMENTS = ['pyTibber==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string +}) + +ICON = 'mdi:currency-usd' +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)) + + async_add_devices(dev) + + +class TibberSensor(Entity): + """Representation of an Tibber sensor.""" + + def __init__(self, tibber_home): + """Initialize the sensor.""" + 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) + + @asyncio.coroutine + def async_update(self): + """Get the latest data and updates the states.""" + 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: + return + + yield from self._tibber_home.update_current_price_info() + + 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 + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device_state_attributes + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement diff --git a/requirements_all.txt b/requirements_all.txt index 4956ea4a194..61cf8945c04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -552,6 +552,9 @@ pyHS100==0.2.4.2 # homeassistant.components.rfxtrx pyRFXtrx==0.20.1 +# homeassistant.components.sensor.tibber +pyTibber==0.1.1 + # homeassistant.components.switch.dlink pyW215==0.6.0 From 3f9d052218d2c07e4d03849757c0c109d3800f6f Mon Sep 17 00:00:00 2001 From: milanvo Date: Wed, 4 Oct 2017 14:07:42 +0200 Subject: [PATCH 051/113] Add recorder purge service, rework purge timer (#9523) * Add recorder purge service * Recorder test to match purge config * Removed purge timer, move service handler to setup, add service description file * Tests for recorder purge service * Recorder purge timer rework, add purge service parameter, tests * Purge service schema change * Service description change value range * First cleanup * Fix name of config --- homeassistant/components/recorder/__init__.py | 72 ++++++++++++++----- .../components/recorder/services.yaml | 9 +++ tests/components/recorder/test_init.py | 3 +- tests/components/recorder/test_purge.py | 48 ++++++++++++- 4 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/recorder/services.yaml diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5d3ca270399..5959165779b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -10,10 +10,11 @@ https://home-assistant.io/components/recorder/ import asyncio import concurrent.futures import logging +from os import path import queue import threading import time -from datetime import timedelta, datetime +from datetime import datetime, timedelta from typing import Optional, Dict import voluptuous as vol @@ -28,6 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util +from homeassistant import config as conf_util from . import purge, migration from .const import DATA_INSTANCE @@ -39,11 +41,21 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'recorder' +SERVICE_PURGE = 'purge' + +ATTR_KEEP_DAYS = 'keep_days' + +SERVICE_PURGE_SCHEMA = vol.Schema({ + vol.Required(ATTR_KEEP_DAYS): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + DEFAULT_URL = 'sqlite:///{hass_config_path}' DEFAULT_DB_FILE = 'home-assistant_v2.db' CONF_DB_URL = 'db_url' -CONF_PURGE_DAYS = 'purge_days' +CONF_PURGE_KEEP_DAYS = 'purge_keep_days' +CONF_PURGE_INTERVAL = 'purge_interval' CONF_EVENT_TYPES = 'event_types' CONNECT_RETRY_WAIT = 3 @@ -65,7 +77,9 @@ FILTER_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: FILTER_SCHEMA.extend({ - vol.Optional(CONF_PURGE_DAYS): + vol.Inclusive(CONF_PURGE_KEEP_DAYS, 'purge'): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Inclusive(CONF_PURGE_INTERVAL, 'purge'): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DB_URL): cv.string, }) @@ -106,7 +120,8 @@ def run_information(hass, point_in_time: Optional[datetime]=None): def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" conf = config.get(DOMAIN, {}) - purge_days = conf.get(CONF_PURGE_DAYS) + purge_days = conf.get(CONF_PURGE_KEEP_DAYS) + purge_interval = conf.get(CONF_PURGE_INTERVAL) db_url = conf.get(CONF_DB_URL, None) if not db_url: @@ -116,24 +131,46 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: include = conf.get(CONF_INCLUDE, {}) exclude = conf.get(CONF_EXCLUDE, {}) instance = hass.data[DATA_INSTANCE] = Recorder( - hass, purge_days=purge_days, uri=db_url, include=include, - exclude=exclude) + hass, uri=db_url, include=include, exclude=exclude) instance.async_initialize() instance.start() + @asyncio.coroutine + def async_handle_purge_interval(now): + """Handle purge interval.""" + instance.do_purge(purge_days) + + @asyncio.coroutine + def async_handle_purge_service(service): + """Handle calls to the purge service.""" + instance.do_purge(service.data[ATTR_KEEP_DAYS]) + + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, path.join( + path.dirname(__file__), 'services.yaml')) + + if purge_interval and purge_days: + async_track_time_interval(hass, async_handle_purge_interval, + timedelta(days=purge_interval)) + + hass.services.async_register(DOMAIN, SERVICE_PURGE, + async_handle_purge_service, + descriptions.get(SERVICE_PURGE), + schema=SERVICE_PURGE_SCHEMA) + return (yield from instance.async_db_ready) class Recorder(threading.Thread): """A threaded recorder class.""" - def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, + def __init__(self, hass: HomeAssistant, uri: str, include: Dict, exclude: Dict) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name='Recorder') self.hass = hass - self.purge_days = purge_days + self.purge_days = None self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri @@ -148,12 +185,19 @@ class Recorder(threading.Thread): self.exclude_t = exclude.get(CONF_EVENT_TYPES, []) self.get_session = None + self.purge_task = object() @callback def async_initialize(self): """Initialize the recorder.""" self.hass.bus.async_listen(MATCH_ALL, self.event_listener) + def do_purge(self, purge_days=None): + """Event listener for purging data.""" + if purge_days is not None: + self.purge_days = purge_days + self.queue.put(self.purge_task) + def run(self): """Start processing events to save.""" from .models import States, Events @@ -190,7 +234,6 @@ class Recorder(threading.Thread): self.hass.add_job(connection_failed) return - purge_task = object() shutdown_task = object() hass_started = concurrent.futures.Future() @@ -220,15 +263,6 @@ class Recorder(threading.Thread): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, notify_hass_started) - if self.purge_days is not None: - @callback - def do_purge(now): - """Event listener for purging data.""" - self.queue.put(purge_task) - - async_track_time_interval(self.hass, do_purge, - timedelta(days=2)) - self.hass.add_job(register) result = hass_started.result() @@ -244,7 +278,7 @@ class Recorder(threading.Thread): self._close_connection() self.queue.task_done() return - elif event is purge_task: + elif event is self.purge_task: purge.purge_old_data(self, self.purge_days) continue elif event.event_type == EVENT_TIME_CHANGED: diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml new file mode 100644 index 00000000000..fa57e8fc07f --- /dev/null +++ b/homeassistant/components/recorder/services.yaml @@ -0,0 +1,9 @@ +# Describes the format for available recorder services + +purge: + description: Start purge task - delete events and states older than x days, according to keep_days service data. + + fields: + keep_days: + description: Number of history days to keep in database after purge. Value >= 0 + example: 2 diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 539b80f50d0..ed04e96a43c 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -195,8 +195,7 @@ def test_recorder_setup_failure(): with patch.object(Recorder, '_setup_connection') as setup, \ patch('homeassistant.components.recorder.time.sleep'): setup.side_effect = ImportError("driver not found") - rec = Recorder( - hass, purge_days=0, uri='sqlite://', include={}, exclude={}) + rec = Recorder(hass, uri='sqlite://', include={}, exclude={}) rec.start() rec.join() diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 1a52e0503bb..5db710882d9 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -1,6 +1,7 @@ """Test data purging.""" import json from datetime import datetime, timedelta +from time import sleep import unittest from homeassistant.components import recorder @@ -16,8 +17,9 @@ class TestRecorderPurge(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" + config = {'purge_keep_days': 4, 'purge_interval': 2} self.hass = get_test_home_assistant() - init_recorder_component(self.hass) + init_recorder_component(self.hass, config) self.hass.start() def tearDown(self): # pylint: disable=invalid-name @@ -107,3 +109,47 @@ class TestRecorderPurge(unittest.TestCase): # now we should only have 3 events left self.assertEqual(events.count(), 3) + + def test_purge_method(self): + """Test purge method.""" + service_data = {'keep_days': 4} + self._add_test_states() + self._add_test_events() + + # make sure we start with 5 states + with session_scope(hass=self.hass) as session: + states = session.query(States) + self.assertEqual(states.count(), 5) + + events = session.query(Events).filter( + Events.event_type.like("EVENT_TEST%")) + self.assertEqual(events.count(), 5) + + self.hass.data[DATA_INSTANCE].block_till_done() + + # run purge method - no service data, should not work + self.hass.services.call('recorder', 'purge') + self.hass.async_block_till_done() + + # Small wait for recorder thread + sleep(0.1) + + # we should only have 2 states left after purging + self.assertEqual(states.count(), 5) + + # now we should only have 3 events left + self.assertEqual(events.count(), 5) + + # run purge method - correct service data + self.hass.services.call('recorder', 'purge', + service_data=service_data) + self.hass.async_block_till_done() + + # Small wait for recorder thread + sleep(0.1) + + # we should only have 2 states left after purging + self.assertEqual(states.count(), 2) + + # now we should only have 3 events left + self.assertEqual(events.count(), 3) From 65de739489fa197681a0971a7bbb9b03c438218c Mon Sep 17 00:00:00 2001 From: milanvo Date: Wed, 4 Oct 2017 14:13:58 +0200 Subject: [PATCH 052/113] Fix restore state by filter out null value row from DB query (#9690) --- homeassistant/components/history.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 5904a99e43c..4f51abf8973 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -41,6 +41,7 @@ def last_recorder_run(hass): with session_scope(hass=hass) as session: res = (session.query(RecorderRuns) + .filter(RecorderRuns.end.isnot(None)) .order_by(RecorderRuns.end.desc()).first()) if res is None: return None From e753c51e34bcc5e6af34122140888f301e0c3338 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Wed, 4 Oct 2017 16:34:37 +0200 Subject: [PATCH 053/113] Updating clicksendaudio component based on feedback (#9692) * Updating clicksendaudio component based on feedback * Updating .coveragerc - forgot to add new file clicksendaudio.py --- .coveragerc | 1 + .../components/notify/clicksendaudio.py | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 homeassistant/components/notify/clicksendaudio.py diff --git a/.coveragerc b/.coveragerc index c1cde971606..c1714f60fe3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -402,6 +402,7 @@ omit = homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py homeassistant/components/notify/clicksend.py + homeassistant/components/notify/clicksendaudio.py homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py diff --git a/homeassistant/components/notify/clicksendaudio.py b/homeassistant/components/notify/clicksendaudio.py new file mode 100644 index 00000000000..b8f346c9478 --- /dev/null +++ b/homeassistant/components/notify/clicksendaudio.py @@ -0,0 +1,90 @@ +""" +Clicksend audio platform for notify component. + +This platform sends text to speech audio messages through clicksend + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.clicksendaudio/ +""" +import json +import logging +import requests + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, + CONTENT_TYPE_JSON) +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) + +BASE_API_URL = 'https://rest.clicksend.com/v3' + +HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} + +CONF_LANGUAGE = 'language' +CONF_VOICE = 'voice' + +DEFAULT_LANGUAGE = 'en-us' +DEFAULT_VOICE = 'female' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): cv.string, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the ClickSend notification service.""" + if _authenticate(config) is False: + _LOGGER.error("You are not authorized to access ClickSend") + return None + + return ClicksendNotificationService(config) + + +class ClicksendNotificationService(BaseNotificationService): + """Implementation of a notification service for the ClickSend service.""" + + def __init__(self, config): + """Initialize the service.""" + self.username = config.get(CONF_USERNAME) + self.api_key = config.get(CONF_API_KEY) + self.recipient = config.get(CONF_RECIPIENT) + self.language = config.get(CONF_LANGUAGE) + self.voice = config.get(CONF_VOICE) + + def send_message(self, message="", **kwargs): + """Send a voice call to a user.""" + data = ({'messages': [{'source': 'hass.notify', 'from': self.recipient, + 'to': self.recipient, 'body': message, + 'lang': self.language, 'voice': self.voice}]}) + api_url = "{}/voice/send".format(BASE_API_URL) + resp = requests.post(api_url, data=json.dumps(data), headers=HEADERS, + auth=(self.username, self.api_key), timeout=5) + + obj = json.loads(resp.text) + response_msg = obj['response_msg'] + response_code = obj['response_code'] + if resp.status_code != 200: + _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, + response_msg, response_code) + + +def _authenticate(config): + """Authenticate with ClickSend.""" + api_url = '{}/account'.format(BASE_API_URL) + resp = requests.get(api_url, headers=HEADERS, + auth=(config.get(CONF_USERNAME), + config.get(CONF_API_KEY)), timeout=5) + + if resp.status_code != 200: + return False + + return True From 84271a2dac2b34e4c80298c0d2ed6de71c928b3a Mon Sep 17 00:00:00 2001 From: bestlibre Date: Wed, 4 Oct 2017 16:35:58 +0200 Subject: [PATCH 054/113] Refactoring of onewire sensor component (#9691) --- homeassistant/components/sensor/onewire.py | 64 ++++++++++++---------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index b36e7bdf267..1f58eb4c13e 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -61,8 +61,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): '[.-]*')): sensor_id = os.path.split(device_folder)[1] device_file = os.path.join(device_folder, 'w1_slave') - devs.append(OneWire(device_names.get(sensor_id, sensor_id), - device_file, 'temperature')) + devs.append(OneWireDirect(device_names.get(sensor_id, + sensor_id), + device_file, 'temperature')) else: for family_file_path in glob(os.path.join(base_dir, '*', 'family')): family_file = open(family_file_path, "r") @@ -73,8 +74,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): os.path.split(family_file_path)[0])[1] device_file = os.path.join( os.path.split(family_file_path)[0], sensor_value) - devs.append(OneWire(device_names.get(sensor_id, sensor_id), - device_file, sensor_key)) + devs.append(OneWireOWFS(device_names.get(sensor_id, + sensor_id), + device_file, sensor_key)) if devs == []: _LOGGER.error("No onewire sensor found. Check if dtoverlay=w1-gpio " @@ -97,9 +99,8 @@ class OneWire(Entity): def _read_value_raw(self): """Read the value as it is returned by the sensor.""" - ds_device_file = open(self._device_file, 'r') - lines = ds_device_file.readlines() - ds_device_file.close() + with open(self._device_file, 'r') as ds_device_file: + lines = ds_device_file.readlines() return lines @property @@ -117,30 +118,37 @@ class OneWire(Entity): """Return the unit the value is expressed in.""" return self._unit_of_measurement + +class OneWireDirect(OneWire): + """Implementation of an One wire Sensor directly connected to RPI GPIO.""" + def update(self): """Get the latest data from the device.""" value = None - if self._device_file.startswith(DEFAULT_MOUNT_DIR): + lines = self._read_value_raw() + while lines[0].strip()[-3:] != 'YES': + time.sleep(0.2) lines = self._read_value_raw() - while lines[0].strip()[-3:] != 'YES': - time.sleep(0.2) - lines = self._read_value_raw() - equals_pos = lines[1].find('t=') - if equals_pos != -1: - value_string = lines[1][equals_pos+2:] - value = round(float(value_string) / 1000.0, 1) - else: - try: - ds_device_file = open(self._device_file, 'r') - value_read = ds_device_file.readlines() - ds_device_file.close() - if len(value_read) == 1: - value = round(float(value_read[0]), 1) - except ValueError: - _LOGGER.warning("Invalid value read from %s", - self._device_file) - except FileNotFoundError: - _LOGGER.warning( - "Cannot read from sensor: %s", self._device_file) + equals_pos = lines[1].find('t=') + if equals_pos != -1: + value_string = lines[1][equals_pos + 2:] + value = round(float(value_string) / 1000.0, 1) + self._state = value + + +class OneWireOWFS(OneWire): + """Implementation of an One wire Sensor through owfs.""" + + def update(self): + """Get the latest data from the device.""" + value = None + try: + value_read = self._read_value_raw() + if len(value_read) == 1: + value = round(float(value_read[0]), 1) + except ValueError: + _LOGGER.warning("Invalid value read from %s", self._device_file) + except FileNotFoundError: + _LOGGER.warning("Cannot read from sensor: %s", self._device_file) self._state = value From f34ebf733d18a8768e0da2dddec8b375d8077387 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 4 Oct 2017 18:31:50 +0200 Subject: [PATCH 055/113] HassIO replace config changes (#9695) * Update flow * fix tests * Update hassio.py --- homeassistant/components/hassio.py | 11 ++++------- tests/components/test_hassio.py | 4 ++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 4bcb762cbd3..1be8ebcf5dd 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -17,7 +17,7 @@ import async_timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN +from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT from homeassistant.components.http import ( HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE) @@ -129,16 +129,13 @@ class HassIO(object): This method return a coroutine. """ + port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT options = { 'ssl': CONF_SSL_CERTIFICATE in http_config, + 'port': port, + 'password': http_config.get(CONF_API_PASSWORD), } - if http_config.get(CONF_SERVER_PORT): - options['port'] = http_config[CONF_SERVER_PORT] - - if http_config.get(CONF_API_PASSWORD): - options['password'] = http_config[CONF_API_PASSWORD] - return self.send_command("/homeassistant/options", payload=options) @asyncio.coroutine diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index 26a8372352f..f7c967da862 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -105,8 +105,8 @@ def test_setup_api_push_api_data_default(hass, aioclient_mock): assert aioclient_mock.call_count == 2 assert not aioclient_mock.mock_calls[-1][2]['ssl'] - assert 'password' not in aioclient_mock.mock_calls[-1][2] - assert 'port' not in aioclient_mock.mock_calls[-1][2] + assert aioclient_mock.mock_calls[-1][2]['password'] is None + assert aioclient_mock.mock_calls[-1][2]['port'] == 8123 @asyncio.coroutine From 89042439b82f09466208e305d4259df7ff04e88a Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 4 Oct 2017 18:04:39 -0400 Subject: [PATCH 056/113] Fixed typo in opencv (#9697) --- homeassistant/components/image_processing/opencv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 3264fc5c96c..56a4ac50bd7 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -28,7 +28,7 @@ CASCADE_URL = \ 'https://raw.githubusercontent.com/opencv/opencv/master/data/' + \ 'lbpcascades/lbpcascade_frontalface.xml' -CONF_CLASSIFIER = 'classifer' +CONF_CLASSIFIER = 'classifier' CONF_FILE = 'file' CONF_MIN_SIZE = 'min_size' CONF_NEIGHBORS = 'neighbors' From 8db4641455ea1115e5f81591430f33296bb3ad74 Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Thu, 5 Oct 2017 17:05:38 +0100 Subject: [PATCH 057/113] [light.tradfri] async support with resource observation. (#7815) * [light.tradfri] Initial support for observe * Update for pytradfri 2.0 * Fix imports * Fix missing call * Don't yield from add devices * Fix imports * Minor fixes to async code. * Imports, formatting * Docker updates, some minor async code changes. * Lint * Lint * Update pytradfri * Minor updates for release version * Build fixes * Retry observation if failed * Revert * Additional logging, fix returns * Fix rename * Bump version * Bump version * Support transitions * Lint * Fix transitions * Update Dockerfile * Set temp first * Observation error handling * Lint * Lint * Lint * Merge upstream changes * Fix bugs * Fix bugs * Fix bugs * Lint * Add sensor * Add sensor * Move sensor attrs * Filter devices better * Lint * Address comments * Pin aiocoap * Fix bug if no devices * Requirements --- Dockerfile | 4 +- homeassistant/components/light/tradfri.py | 240 +++++++++++++++------ homeassistant/components/sensor/tradfri.py | 116 ++++++++++ homeassistant/components/tradfri.py | 17 +- requirements_all.txt | 2 +- virtualization/Docker/Dockerfile.dev | 2 +- virtualization/Docker/scripts/aiocoap | 23 ++ virtualization/Docker/scripts/coap_client | 17 -- virtualization/Docker/setup_docker_prereqs | 6 +- 9 files changed, 336 insertions(+), 91 deletions(-) create mode 100644 homeassistant/components/sensor/tradfri.py create mode 100755 virtualization/Docker/scripts/aiocoap delete mode 100755 virtualization/Docker/scripts/coap_client diff --git a/Dockerfile b/Dockerfile index f0d5accdf3d..908e8481eee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,10 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no -#ENV INSTALL_COAP_CLIENT no +#ENV INSTALL_COAP no #ENV INSTALL_SSOCR no + VOLUME /config RUN mkdir -p /usr/src/app @@ -25,7 +26,6 @@ RUN virtualization/Docker/setup_docker_prereqs # Install hass component dependencies COPY requirements_all.txt requirements_all.txt - # Uninstall enum34 because some depenndecies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 0f56982dae5..3efab8309fc 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -4,15 +4,18 @@ Support for the IKEA Tradfri platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.tradfri/ """ +import asyncio import logging +from homeassistant.core import callback from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) -from homeassistant.components.light import ( - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA) -from homeassistant.components.tradfri import ( - KEY_GATEWAY, KEY_TRADFRI_GROUPS, KEY_API) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, + SUPPORT_RGB_COLOR, Light) +from homeassistant.components.light import \ + PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA +from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS, \ + KEY_API from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -20,10 +23,13 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' +TRADFRI_LIGHT_MANAGER = 'Tradfri Light Manager' +SUPPORTED_FEATURES = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) ALLOWED_TEMPERATURES = {IKEA} -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the IKEA Tradfri Light platform.""" if discovery_info is None: return @@ -31,14 +37,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): gateway_id = discovery_info['gateway'] api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = api(gateway.get_devices()) - lights = [dev for dev in devices if api(dev).has_light_control] - add_devices(Tradfri(light, api) for light in lights) + + devices_command = gateway.get_devices() + devices_commands = yield from api(devices_command) + devices = yield from api(*devices_commands) + lights = [dev for dev in devices if dev.has_light_control] + if lights: + async_add_devices(TradfriLight(light, api) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = api(gateway.get_groups()) - add_devices(TradfriGroup(group, api) for group in groups) + groups_command = gateway.get_groups() + groups_commands = yield from api(groups_command) + groups = yield from api(*groups_commands) + if groups: + async_add_devices(TradfriGroup(group, api) for group in groups) class TradfriGroup(Light): @@ -46,14 +59,26 @@ class TradfriGroup(Light): def __init__(self, light, api): """Initialize a Group.""" - self._group = api(light) self._api = api - self._name = self._group.name + self._group = light + self._name = light.name + + self._refresh(light) + + @asyncio.coroutine + def async_added_to_hass(self): + """Start thread when added to hass.""" + self._async_start_observe() + + @property + def should_poll(self): + """No polling needed for tradfri group.""" + return False @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS + return SUPPORTED_FEATURES @property def name(self): @@ -70,49 +95,68 @@ class TradfriGroup(Light): """Return the brightness of the group lights.""" return self._group.dimmer - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._api(self._group.set_state(0)) + self.hass.async_add_job(self._api(self._group.set_state(0))) - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" + keys = {} + if ATTR_TRANSITION in kwargs: + keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) + if ATTR_BRIGHTNESS in kwargs: - self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) + self.hass.async_add_job(self._api( + self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys))) else: - self._api(self._group.set_state(1)) + self.hass.async_add_job(self._api(self._group.set_state(1))) + + @callback + def _async_start_observe(self, exc=None): + """Start observation of light.""" + from pytradfri.error import PyTradFriError + if exc: + _LOGGER.warning("Observation failed for %s", self._name, + exc_info=exc) - def update(self): - """Fetch new state data for this group.""" - from pytradfri import RequestTimeout try: - self._api(self._group.update()) - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + cmd = self._group.observe(callback=self._observe_update, + err_callback=self._async_start_observe, + duration=0) + self.hass.async_add_job(self._api(cmd)) + except PyTradFriError as err: + _LOGGER.warning("Observation failed, trying again", exc_info=err) + self._async_start_observe() + + def _refresh(self, group): + """Refresh the light data.""" + self._group = group + self._name = group.name + + def _observe_update(self, tradfri_device): + """Receive new state data for this light.""" + self._refresh(tradfri_device) + + self.hass.async_add_job(self.async_update_ha_state()) -class Tradfri(Light): - """The platform class required by Home Asisstant.""" +class TradfriLight(Light): + """The platform class required by Home Assistant.""" def __init__(self, light, api): """Initialize a Light.""" - self._light = api(light) self._api = api - - # Caching of LightControl and light object - self._light_control = self._light.light_control - self._light_data = self._light_control.lights[0] - self._name = self._light.name + self._light = None + self._light_control = None + self._light_data = None + self._name = None self._rgb_color = None - self._features = SUPPORT_BRIGHTNESS + self._features = SUPPORTED_FEATURES + self._temp_supported = False - if self._light_data.hex_color is not None: - if self._light.device_info.manufacturer == IKEA: - self._features |= SUPPORT_COLOR_TEMP - else: - self._features |= SUPPORT_RGB_COLOR - - self._ok_temps = \ - self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + self._refresh(light) @property def min_mireds(self): @@ -126,6 +170,30 @@ class Tradfri(Light): from pytradfri.color import MIN_KELVIN_WS return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) + @property + def device_state_attributes(self): + """Return the devices' state attributes.""" + info = self._light.device_info + attrs = { + 'manufacturer': info.manufacturer, + 'model_number': info.model_number, + 'serial': info.serial, + 'firmware_version': info.firmware_version, + 'power_source': info.power_source_str, + 'battery_level': info.battery_level + } + return attrs + + @asyncio.coroutine + def async_added_to_hass(self): + """Start thread when added to hass.""" + self._async_start_observe() + + @property + def should_poll(self): + """No polling needed for tradfri light.""" + return False + @property def supported_features(self): """Flag supported features.""" @@ -151,7 +219,7 @@ class Tradfri(Light): """Return the CT color value in mireds.""" if (self._light_data.kelvin_color is None or self.supported_features & SUPPORT_COLOR_TEMP == 0 or - not self._ok_temps): + not self._temp_supported): return None return color_util.color_temperature_kelvin_to_mired( self._light_data.kelvin_color @@ -162,42 +230,90 @@ class Tradfri(Light): """RGB color of the light.""" return self._rgb_color - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._api(self._light_control.set_state(False)) + self.hass.async_add_job(self._api( + self._light_control.set_state(False))) - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """ Instruct the light to turn on. After adding "self._light_data.hexcolor is not None" for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ - if ATTR_BRIGHTNESS in kwargs: - self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) - else: - self._api(self._light_control.set_state(True)) - if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._api(self._light.light_control.set_rgb_color( - *kwargs[ATTR_RGB_COLOR])) + self.hass.async_add_job(self._api( + self._light.light_control.set_rgb_color( + *kwargs[ATTR_RGB_COLOR]))) elif ATTR_COLOR_TEMP in kwargs and \ - self._light_data.hex_color is not None and self._ok_temps: + self._light_data.hex_color is not None and \ + self._temp_supported: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - self._api(self._light_control.set_kelvin_color(kelvin)) + self.hass.async_add_job(self._api( + self._light_control.set_kelvin_color(kelvin))) + + keys = {} + if ATTR_TRANSITION in kwargs: + keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) + + if ATTR_BRIGHTNESS in kwargs: + self.hass.async_add_job(self._api( + self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS], + **keys))) + else: + self.hass.async_add_job(self._api( + self._light_control.set_state(True))) + + @callback + def _async_start_observe(self, exc=None): + """Start observation of light.""" + from pytradfri.error import PyTradFriError + if exc: + _LOGGER.warning("Observation failed for %s", self._name, + exc_info=exc) - def update(self): - """Fetch new state data for this light.""" - from pytradfri import RequestTimeout try: - self._api(self._light.update()) - except RequestTimeout as exception: - _LOGGER.warning("Tradfri update request timed out: %s", exception) + cmd = self._light.observe(callback=self._observe_update, + err_callback=self._async_start_observe, + duration=0) + self.hass.async_add_job(self._api(cmd)) + except PyTradFriError as err: + _LOGGER.warning("Observation failed, trying again", exc_info=err) + self._async_start_observe() + + def _refresh(self, light): + """Refresh the light data.""" + self._light = light + + # Caching of LightControl and light object + self._light_control = light.light_control + self._light_data = light.light_control.lights[0] + self._name = light.name + self._rgb_color = None + self._features = SUPPORTED_FEATURES + + if self._light_data.hex_color is not None: + if self._light.device_info.manufacturer == IKEA: + self._features |= SUPPORT_COLOR_TEMP + else: + self._features |= SUPPORT_RGB_COLOR + + self._temp_supported = self._light.device_info.manufacturer \ + in ALLOWED_TEMPERATURES + + def _observe_update(self, tradfri_device): + """Receive new state data for this light.""" + self._refresh(tradfri_device) # Handle Hue lights paired with the gateway # hex_color is 0 when bulb is unreachable if self._light_data.hex_color not in (None, '0'): self._rgb_color = color_util.rgb_hex_to_rgb_list( self._light_data.hex_color) + + self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/sensor/tradfri.py b/homeassistant/components/sensor/tradfri.py new file mode 100644 index 00000000000..314c18b7636 --- /dev/null +++ b/homeassistant/components/sensor/tradfri.py @@ -0,0 +1,116 @@ +""" +Support for the IKEA Tradfri platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tradfri/ +""" +import asyncio +import logging + +from datetime import timedelta + +from homeassistant.core import callback +from homeassistant.components.tradfri import KEY_GATEWAY, KEY_API +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tradfri'] + +SCAN_INTERVAL = timedelta(minutes=5) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the IKEA Tradfri device platform.""" + if discovery_info is None: + return + + gateway_id = discovery_info['gateway'] + api = hass.data[KEY_API][gateway_id] + gateway = hass.data[KEY_GATEWAY][gateway_id] + + devices_command = gateway.get_devices() + devices_commands = yield from api(devices_command) + all_devices = yield from api(*devices_commands) + devices = [dev for dev in all_devices if not dev.has_light_control] + async_add_devices(TradfriDevice(device, api) for device in devices) + + +class TradfriDevice(Entity): + """The platform class required by Home Assistant.""" + + def __init__(self, device, api): + """Initialize the device.""" + self._api = api + self._device = None + self._name = None + + self._refresh(device) + + @asyncio.coroutine + def async_added_to_hass(self): + """Start thread when added to hass.""" + self._async_start_observe() + + @property + def should_poll(self): + """No polling needed for tradfri.""" + return False + + @property + def name(self): + """Return the display name of this device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return '%' + + @property + def device_state_attributes(self): + """Return the devices' state attributes.""" + info = self._device.device_info + attrs = { + 'manufacturer': info.manufacturer, + 'model_number': info.model_number, + 'serial': info.serial, + 'firmware_version': info.firmware_version, + 'power_source': info.power_source_str, + 'battery_level': info.battery_level + } + return attrs + + @property + def state(self): + """Return the current state of the device.""" + return self._device.device_info.battery_level + + @callback + def _async_start_observe(self, exc=None): + """Start observation of light.""" + from pytradfri.error import PyTradFriError + if exc: + _LOGGER.warning("Observation failed for %s", self._name, + exc_info=exc) + + try: + cmd = self._device.observe(callback=self._observe_update, + err_callback=self._async_start_observe, + duration=0) + self.hass.async_add_job(self._api(cmd)) + except PyTradFriError as err: + _LOGGER.warning("Observation failed, trying again", exc_info=err) + self._async_start_observe() + + def _refresh(self, device): + """Refresh the device data.""" + self._device = device + self._name = device.name + + def _observe_update(self, tradfri_device): + """Receive new state data for this device.""" + self._refresh(tradfri_device) + + self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 34422819743..ef4d7fceed8 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==2.2'] +REQUIREMENTS = ['pytradfri==2.2.2'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' @@ -111,16 +111,21 @@ def async_setup(hass, config): def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" from pytradfri import Gateway, RequestError - from pytradfri.api.libcoap_api import api_factory + try: + from pytradfri.api.aiocoap_api import api_factory + except ImportError: + _LOGGER.exception("Looks like something isn't installed!") + return False try: - api = api_factory(host, key) + api = yield from api_factory(host, key, loop=hass.loop) except RequestError: + _LOGGER.exception("Tradfri setup failed.") return False gateway = Gateway() - # pylint: disable=no-member - gateway_id = api(gateway.get_gateway_info()).id + gateway_info_result = yield from api(gateway.get_gateway_info()) + gateway_id = gateway_info_result.id hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] @@ -137,6 +142,8 @@ def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): gateways[gateway_id] = gateway hass.async_add_job(discovery.async_load_platform( hass, 'light', DOMAIN, {'gateway': gateway_id}, hass_config)) + hass.async_add_job(discovery.async_load_platform( + hass, 'sensor', DOMAIN, {'gateway': gateway_id}, hass_config)) return True diff --git a/requirements_all.txt b/requirements_all.txt index 61cf8945c04..a04a4240ae4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -829,7 +829,7 @@ pythonegardia==1.0.21 pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==2.2 +pytradfri==2.2.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 3aa468ca6a7..70b1a19f46d 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -11,7 +11,7 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no -#ENV INSTALL_COAP_CLIENT no +#ENV INSTALL_COAP no #ENV INSTALL_SSOCR no VOLUME /config diff --git a/virtualization/Docker/scripts/aiocoap b/virtualization/Docker/scripts/aiocoap new file mode 100755 index 00000000000..8e36c616cb4 --- /dev/null +++ b/virtualization/Docker/scripts/aiocoap @@ -0,0 +1,23 @@ +#!/bin/sh +# Installs a modified coap client with support for dtls for use with IKEA Tradfri + +# Stop on errors +set -e + +python3 -m pip install cython + +cd /usr/src/app/ +mkdir -p build && cd build + +git clone --depth 1 https://git.fslab.de/jkonra2m/tinydtls +cd tinydtls +autoreconf +./configure --with-ecc --without-debug +cd cython +python3 setup.py install + +cd ../.. +git clone --depth 1 https://github.com/chrysn/aiocoap/ +cd aiocoap +git reset --hard 0df6a1e44582de99ae944b6a7536d08e2a612e8f +python3 -m pip install . diff --git a/virtualization/Docker/scripts/coap_client b/virtualization/Docker/scripts/coap_client deleted file mode 100755 index 82606c5f14d..00000000000 --- a/virtualization/Docker/scripts/coap_client +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -# Installs a modified coap client with support for dtls for use with IKEA Tradfri - -# Stop on errors -set -e - -apt-get install -y --no-install-recommends git autoconf automake libtool - -cd /usr/src/app/ -mkdir -p build && cd build - -git clone --depth 1 --recursive -b dtls https://github.com/home-assistant/libcoap.git -cd libcoap -./autogen.sh -./configure --disable-documentation --disable-shared --without-debug CFLAGS="-D COAP_DEBUG_FD=stderr" -make -make install diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 91bb9888765..95c8cd3f2e7 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -9,7 +9,7 @@ INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}" INSTALL_FFMPEG="${INSTALL_FFMPEG:-yes}" INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}" INSTALL_PHANTOMJS="${INSTALL_PHANTOMJS:-yes}" -INSTALL_COAP_CLIENT="${INSTALL_COAP_CLIENT:-yes}" +INSTALL_COAP="${INSTALL_COAP:-yes}" INSTALL_SSOCR="${INSTALL_SSOCR:-yes}" # Required debian packages for running hass or components @@ -59,8 +59,8 @@ if [ "$INSTALL_PHANTOMJS" == "yes" ]; then virtualization/Docker/scripts/phantomjs fi -if [ "$INSTALL_COAP_CLIENT" == "yes" ]; then - virtualization/Docker/scripts/coap_client +if [ "$INSTALL_COAP" == "yes" ]; then + virtualization/Docker/scripts/aiocoap fi if [ "$INSTALL_SSOCR" == "yes" ]; then From 75f902f57e320983c5c4862dc694fd9de01728f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Oct 2017 09:10:29 -0700 Subject: [PATCH 058/113] RFC: Create a secrets file and enable HTTP password by default (#9685) * Create a secret and enable password by default * Comment out api password secret * Lint/fix tests --- homeassistant/config.py | 17 +++++++++++++---- homeassistant/util/yaml.py | 15 ++++++++++----- tests/test_config.py | 6 ++++++ tests/util/test_yaml.py | 6 +++--- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index ee48ece67ab..6be0e776f3f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import callback, DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml, SECRET_YAML import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as date_util, location as loc_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM @@ -70,8 +70,8 @@ frontend: config: http: - # Uncomment this to add a password (recommended!) - # api_password: PASSWORD + # Secrets are defined in the file secrets.yaml + # api_password: !secret http_password # Uncomment this if you are using SSL/TLS, running in Docker container, etc. # base_url: example.duckdns.org:8123 @@ -111,6 +111,11 @@ group: !include groups.yaml automation: !include automations.yaml script: !include scripts.yaml """ +DEFAULT_SECRETS = """ +# Use this file to store secrets like usernames and passwords. +# Learn more at https://home-assistant.io/docs/configuration/secrets/ +http_password: welcome +""" PACKAGES_CONFIG_SCHEMA = vol.Schema({ @@ -181,6 +186,7 @@ def create_default_config(config_dir, detect_location=True): CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) config_path = os.path.join(config_dir, YAML_CONFIG_FILE) + secret_path = os.path.join(config_dir, SECRET_YAML) version_path = os.path.join(config_dir, VERSION_FILE) group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) @@ -209,7 +215,7 @@ def create_default_config(config_dir, detect_location=True): # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. try: - with open(config_path, 'w') as config_file: + with open(config_path, 'wt') as config_file: config_file.write("homeassistant:\n") for attr, _, _, description in DEFAULT_CORE_CONFIG: @@ -221,6 +227,9 @@ def create_default_config(config_dir, detect_location=True): config_file.write(DEFAULT_CONFIG) + with open(secret_path, 'wt') as secret_file: + secret_file.write(DEFAULT_SECRETS) + with open(version_path, 'wt') as version_file: version_file.write(__version__) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 7d8789c507b..c484fe3372a 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) _SECRET_NAMESPACE = 'homeassistant' -_SECRET_YAML = 'secrets.yaml' +SECRET_YAML = 'secrets.yaml' __SECRET_CACHE = {} # type: Dict @@ -133,7 +133,7 @@ def _include_dir_merge_named_yaml(loader: SafeLineLoader, mapping = OrderedDict() # type: OrderedDict loc = os.path.join(os.path.dirname(loader.name), node.value) for fname in _find_files(loc, '*.yaml'): - if os.path.basename(fname) == _SECRET_YAML: + if os.path.basename(fname) == SECRET_YAML: continue loaded_yaml = load_yaml(fname) if isinstance(loaded_yaml, dict): @@ -146,7 +146,7 @@ def _include_dir_list_yaml(loader: SafeLineLoader, """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.name), node.value) return [load_yaml(f) for f in _find_files(loc, '*.yaml') - if os.path.basename(f) != _SECRET_YAML] + if os.path.basename(f) != SECRET_YAML] def _include_dir_merge_list_yaml(loader: SafeLineLoader, @@ -156,7 +156,7 @@ def _include_dir_merge_list_yaml(loader: SafeLineLoader, node.value) # type: str merged_list = [] # type: List for fname in _find_files(loc, '*.yaml'): - if os.path.basename(fname) == _SECRET_YAML: + if os.path.basename(fname) == SECRET_YAML: continue loaded_yaml = load_yaml(fname) if isinstance(loaded_yaml, list): @@ -216,7 +216,7 @@ def _env_var_yaml(loader: SafeLineLoader, def _load_secret_yaml(secret_path: str) -> Dict: """Load the secrets yaml from path.""" - secret_path = os.path.join(secret_path, _SECRET_YAML) + secret_path = os.path.join(secret_path, SECRET_YAML) if secret_path in __SECRET_CACHE: return __SECRET_CACHE[secret_path] @@ -264,6 +264,8 @@ def _secret_yaml(loader: SafeLineLoader, _LOGGER.debug("Secret %s retrieved from keyring", node.value) return pwd + global credstash # pylint: disable=invalid-name + if credstash: try: pwd = credstash.getSecret(node.value, table=_SECRET_NAMESPACE) @@ -272,6 +274,9 @@ def _secret_yaml(loader: SafeLineLoader, return pwd except credstash.ItemNotFound: pass + except Exception: # pylint: disable=broad-except + # Catch if package installed and no config + credstash = None _LOGGER.error("Secret %s not defined", node.value) raise HomeAssistantError(node.value) diff --git a/tests/test_config.py b/tests/test_config.py index 1cb5e00bee9..400acbef17a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT) from homeassistant.util import location as location_util, dt as dt_util +from homeassistant.util.yaml import SECRET_YAML from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.helpers.entity import Entity from homeassistant.components.config.group import ( @@ -32,6 +33,7 @@ from tests.common import ( CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) +SECRET_PATH = os.path.join(CONFIG_DIR, SECRET_YAML) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) @@ -62,6 +64,9 @@ class TestConfig(unittest.TestCase): if os.path.isfile(YAML_PATH): os.remove(YAML_PATH) + if os.path.isfile(SECRET_PATH): + os.remove(SECRET_PATH) + if os.path.isfile(VERSION_PATH): os.remove(VERSION_PATH) @@ -85,6 +90,7 @@ class TestConfig(unittest.TestCase): config_util.create_default_config(CONFIG_DIR, False) assert os.path.isfile(YAML_PATH) + assert os.path.isfile(SECRET_PATH) assert os.path.isfile(VERSION_PATH) assert os.path.isfile(GROUP_PATH) assert os.path.isfile(AUTOMATIONS_PATH) diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 918a684f322..50e271008a2 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -302,7 +302,7 @@ class TestSecrets(unittest.TestCase): config_dir = get_test_config_dir() yaml.clear_secret_cache() self._yaml_path = os.path.join(config_dir, YAML_CONFIG_FILE) - self._secret_path = os.path.join(config_dir, yaml._SECRET_YAML) + self._secret_path = os.path.join(config_dir, yaml.SECRET_YAML) self._sub_folder_path = os.path.join(config_dir, 'subFolder') self._unrelated_path = os.path.join(config_dir, 'unrelated') @@ -351,7 +351,7 @@ class TestSecrets(unittest.TestCase): def test_secret_overrides_parent(self): """Test loading current directory secret overrides the parent.""" expected = {'api_password': 'override'} - load_yaml(os.path.join(self._sub_folder_path, yaml._SECRET_YAML), + load_yaml(os.path.join(self._sub_folder_path, yaml.SECRET_YAML), 'http_pw: override') self._yaml = load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'), 'http:\n' @@ -365,7 +365,7 @@ class TestSecrets(unittest.TestCase): def test_secrets_from_unrelated_fails(self): """Test loading secrets from unrelated folder fails.""" - load_yaml(os.path.join(self._unrelated_path, yaml._SECRET_YAML), + load_yaml(os.path.join(self._unrelated_path, yaml.SECRET_YAML), 'test: failure') with self.assertRaises(HomeAssistantError): load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'), From 6de403e0ac8c055dec19589cea6fe9adc6d52a22 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 5 Oct 2017 18:12:02 +0200 Subject: [PATCH 059/113] Support for The Things Network (#9627) * Support for The Things network's Data Storage * Rename platform and other changes (async and dict) * Rename sensor platform and remove check for 200 --- .coveragerc | 3 + .../components/sensor/thethingsnetwork.py | 163 ++++++++++++++++++ homeassistant/components/thethingsnetwork.py | 47 +++++ 3 files changed, 213 insertions(+) create mode 100644 homeassistant/components/sensor/thethingsnetwork.py create mode 100644 homeassistant/components/thethingsnetwork.py diff --git a/.coveragerc b/.coveragerc index c1714f60fe3..8b31cca97b5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -182,6 +182,9 @@ omit = homeassistant/components/tesla.py homeassistant/components/*/tesla.py + homeassistant/components/thethingsnetwork.py + homeassistant/components/*/thethingsnetwork.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py diff --git a/homeassistant/components/sensor/thethingsnetwork.py b/homeassistant/components/sensor/thethingsnetwork.py new file mode 100644 index 00000000000..90b21cc19e5 --- /dev/null +++ b/homeassistant/components/sensor/thethingsnetwork.py @@ -0,0 +1,163 @@ +""" +Support for The Things Network's Data storage integration. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.thethingsnetwork_data/ +""" +import asyncio +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.thethingsnetwork import ( + DATA_TTN, TTN_APP_ID, TTN_ACCESS_KEY, TTN_DATA_STORAGE_URL) +from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEVICE_ID = 'device_id' +ATTR_RAW = 'raw' +ATTR_TIME = 'time' + +DEFAULT_TIMEOUT = 10 +DEPENDENCIES = ['thethingsnetwork'] + +CONF_DEVICE_ID = 'device_id' +CONF_VALUES = 'values' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_VALUES): {cv.string: cv.string}, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up The Things Network Data storage sensors.""" + ttn = hass.data.get(DATA_TTN) + device_id = config.get(CONF_DEVICE_ID) + values = config.get(CONF_VALUES) + app_id = ttn.get(TTN_APP_ID) + access_key = ttn.get(TTN_ACCESS_KEY) + + ttn_data_storage = TtnDataStorage( + hass, app_id, device_id, access_key, values) + success = yield from ttn_data_storage.async_update() + + if not success: + return False + + devices = [] + for value, unit_of_measurement in values.items(): + devices.append(TtnDataSensor( + ttn_data_storage, device_id, value, unit_of_measurement)) + async_add_devices(devices, True) + + +class TtnDataSensor(Entity): + """Representation of a The Things Network Data Storage sensor.""" + + def __init__(self, ttn_data_storage, device_id, value, + unit_of_measurement): + """Initialize a The Things Network Data Storage sensor.""" + self._ttn_data_storage = ttn_data_storage + self._state = None + self._device_id = device_id + self._unit_of_measurement = unit_of_measurement + self._value = value + self._name = '{} {}'.format(self._device_id, self._value) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the entity.""" + if self._ttn_data_storage.data is not None: + try: + return round(self._state[self._value], 1) + except KeyError: + pass + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._ttn_data_storage.data is not None: + return { + ATTR_DEVICE_ID: self._device_id, + ATTR_RAW: self._state['raw'], + ATTR_TIME: self._state['time'], + } + + @asyncio.coroutine + def async_update(self): + """Get the current state.""" + yield from self._ttn_data_storage.async_update() + self._state = self._ttn_data_storage.data + + +class TtnDataStorage(object): + """Get the latest data from The Things Network Data Storage.""" + + def __init__(self, hass, app_id, device_id, access_key, values): + """Initialize the data object.""" + self.data = None + self._hass = hass + self._app_id = app_id + self._device_id = device_id + self._values = values + self._url = TTN_DATA_STORAGE_URL.format( + app_id=app_id, endpoint='api/v2/query', device_id=device_id) + self._headers = { + 'Accept': CONTENT_TYPE_JSON, + 'Authorization': 'key {}'.format(access_key), + } + + @asyncio.coroutine + def async_update(self): + """Get the current state from The Things Network Data Storage.""" + try: + session = async_get_clientsession(self._hass) + with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self._hass.loop): + req = yield from session.get(self._url, headers=self._headers) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Error while accessing: %s", self._url) + return False + + status = req.status + + if status == 204: + _LOGGER.error("The device is not available: %s", self._device_id) + return False + + if status == 401: + _LOGGER.error( + "Not authorized for Application ID: %s", self._app_id) + return False + + if status == 404: + _LOGGER.error("Application ID is not available: %s", self._app_id) + return False + + data = yield from req.json() + self.data = data[0] + + for value in self._values.items(): + if value[0] not in self.data.keys(): + _LOGGER.warning("Value not available: %s", value[0]) + + return req diff --git a/homeassistant/components/thethingsnetwork.py b/homeassistant/components/thethingsnetwork.py new file mode 100644 index 00000000000..08715c74d1f --- /dev/null +++ b/homeassistant/components/thethingsnetwork.py @@ -0,0 +1,47 @@ +""" +Support for The Things network. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/thethingsnetwork/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_ACCESS_KEY = 'access_key' +CONF_APP_ID = 'app_id' + +DATA_TTN = 'data_thethingsnetwork' +DOMAIN = 'thethingsnetwork' + +TTN_ACCESS_KEY = 'ttn_access_key' +TTN_APP_ID = 'ttn_app_id' +TTN_DATA_STORAGE_URL = \ + 'https://{app_id}.data.thethingsnetwork.org/{endpoint}/{device_id}' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_APP_ID): cv.string, + vol.Required(CONF_ACCESS_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize of The Things Network component.""" + conf = config[DOMAIN] + app_id = conf.get(CONF_APP_ID) + access_key = conf.get(CONF_ACCESS_KEY) + + hass.data[DATA_TTN] = { + TTN_ACCESS_KEY: access_key, + TTN_APP_ID: app_id, + } + + return True From 6627c352e63ac24b0c7e61d86359cd7c19d17a19 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Oct 2017 09:17:26 -0700 Subject: [PATCH 060/113] Update frontend --- homeassistant/components/frontend/version.py | 6 +- .../frontend/www_static/frontend.html | 23 +- .../frontend/www_static/frontend.html.gz | Bin 168665 -> 172521 bytes .../www_static/home-assistant-polymer | 2 +- .../components/frontend/www_static/mdi.html | 2 +- .../frontend/www_static/mdi.html.gz | Bin 208182 -> 211310 bytes .../www_static/panels/ha-panel-config.html | 4 +- .../www_static/panels/ha-panel-config.html.gz | Bin 34594 -> 35106 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5138 -> 5136 bytes .../frontend/www_static/webcomponents-lite.js | 339 +++++++++--------- .../www_static/webcomponents-lite.js.gz | Bin 26084 -> 26556 bytes 12 files changed, 191 insertions(+), 187 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index b5edb751d50..052bd7e86fe 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "7e13ce36d3141182a62a5b061e87e77a", - "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", + "frontend.html": "2de1bde3b4a6c6c47dd95504fc098906", + "mdi.html": "2e848b4da029bf73d426d5ba058a088d", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "61f65e75e39368e07441d7d6a4e36ae3", + "panels/ha-panel-config.html": "52e2e1d477bfd6dc3708d65b8337f0af", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 60713690c44..c873d66777e 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;}