From fee922c4be32a2cae6c00afb86d083221c8fe05e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 21 Sep 2017 21:19:04 -0700 Subject: [PATCH 01/94] Version bump to 0.55.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a92f0d68c9..b6937e9a0a6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 54 +MINOR_VERSION = 55 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From e980ced0b74bb283543a7dcef58265da292f72b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 22 Sep 2017 10:39:53 +0200 Subject: [PATCH 02/94] flux led lib 0.20 (#9533) --- homeassistant/components/light/flux_led.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 209c3ab7724..95f13cad860 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['flux_led==0.19'] +REQUIREMENTS = ['flux_led==0.20'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d87abe0de66..f3e3de0eacd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ fitbit==0.3.0 fixerio==0.1.1 # homeassistant.components.light.flux_led -flux_led==0.19 +flux_led==0.20 # homeassistant.components.notify.free_mobile freesms==0.1.1 From 5e35beb41a1d75eed051298fa496aa39c130fc53 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 03/94] 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 f3e3de0eacd..8bafee12f94 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 e7c08921ebe95ba1d21f425428b4b0f0705c1fac Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Fri, 22 Sep 2017 22:00:35 -0400 Subject: [PATCH 04/94] 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 8bafee12f94..7ce8cc5ae9d 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 a8784f9adf08c88d56677d691e7b3aa7ac38d3b6 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Sat, 23 Sep 2017 00:53:16 -0400 Subject: [PATCH 05/94] 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 7ce8cc5ae9d..538644f6c28 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 3704a18da53b7c9426c939ea651ac8b572e8e45e Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 23 Sep 2017 15:53:48 +0200 Subject: [PATCH 06/94] Bugfix Homematic hub object (#9544) * Bugfix Homematic hub object * fix hass instance * fix state unknow if 0 states --- homeassistant/components/homematic.py | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index 621772e6e1a..e2d34ca897e 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import track_time_interval from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['pyhomematic==0.1.32'] @@ -292,7 +292,7 @@ def setup(hass, config): entity_hubs = [] for _, hub_data in hosts.items(): entity_hubs.append(HMHub( - homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + hass, homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -571,8 +571,9 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, homematic, name, use_variables): + def __init__(self, hass, homematic, name, use_variables): """Initialize HomeMatic hub.""" + self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) self._homematic = homematic self._variables = {} @@ -580,18 +581,15 @@ class HMHub(Entity): self._state = STATE_UNKNOWN self._use_variables = use_variables - @asyncio.coroutine - def async_added_to_hass(self): - """Load data init callbacks.""" # Load data - async_track_time_interval( + track_time_interval( self.hass, self._update_hub, SCAN_INTERVAL_HUB) - yield from self.hass.async_add_job(self._update_hub, None) + self.hass.add_job(self._update_hub, None) if self._use_variables: - async_track_time_interval( + track_time_interval( self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - yield from self.hass.async_add_job(self._update_variables, None) + self.hass.add_job(self._update_variables, None) @property def name(self): @@ -621,10 +619,12 @@ class HMHub(Entity): def _update_hub(self, now): """Retrieve latest state.""" - state = self._homematic.getServiceMessages(self._name) - self._state = STATE_UNKNOWN if state is None else len(state) + service_message = self._homematic.getServiceMessages(self._name) + state = None if service_message is None else len(service_message) - if now: + # state have change? + if self._state != state: + self._state = state self.schedule_update_ha_state() def _update_variables(self, now): @@ -641,7 +641,7 @@ class HMHub(Entity): state_change = True self._variables.update({key: value}) - if state_change and now: + if state_change: self.schedule_update_ha_state() def hm_set_variable(self, name, value): From 08b0629eca6417a2bb1b6f3c805ff19208484e4d Mon Sep 17 00:00:00 2001 From: Michael Prokop Date: Sat, 23 Sep 2017 17:15:46 +0200 Subject: [PATCH 07/94] Fix a bunch of typos (#9545) s/Addres /Address / s/Chnage/Change/ s/Converion/Conversion/ s/Supressing/Suppressing/ s/agains /against / s/allready/already/ s/analagous/analogous/ s/aquired/acquired/ s/arbitray/arbitrary/ s/argment/argument/ s/aroung/around/ s/attibute/attribute/ s/auxillary/auxiliary/ s/befor /before / s/commmand/command/ s/conatin/contain/ s/conection/connection/ s/coresponding/corresponding/ s/entites/entities/ s/enviroment/environment/ s/everyhing/everything/ s/expected expected/expected/ s/explicity/explicitly/ s/formated/formatted/ s/incomming/incoming/ s/informations/information/ s/inital/initial/ s/inteface/interface/ s/interupt/interrupt/ s/mimick/mimic/ s/mulitple/multiple/ s/multible/multiple/ s/occured/occurred/ s/occuring/occurring/ s/overrided/overridden/ s/overriden/overridden/ s/platfrom/platform/ s/positon/position/ s/progess/progress/ s/recieved/received/ s/reciever/receiver/ s/recieving/receiving/ s/reponse/response/ s/representaion/representation/ s/resgister/register/ s/retrive/retrieve/ s/reuqests/requests/ s/segements/segments/ s/seperated/separated/ s/sheduled/scheduled/ s/succesfully/successfully/ s/suppport/support/ s/targetting/targeting/ s/thats/that's/ s/the the/the/ s/unkown/unknown/ s/verison/version/ s/while loggin out/while logging out/ --- .../alarm_control_panel/concord232.py | 2 +- homeassistant/components/alexa/smart_home.py | 8 ++++---- .../components/binary_sensor/insteon_plm.py | 4 ++-- homeassistant/components/calendar/todoist.py | 2 +- homeassistant/components/camera/amcrest.py | 2 +- homeassistant/components/camera/blink.py | 2 +- homeassistant/components/camera/foscam.py | 2 +- homeassistant/components/climate/__init__.py | 10 +++++----- homeassistant/components/climate/demo.py | 4 ++-- .../components/climate/services.yaml | 2 +- homeassistant/components/climate/wink.py | 4 ++-- .../components/device_tracker/icloud.py | 2 +- .../components/device_tracker/snmp.py | 2 +- .../components/device_tracker/xiaomi.py | 2 +- homeassistant/components/downloader.py | 2 +- .../components/emulated_hue/__init__.py | 2 +- homeassistant/components/emulated_hue/upnp.py | 6 +++--- homeassistant/components/fan/insteon_local.py | 2 +- .../image_processing/seven_segments.py | 2 +- homeassistant/components/knx.py | 2 +- homeassistant/components/light/hue.py | 2 +- .../components/light/insteon_local.py | 2 +- homeassistant/components/light/insteon_plm.py | 4 ++-- homeassistant/components/light/rflink.py | 2 +- homeassistant/components/light/tellstick.py | 2 +- homeassistant/components/lock/services.yaml | 2 +- .../components/media_player/__init__.py | 4 ++-- homeassistant/components/media_player/cast.py | 2 +- .../components/media_player/directv.py | 4 ++-- .../components/media_player/openhome.py | 2 +- .../components/media_player/philips_js.py | 4 ++-- homeassistant/components/media_player/plex.py | 2 +- .../components/media_player/russound_rnet.py | 2 +- .../components/media_player/services.yaml | 20 +++++++++---------- .../components/media_player/sonos.py | 6 +++--- .../components/media_player/universal.py | 2 +- homeassistant/components/media_player/vlc.py | 2 +- .../components/media_player/yamaha.py | 2 +- homeassistant/components/notify/apns.py | 2 +- homeassistant/components/notify/kodi.py | 2 +- .../components/recorder/migration.py | 4 ++-- homeassistant/components/sensor/dsmr.py | 4 ++-- homeassistant/components/sensor/envirophat.py | 2 +- homeassistant/components/sensor/modbus.py | 2 +- .../components/sensor/wunderground.py | 2 +- homeassistant/components/services.yaml | 10 +++++----- homeassistant/components/sleepiq.py | 2 +- .../components/switch/acer_projector.py | 2 +- .../components/switch/android_ip_webcam.py | 2 +- .../components/switch/insteon_local.py | 2 +- .../components/switch/insteon_plm.py | 4 ++-- homeassistant/components/switch/services.yaml | 2 +- homeassistant/components/switch/tellstick.py | 2 +- homeassistant/components/tellduslive.py | 5 +++-- homeassistant/components/tellstick.py | 2 +- homeassistant/components/vacuum/roomba.py | 2 +- homeassistant/components/zha/__init__.py | 2 +- homeassistant/core.py | 6 +++--- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/script.py | 2 +- homeassistant/scripts/influxdb_migrator.py | 4 ++-- homeassistant/util/__init__.py | 2 +- homeassistant/util/color.py | 4 ++-- homeassistant/util/location.py | 2 +- script/test_docker | 2 +- tests/common.py | 2 +- tests/components/binary_sensor/test_aurora.py | 2 +- tests/components/camera/test_init.py | 2 +- tests/components/climate/test_demo.py | 4 ++-- tests/components/cloud/test_http_api.py | 10 +++++----- tests/components/emulated_hue/test_init.py | 2 +- .../components/image_processing/test_init.py | 4 ++-- tests/components/light/test_mochad.py | 2 +- tests/components/media_player/test_yamaha.py | 4 ++-- tests/components/sensor/test_mfi.py | 2 +- tests/components/switch/test_mochad.py | 2 +- tests/components/switch/test_rflink.py | 2 +- tests/components/test_influxdb.py | 2 +- tests/components/test_init.py | 2 +- tests/components/test_logbook.py | 2 +- tests/helpers/test_entity_component.py | 6 +++--- tests/util/test_yaml.py | 8 ++++---- 82 files changed, 134 insertions(+), 133 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index df815424ee9..291d4bc80b5 100755 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -107,7 +107,7 @@ class Concord232Alarm(alarm.AlarmControlPanel): newstate = STATE_ALARM_ARMED_AWAY if not newstate == self._state: - _LOGGER.info("State Chnage from %s to %s", self._state, newstate) + _LOGGER.info("State Change from %s to %s", self._state, newstate) self._state = newstate return self._state diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index aa4b1cbec70..ae1ecb87f60 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -43,7 +43,7 @@ def mapping_api_function(name): @asyncio.coroutine def async_handle_message(hass, message): - """Handle incomming API messages.""" + """Handle incoming API messages.""" assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 # Do we support this API request? @@ -57,7 +57,7 @@ def async_handle_message(hass, message): def api_message(name, namespace, payload=None): - """Create a API formated response message. + """Create a API formatted response message. Async friendly. """ @@ -74,7 +74,7 @@ def api_message(name, namespace, payload=None): def api_error(request, exc='DriverInternalError'): - """Create a API formated error response. + """Create a API formatted error response. Async friendly. """ @@ -83,7 +83,7 @@ def api_error(request, exc='DriverInternalError'): @asyncio.coroutine def async_api_discovery(hass, request): - """Create a API formated discovery response. + """Create a API formatted discovery response. Async friendly. """ diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 448ceae8636..0702ce8bb9e 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -55,12 +55,12 @@ class InsteonPLMBinarySensorDevice(BinarySensorDevice): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index ae9a1c9afa8..eb9f0a2677e 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -277,7 +277,7 @@ class TodoistProjectData(object): """ Class used by the Task Device service object to hold all Todoist Tasks. - This is analagous to the GoogleCalendarData found in the Google Calendar + This is analogous to the GoogleCalendarData found in the Google Calendar component. Takes an object with a 'name' field and optionally an 'id' field (either diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 51b8ff13906..aba1bb08c93 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -62,7 +62,7 @@ class AmcrestCam(Camera): self._token = self._auth = authentication def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data response = self._camera.snapshot(channel=self._resolution) return response.data diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py index bca4fafec4f..4b708817cfd 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -76,6 +76,6 @@ class BlinkCamera(Camera): return self.data.camera_thumbs[self._name] def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" self.request_image() return self.response.content diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 3f2761e332a..3cc391eae33 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -59,7 +59,7 @@ class FoscamCam(Camera): self._password, verbose=False) def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed result, response = self._foscam_session.snap_picture_2() diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 1f919301254..8ccc3b2d663 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -147,7 +147,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None): @bind_hass def set_aux_heat(hass, aux_heat, entity_id=None): - """Turn all or specified climate devices auxillary heater on.""" + """Turn all or specified climate devices auxiliary heater on.""" data = { ATTR_AUX_HEAT: aux_heat } @@ -661,22 +661,22 @@ class ClimateDevice(Entity): return self.hass.async_add_job(self.set_hold_mode, hold_mode) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" raise NotImplementedError() def async_turn_aux_heat_on(self): - """Turn auxillary heater on. + """Turn auxiliary heater on. This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job(self.turn_aux_heat_on) def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" raise NotImplementedError() def async_turn_aux_heat_off(self): - """Turn auxillary heater off. + """Turn auxiliary heater off. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 24b40af7eb1..0880cb3db8f 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -183,11 +183,11 @@ class DemoClimate(ClimateDevice): self.schedule_update_ha_state() def turn_aux_heat_on(self): - """Turn away auxillary heater on.""" + """Turn away auxiliary heater on.""" self._aux = True self.schedule_update_ha_state() def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" self._aux = False self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 4aebb1c85c9..92d821ebbaf 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,5 +1,5 @@ set_aux_heat: - description: Turn auxillary heater on/off for climate device + description: Turn auxiliary heater on/off for climate device fields: entity_id: diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f52340dc627..90b101e1b7b 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -281,11 +281,11 @@ class WinkThermostat(WinkDevice, ClimateDevice): self.wink.set_fan_mode(fan.lower()) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" self.set_operation_mode(STATE_AUX) def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" self.set_operation_mode(STATE_AUTO) @property diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index e670287dd87..472b48fef6e 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -248,7 +248,7 @@ class Icloud(DeviceScanner): self._trusted_device, self._verification_code): raise PyiCloudException('Unknown failure') except PyiCloudException as error: - # Reset to the inital 2FA state to allow the user to retry + # Reset to the initial 2FA state to allow the user to retry _LOGGER.error("Failed to verify verification code: %s", error) self._trusted_device = None self._verification_code = None diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 3efae2b9ce2..25176cd82d0 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -75,7 +75,7 @@ class SnmpScanner(DeviceScanner): return [client['mac'] for client in self.last_results if client.get('mac')] - # Supressing no-self-use warning + # Suppressing no-self-use warning # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index 8b8db3da2d8..12e64b724dd 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -69,7 +69,7 @@ class XiaomiDeviceScanner(DeviceScanner): return self.mac2name.get(device.upper(), None) def _update_info(self): - """Ensure the informations from the router are up to date. + """Ensure the information from the router are up to date. Returns true if scanning successful. """ diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 2e26b306673..0450ba175ee 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -122,7 +122,7 @@ def setup(hass, config): _LOGGER.info("Downloading of %s done", url) except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occured for %s", url) + _LOGGER.exception("ConnectionError occurred for %s", url) # Remove file if we started downloading but failed if final_path and os.path.isfile(final_path): diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ca056398d2b..2feea724cb7 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -148,7 +148,7 @@ class Config(object): self.listen_port) if self.type == TYPE_GOOGLE and self.listen_port != 80: - _LOGGER.warning("When targetting Google Home, listening port has " + _LOGGER.warning("When targeting Google Home, listening port has " "to be port 80") # Get whether or not UPNP binds to multicast address (239.255.255.250) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 42a258cbf4b..548b6f3d771 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,4 +1,4 @@ -"""Provides a UPNP discovery method that mimicks Hue hubs.""" +"""Provides a UPNP discovery method that mimics Hue hubs.""" import threading import socket import logging @@ -123,14 +123,14 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 if ssdp_socket in read: data, addr = ssdp_socket.recvfrom(1024) else: - # most likely the timeout, so check for interupt + # most likely the timeout, so check for interrupt continue except socket.error as ex: if self._interrupted: clean_socket_close(ssdp_socket) return - _LOGGER.error("UPNP Responder socket exception occured: %s", + _LOGGER.error("UPNP Responder socket exception occurred: %s", ex.__str__) # without the following continue, a second exception occurs # because the data object has not been initialized diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index 5bdfec08427..e12e3476c3a 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -137,7 +137,7 @@ class InsteonLocalFanDevice(FanEntity): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/image_processing/seven_segments.py b/homeassistant/components/image_processing/seven_segments.py index d91f4666046..60b2eadee92 100644 --- a/homeassistant/components/image_processing/seven_segments.py +++ b/homeassistant/components/image_processing/seven_segments.py @@ -1,5 +1,5 @@ """ -Local optical character recognition processing of seven segements displays. +Local optical character recognition processing of seven segments displays. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.seven_segments/ diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 047620860b9..4b976e6ca3f 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -217,7 +217,7 @@ class KNXModule(object): @asyncio.coroutine def service_send_to_knx_bus(self, call): - """Service for sending an arbitray KNX message to the KNX bus.""" + """Service for sending an arbitrary KNX message to the KNX bus.""" from xknx.knx import Telegram, Address, DPTBinary, DPTArray attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 79d80d2b8a0..d4e650f2ba5 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -206,7 +206,7 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, if not skip_groups: # Group ID 0 is a special group in the hub for all lights, but it - # is not returned by get_api() so explicity get it and include it. + # is not returned by get_api() so explicitly get it and include it. # See https://developers.meethue.com/documentation/ # groups-api#21_get_all_groups _LOGGER.debug("Getting group 0 from bridge") diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index ebd6ab92d0f..8917a9e9ccf 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -134,7 +134,7 @@ class InsteonLocalDimmerDevice(Light): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index 3b3dd43f496..51de9f03df5 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -60,12 +60,12 @@ class InsteonPLMDimmerDevice(Light): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index 0b56f1de0ac..4308be107dd 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -123,7 +123,7 @@ def devices_from_config(domain_config, hass=None): _LOGGER.warning( "Hybrid type for %s not compatible with signal " "repetitions. Please set 'dimmable' or 'switchable' " - "type explicity in configuration", device_id) + "type explicitly in configuration", device_id) device = entity_class(device_id, hass, **device_config) devices.append(device) diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 98af61ffb7d..598cd22c986 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -56,7 +56,7 @@ class TellstickLight(TellstickDevice, Light): return kwargs.get(ATTR_BRIGHTNESS) def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" + """Turn the value received from tellcore into something useful.""" if tellcore_data is not None: brightness = int(tellcore_data) return brightness diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 04e9f458f9c..3fde6a2d8ad 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -17,7 +17,7 @@ get_usercode: description: Node id of the lock example: 18 code_slot: - description: Code slot to retrive a code from + description: Code slot to retrieve a code from example: 1 nuki_lock_n_go: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 870252cc55e..2ff957186ba 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -637,11 +637,11 @@ class MediaPlayerDevice(Entity): return self.hass.async_add_job(self.set_volume_level, volume) def media_play(self): - """Send play commmand.""" + """Send play command.""" raise NotImplementedError() def async_media_play(self): - """Send play commmand. + """Send play command. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 780bd0e31ad..2aebbac5043 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -287,7 +287,7 @@ class CastDevice(MediaPlayerDevice): self.cast.set_volume(volume) def media_play(self): - """Send play commmand.""" + """Send play command.""" self.cast.media_controller.play() def media_pause(self): diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index a334dc7caa4..a10b5cd8a25 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -1,5 +1,5 @@ """ -Support for the DirecTV recievers. +Support for the DirecTV receivers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.directv/ @@ -82,7 +82,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DirecTvDevice(MediaPlayerDevice): - """Representation of a DirecTV reciever on the network.""" + """Representation of a DirecTV receiver on the network.""" def __init__(self, name, host, port, device): """Initialize the device.""" diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index b2242bfecad..bca6f2ad770 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -124,7 +124,7 @@ class OpenhomeDevice(MediaPlayerDevice): self._device.Stop() def media_play(self): - """Send play commmand.""" + """Send play command.""" self._device.Play() def media_next_track(self): diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index da572896ee0..d8450d31ea4 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -151,11 +151,11 @@ class PhilipsTV(MediaPlayerDevice): self._state = STATE_OFF def media_previous_track(self): - """Send rewind commmand.""" + """Send rewind command.""" self._tv.sendKey('Previous') def media_next_track(self): - """Send fast forward commmand.""" + """Send fast forward command.""" self._tv.sendKey('Next') @property diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a901cd1d569..54ec61b50f8 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -711,7 +711,7 @@ class PlexClient(MediaPlayerDevice): if ("127.0.0.1" in client.baseurl and client.machineIdentifier == self.device.machineIdentifier): # point controls to server since that's where the - # playback is occuring + # playback is occurring _LOGGER.debug( "Local client detected, redirecting controls to " "Plex server: %s", self.entity_id) diff --git a/homeassistant/components/media_player/russound_rnet.py b/homeassistant/components/media_player/russound_rnet.py index 9ce3dcfc4f4..77a9939c36c 100644 --- a/homeassistant/components/media_player/russound_rnet.py +++ b/homeassistant/components/media_player/russound_rnet.py @@ -135,7 +135,7 @@ class RussoundRNETDevice(MediaPlayerDevice): def set_volume_level(self, volume): """Set volume level. Volume has a range (0..1). - Translate this to a range of (0..100) as expected expected + Translate this to a range of (0..100) as expected by _russ.set_volume() """ self._russ.set_volume('1', self._zone_id, volume * 100) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 2cf3617cc61..993863ea725 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -140,7 +140,7 @@ select_source: fields: entity_id: - description: Name(s) of entites to change source on + description: Name(s) of entities to change source on example: 'media_player.media_player.txnr535_0009b0d81f82' source: description: Name of the source to switch to. Platform dependent. @@ -151,7 +151,7 @@ clear_playlist: fields: entity_id: - description: Name(s) of entites to change source on + description: Name(s) of entities to change source on example: 'media_player.living_room_chromecast' shuffle_set: @@ -170,7 +170,7 @@ snapcast_snapshot: fields: entity_id: - description: Name(s) of entites that will be snapshotted. Platform dependent. + description: Name(s) of entities that will be snapshotted. Platform dependent. example: 'media_player.living_room' snapcast_restore: @@ -178,7 +178,7 @@ snapcast_restore: fields: entity_id: - description: Name(s) of entites that will be restored. Platform dependent. + description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room' sonos_join: @@ -190,7 +190,7 @@ sonos_join: example: 'media_player.living_room_sonos' entity_id: - description: Name(s) of entites that will coordinate the grouping. Platform dependent. + description: Name(s) of entities that will coordinate the grouping. Platform dependent. example: 'media_player.living_room_sonos' sonos_unjoin: @@ -198,7 +198,7 @@ sonos_unjoin: fields: entity_id: - description: Name(s) of entites that will be unjoined from their group. Platform dependent. + description: Name(s) of entities that will be unjoined from their group. Platform dependent. example: 'media_player.living_room_sonos' sonos_snapshot: @@ -206,7 +206,7 @@ sonos_snapshot: fields: entity_id: - description: Name(s) of entites that will be snapshot. Platform dependent. + description: Name(s) of entities that will be snapshot. Platform dependent. example: 'media_player.living_room_sonos' with_group: @@ -218,7 +218,7 @@ sonos_restore: fields: entity_id: - description: Name(s) of entites that will be restored. Platform dependent. + description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room_sonos' with_group: @@ -230,7 +230,7 @@ sonos_set_sleep_timer: fields: entity_id: - description: Name(s) of entites that will have a timer set. + description: Name(s) of entities that will have a timer set. example: 'media_player.living_room_sonos' sleep_time: description: Number of seconds to set the timer @@ -241,7 +241,7 @@ sonos_clear_sleep_timer: fields: entity_id: - description: Name(s) of entites that will have the timer cleared. + description: Name(s) of entities that will have the timer cleared. example: 'media_player.living_room_sonos' diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index a5ef91ecc87..410728dafaa 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -915,8 +915,8 @@ class SonosDevice(MediaPlayerDevice): """Replace queue with playlist represented by src. Playlists can't be played directly with the self._player.play_uri - API as they are actually composed of mulitple URLs. Until soco has - suppport for playing a playlist, we'll need to parse the playlist item + API as they are actually composed of multiple URLs. Until soco has + support for playing a playlist, we'll need to parse the playlist item and replace the current queue in order to play it. """ import soco @@ -1116,7 +1116,7 @@ class SonosDevice(MediaPlayerDevice): return ## - # old is allready master, rejoin + # old is already master, rejoin if old.coordinator.group.coordinator == old.coordinator: self._player.join(old.coordinator) return diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index b79c708c33c..9647f04f5c3 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -441,7 +441,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): SERVICE_VOLUME_SET, data, allow_override=True) def async_media_play(self): - """Send play commmand. + """Send play command. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/media_player/vlc.py b/homeassistant/components/media_player/vlc.py index f77b06054e1..d3346495015 100644 --- a/homeassistant/components/media_player/vlc.py +++ b/homeassistant/components/media_player/vlc.py @@ -137,7 +137,7 @@ class VlcDevice(MediaPlayerDevice): self._volume = volume def media_play(self): - """Send play commmand.""" + """Send play command.""" self._vlc.play() self._state = STATE_PLAYING diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index f2e64b1fb25..c413bfd3357 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -214,7 +214,7 @@ class YamahaDevice(MediaPlayerDevice): self._volume = (self._receiver.volume / 100) + 1 def media_play(self): - """Send play commmand.""" + """Send play command.""" self._call_playback_function(self._receiver.play, "play") def media_pause(self): diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 136d5300183..250ef5c50c8 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -111,7 +111,7 @@ class ApnsDevice(object): return self.device_disabled def disable(self): - """Disable the device from recieving notifications.""" + """Disable the device from receiving notifications.""" self.device_disabled = True def __eq__(self, other): diff --git a/homeassistant/components/notify/kodi.py b/homeassistant/components/notify/kodi.py index eda01c13086..05f4c5d17f3 100644 --- a/homeassistant/components/notify/kodi.py +++ b/homeassistant/components/notify/kodi.py @@ -53,7 +53,7 @@ def async_get_service(hass, config, discovery_info=None): if host.startswith('http://') or host.startswith('https://'): host = host.lstrip('http://').lstrip('https://') _LOGGER.warning( - "Kodi host name should no longer conatin http:// See updated " + "Kodi host name should no longer contain http:// See updated " "definitions here: " "https://home-assistant.io/components/media_player.kodi/") diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 5a68fe43fe0..325267b857e 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -47,7 +47,7 @@ def _create_index(engine, table_name, index_name): table = Table(table_name, models.Base.metadata) _LOGGER.debug("Looking up index for table %s", table_name) - # Look up the index object by name from the table is the the models + # Look up the index object by name from the table is the models index = next(idx for idx in table.indexes if idx.name == index_name) _LOGGER.debug("Creating %s index", index_name) _LOGGER.info("Adding index `%s` to database. Note: this can take several " @@ -151,7 +151,7 @@ def _apply_update(engine, new_version, old_version): def _inspect_schema_version(engine, session): """Determine the schema version by inspecting the db structure. - When the schema verison is not present in the db, either db was just + When the schema version is not present in the db, either db was just created with the correct schema, or this is a db created before schema versions were tracked. For now, we'll test if the changes for schema version 1 are present to make the determination. Eventually this logic diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 76fde35410d..4f360e860bd 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -155,7 +155,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): stop_listerer() # reflect disconnect state in devices state by setting an - # empty telegram resulting in `unkown` states + # empty telegram resulting in `unknown` states update_entities_telegram({}) # throttle reconnect attempts @@ -181,7 +181,7 @@ class DSMREntity(Entity): if self._obis not in self.telegram: return None - # get the attibute value if the object has it + # get the attribute value if the object has it dsmr_object = self.telegram[self._obis] return getattr(dsmr_object, attribute, None) diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index f2db833954f..ce5e2a81939 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -171,7 +171,7 @@ class EnvirophatData(object): self.light = self.envirophat.light.light() if self.use_leds: self.envirophat.leds.on() - # the three color values scaled agains the overall light, 0-255 + # the three color values scaled against the overall light, 0-255 self.light_red, self.light_green, self.light_blue = \ self.envirophat.light.rgb() if self.use_leds: diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index 9453daea413..0b2198bd396 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ModbusRegisterSensor(Entity): - """Modbus resgister sensor.""" + """Modbus register sensor.""" def __init__(self, name, slave, register, register_type, unit_of_measurement, count, scale, offset, data_type, diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 8f9a5ef1862..b68ef67bf37 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -139,7 +139,7 @@ class WUDailySimpleForecastSensorConfig(WUSensorConfig): wu_unit (string): "fahrenheit", "celsius", "degrees" etc. see the example json at: https://www.wunderground.com/weather/api/d/docs?d=data/forecast&MR=1 - ha_unit (string): coresponding unit in home assistant + ha_unit (string): corresponding unit in home assistant title (string): friendly_name of the sensor """ super().__init__( diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 865a6c7df58..0c9f1daf70f 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -162,7 +162,7 @@ homematic: example: 1 set_dev_value: - description: Set a device property on RPC XML inteface. + description: Set a device property on RPC XML interface. fields: address: @@ -333,7 +333,7 @@ hdmi_cec: description: Select HDMI device. fields: device: - description: Addres of device to select. Can be entity_id, physical address or alias from confuguration. + description: Address of device to select. Can be entity_id, physical address or alias from confuguration. example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"' power_on: @@ -347,21 +347,21 @@ ffmpeg: description: Send a start command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entites that will start. Platform dependent. + description: Name(s) of entities that will start. Platform dependent. example: 'binary_sensor.ffmpeg_noise' stop: description: Send a stop command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entites that will stop. Platform dependent. + description: Name(s) of entities that will stop. Platform dependent. example: 'binary_sensor.ffmpeg_noise' restart: description: Send a restart command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entites that will restart. Platform dependent. + description: Name(s) of entities that will restart. Platform dependent. example: 'binary_sensor.ffmpeg_noise' logger: diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py index d9d81d3fee0..baf6d154c66 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -117,7 +117,7 @@ class SleepIQSensor(Entity): def update(self): """Get the latest data from SleepIQ and updates the states.""" # Call the API for new sleepiq data. Each sensor will re-trigger this - # same exact call, but thats fine. We cache results for a short period + # same exact call, but that's fine. We cache results for a short period # of time to prevent hitting API limits. self.sleepiq_data.update() diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index f32829b0633..58361b2e8b2 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -109,7 +109,7 @@ class AcerSwitch(SwitchDevice): def _write_read_format(self, msg): """Write msg, obtain awnser and format output.""" - # awnsers are formated as ***\rawnser\r*** + # awnsers are formatted as ***\rawnser\r*** awns = self._write_read(msg) match = re.search(r'\r(.+)\r', awns) if match: diff --git a/homeassistant/components/switch/android_ip_webcam.py b/homeassistant/components/switch/android_ip_webcam.py index df86b3fbb8f..8de2ce593af 100644 --- a/homeassistant/components/switch/android_ip_webcam.py +++ b/homeassistant/components/switch/android_ip_webcam.py @@ -47,7 +47,7 @@ class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @asyncio.coroutine diff --git a/homeassistant/components/switch/insteon_local.py b/homeassistant/components/switch/insteon_local.py index 94259b8bb80..674a20278b3 100644 --- a/homeassistant/components/switch/insteon_local.py +++ b/homeassistant/components/switch/insteon_local.py @@ -130,7 +130,7 @@ class InsteonLocalSwitchDevice(SwitchDevice): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index ee192b82be4..ed7d0ffc479 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -55,12 +55,12 @@ class InsteonPLMSwitchDevice(SwitchDevice): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index 00b2abb91a4..5fdd8142ffc 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -29,7 +29,7 @@ mysensors_send_ir_code: fields: entity_id: - description: Name(s) of entites that should have the IR code set and be turned on. Platform dependent. + description: Name(s) of entities that should have the IR code set and be turned on. Platform dependent. example: 'switch.living_room_1_1' V_IR_SEND: diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index c631eedc050..de7a3bf4545 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -39,7 +39,7 @@ class TellstickSwitch(TellstickDevice, ToggleEntity): return None def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" + """Turn the value received from tellcore into something useful.""" return None def _update_model(self, new_state, data): diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 01ccb981cfa..1f2b3720062 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -53,7 +53,8 @@ def setup(hass, config): if not client.validate_session(): _LOGGER.error( "Authentication Error: Please make sure you have configured your " - "keys that can be aquired from https://api.telldus.com/keys/index") + "keys that can be acquired from " + "https://api.telldus.com/keys/index") return False hass.data[DOMAIN] = client @@ -173,7 +174,7 @@ class TelldusLiveEntity(Entity): @property def device(self): - """Return the representaion of the device.""" + """Return the representation of the device.""" return self._client.device(self.device_id) @property diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 5d0ec78dfa7..6ae96b88da7 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -192,7 +192,7 @@ class TellstickDevice(Entity): raise NotImplementedError def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" + """Turn the value received from tellcore into something useful.""" raise NotImplementedError def _update_model(self, new_state, data): diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 37cd9d06785..500b98420fc 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -310,7 +310,7 @@ class RoombaVacuum(VacuumDevice): if error_msg and error_msg != 'None': self._state_attrs[ATTR_ERROR] = error_msg - # Not all Roombas expose positon data + # Not all Roombas expose position data # https://github.com/koalazak/dorita980/issues/48 if self._capabilities[CAP_POSITION]: pos_state = state.get('pose', {}) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1b2d46ee72b..55fb0e41cb2 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -218,7 +218,7 @@ class ApplicationListener: class Entity(entity.Entity): """A base class for ZHA entities.""" - _domain = None # Must be overriden by subclasses + _domain = None # Must be overridden by subclasses def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, model, **kwargs): diff --git a/homeassistant/core.py b/homeassistant/core.py index 187dfcf1b83..a8704869f21 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -218,7 +218,7 @@ class HomeAssistant(object): else: task = self.loop.run_in_executor(None, target, *args) - # If a task is sheduled + # If a task is scheduled if self._track_task and task is not None: self._pending_tasks.append(task) @@ -914,7 +914,7 @@ class ServiceRegistry(object): Waits a maximum of SERVICE_CALL_LIMIT. If blocking = True, will return boolean if service executed - succesfully within SERVICE_CALL_LIMIT. + successfully within SERVICE_CALL_LIMIT. This method will fire an event to call the service. This event will be picked up by this ServiceRegistry and any @@ -937,7 +937,7 @@ class ServiceRegistry(object): Waits a maximum of SERVICE_CALL_LIMIT. If blocking = True, will return boolean if service executed - succesfully within SERVICE_CALL_LIMIT. + successfully within SERVICE_CALL_LIMIT. This method will fire an event to call the service. This event will be picked up by this ServiceRegistry and any diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 835b616987c..b2928e73070 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -71,7 +71,7 @@ class Entity(object): # If we reported if this entity was slow _slow_reported = False - # protect for multible updates + # protect for multiple updates _update_warn = None @property diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index b44905a3141..bafaf4d0fdb 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -117,7 +117,7 @@ class Script(): wait_template = action[CONF_WAIT_TEMPLATE] wait_template.hass = self.hass - # check if condition allready okay + # check if condition already okay if condition.async_template( self.hass, wait_template, variables): continue diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py index 6f130d18757..cad8f878ca6 100644 --- a/homeassistant/scripts/influxdb_migrator.py +++ b/homeassistant/scripts/influxdb_migrator.py @@ -104,7 +104,7 @@ def run(script_args: List) -> int: for index, measurement in enumerate(measurements): client.query('''SELECT * INTO {}..:MEASUREMENT FROM ''' '"{}" GROUP BY *'.format(old_dbname, measurement)) - # Print progess + # Print progress print_progress(index + 1, nb_measurements) # Delete the database @@ -184,7 +184,7 @@ def run(script_args: List) -> int: else: # Increment offset offset += args.step - # Print progess + # Print progress print_progress(index + 1, nb_measurements) # Delete database if needed diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 824f3969b2c..646edcf1c35 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -268,7 +268,7 @@ class Throttle(object): # We want to be able to differentiate between function and unbound # methods (which are considered functions). - # All methods have the classname in their qualname seperated by a '.' + # All methods have the classname in their qualname separated by a '.' # Functions have a '.' in their qualname if defined inline, but will # be prefixed by '..' so we strip that out. is_func = (not hasattr(method, '__self__') and diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index d76816cfbb8..9616774c623 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -10,7 +10,7 @@ _LOGGER = logging.getLogger(__name__) # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against -# reuqests more easily (by removing spaces from the requests as well). +# requests more easily (by removing spaces from the requests as well). # This lets "dark seagreen" and "dark sea green" both match the same # color "darkseagreen". COLORS = { @@ -308,7 +308,7 @@ def color_rgbw_to_rgb(r, g, b, w): # Add the white channel back into the rgb channels. rgb = (r + w, g + w, b + w) - # Match the output maximum value to the input. This ensures the the + # Match the output maximum value to the input. This ensures the # output doesn't overflow. return _match_max_scale((r, g, b, w), rgb) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index c7bc4205297..8b07a344148 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -146,7 +146,7 @@ def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], (-3 + 4 * cos2SigmaM ** 2))) s = AXIS_B * A * (sigma - deltaSigma) - s /= 1000 # Converion of meters to kilometers + s /= 1000 # Conversion of meters to kilometers if miles: s *= MILES_PER_KILOMETER # kilometers to miles diff --git a/script/test_docker b/script/test_docker index 9f3bbb4be07..bbea52a3a0b 100755 --- a/script/test_docker +++ b/script/test_docker @@ -1,6 +1,6 @@ #!/bin/sh # Executes the tests with tox in a docker container. -# Every argment is passed to tox to allow running only a subset of tests. +# Every argument is passed to tox to allow running only a subset of tests. # The following example will only run media_player tests: # ./test_docker -- tests/components/media_player/ diff --git a/tests/common.py b/tests/common.py index f0d6a5bd057..d7b603cca58 100644 --- a/tests/common.py +++ b/tests/common.py @@ -477,7 +477,7 @@ def assert_setup_component(count, domain=None): - domain: The domain to count is optional. It can be automatically determined most of the time - Use as a context manager aroung setup.setup_component + Use as a context manager around setup.setup_component with assert_setup_component(0) as result_config: setup_component(hass, domain, start_config) # using result_config is optional diff --git a/tests/components/binary_sensor/test_aurora.py b/tests/components/binary_sensor/test_aurora.py index c18d07575ca..ed68d23905f 100644 --- a/tests/components/binary_sensor/test_aurora.py +++ b/tests/components/binary_sensor/test_aurora.py @@ -64,7 +64,7 @@ class TestAuroraSensorSetUp(unittest.TestCase): @requests_mock.Mocker() def test_custom_threshold_works(self, mock_req): - """Test that the the config can take a custom forecast threshold.""" + """Test that the config can take a custom forecast threshold.""" uri = re.compile( "http://services\.swpc\.noaa\.gov/text/aurora-nowcast-map\.txt" ) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 4b69116f010..97f6c0385df 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,7 +27,7 @@ class TestSetupCamera(object): self.hass.stop() def test_setup_component(self): - """Setup demo platfrom on camera component.""" + """Setup demo platform on camera component.""" config = { camera.DOMAIN: { 'platform': 'demo' diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 27d79b40aa8..d15249d61f3 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -230,7 +230,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual(None, state.attributes.get('hold_mode')) def test_set_aux_heat_bad_attr(self): - """Test setting the auxillary heater without required attribute.""" + """Test setting the auxiliary heater without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('aux_heat')) climate.set_aux_heat(self.hass, None, ENTITY_CLIMATE) @@ -245,7 +245,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual('on', state.attributes.get('aux_heat')) def test_set_aux_heat_off(self): - """Test setting the auxillary heater off/false.""" + """Test setting the auxiliary heater off/false.""" climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index fc9b3cce864..e79f23c0845 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -142,7 +142,7 @@ def test_logout_view_request_timeout(mock_auth, cloud_client): @asyncio.coroutine def test_logout_view_unknown_error(mock_auth, cloud_client): - """Test unknown error while loggin out.""" + """Test unknown error while logging out.""" mock_auth.logout.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 @@ -186,7 +186,7 @@ def test_register_view_request_timeout(mock_cognito, cloud_client): @asyncio.coroutine def test_register_view_unknown_error(mock_cognito, cloud_client): - """Test unknown error while loggin out.""" + """Test unknown error while logging out.""" mock_cognito.register.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', @@ -233,7 +233,7 @@ def test_confirm_register_view_request_timeout(mock_cognito, cloud_client): @asyncio.coroutine def test_confirm_register_view_unknown_error(mock_cognito, cloud_client): - """Test unknown error while loggin out.""" + """Test unknown error while logging out.""" mock_cognito.confirm_sign_up.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/confirm_register', json={ 'email': 'hello@bla.com', @@ -274,7 +274,7 @@ def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): @asyncio.coroutine def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): - """Test unknown error while loggin out.""" + """Test unknown error while logging out.""" mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', @@ -329,7 +329,7 @@ def test_confirm_forgot_password_view_request_timeout(mock_cognito, @asyncio.coroutine def test_confirm_forgot_password_view_unknown_error(mock_cognito, cloud_client): - """Test unknown error while loggin out.""" + """Test unknown error while logging out.""" mock_cognito.confirm_forgot_password.side_effect = auth_api.UnknownError req = yield from cloud_client.post( '/api/cloud/confirm_forgot_password', json={ diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 2dcb9ecbf21..b9ef09fe4a7 100755 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -125,4 +125,4 @@ def test_warning_config_google_home_listen_port(): assert mock_warn.called assert mock_warn.mock_calls[0][1][0] == \ - "When targetting Google Home, listening port has to be port 80" + "When targeting Google Home, listening port has to be port 80" diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 816976751a7..0594c436abd 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -24,7 +24,7 @@ class TestSetupImageProcessing(object): self.hass.stop() def test_setup_component(self): - """Setup demo platfrom on image_process component.""" + """Setup demo platform on image_process component.""" config = { ip.DOMAIN: { 'platform': 'demo' @@ -35,7 +35,7 @@ class TestSetupImageProcessing(object): setup_component(self.hass, ip.DOMAIN, config) def test_setup_component_with_service(self): - """Setup demo platfrom on image_process component test service.""" + """Setup demo platform on image_process component test service.""" config = { ip.DOMAIN: { 'platform': 'demo' diff --git a/tests/components/light/test_mochad.py b/tests/components/light/test_mochad.py index b1644effd57..e69ebdb4aef 100644 --- a/tests/components/light/test_mochad.py +++ b/tests/components/light/test_mochad.py @@ -32,7 +32,7 @@ class TestMochadSwitchSetup(unittest.TestCase): self.hass = get_test_home_assistant() def tearDown(self): - """Stop everyhing that was started.""" + """Stop everything that was started.""" self.hass.stop() @mock.patch('homeassistant.components.light.mochad.MochadLight') diff --git a/tests/components/media_player/test_yamaha.py b/tests/components/media_player/test_yamaha.py index 8cea5f7c63e..ad443fadebb 100644 --- a/tests/components/media_player/test_yamaha.py +++ b/tests/components/media_player/test_yamaha.py @@ -29,7 +29,7 @@ class FakeYamaha(rxv.rxv.RXV): @property def input(self): - """A fake input for the reciever.""" + """A fake input for the receiver.""" return self._fake_input @input.setter @@ -39,7 +39,7 @@ class FakeYamaha(rxv.rxv.RXV): self._fake_input = input_name def inputs(self): - """All inputs of the the fake receiver.""" + """All inputs of the fake receiver.""" return {'AUDIO1': None, 'AUDIO2': None, 'AV1': None, diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index 8b037209cbc..ae967449ef2 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -61,7 +61,7 @@ class TestMfiSensorSetup(unittest.TestCase): @mock.patch('mficlient.client.MFiClient') def test_setup_failed_connect(self, mock_client): - """Test setup with conection failure.""" + """Test setup with connection failure.""" mock_client.side_effect = requests.exceptions.ConnectionError self.assertFalse( self.PLATFORM.setup_platform( diff --git a/tests/components/switch/test_mochad.py b/tests/components/switch/test_mochad.py index 0851bfbc324..8011d85860e 100644 --- a/tests/components/switch/test_mochad.py +++ b/tests/components/switch/test_mochad.py @@ -32,7 +32,7 @@ class TestMochadSwitchSetup(unittest.TestCase): self.hass = get_test_home_assistant() def tearDown(self): - """Stop everyhing that was started.""" + """Stop everything that was started.""" self.hass.stop() @mock.patch('homeassistant.components.switch.mochad.MochadSwitch') diff --git a/tests/components/switch/test_rflink.py b/tests/components/switch/test_rflink.py index b261d9c9b49..f215b16d746 100644 --- a/tests/components/switch/test_rflink.py +++ b/tests/components/switch/test_rflink.py @@ -81,7 +81,7 @@ def test_default_setup(hass, monkeypatch): assert hass.states.get('switch.test').state == 'on' # The switch component does not support adding new devices for incoming - # events because every new unkown device is added as a light by default. + # events because every new unknown device is added as a light by default. # test changing state from HA propagates to Rflink hass.async_add_job( diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index f117b62fddb..7c98dfcd540 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -578,7 +578,7 @@ class TestInfluxDB(unittest.TestCase): mock_client.return_value.write_points.reset_mock() def test_event_listener_component_override_measurement(self, mock_client): - """Test the event listener with overrided measurements.""" + """Test the event listener with overridden measurements.""" config = { 'influxdb': { 'host': 'host', diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 222d25f644a..06ba8a57508 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -82,7 +82,7 @@ class TestComponentsCore(unittest.TestCase): # We can't test if our service call results in services being called # because by mocking out the call service method, we mock out all - # So we mimick how the service registry calls services + # So we mimic how the service registry calls services service_call = ha.ServiceCall('homeassistant', 'turn_on', { 'entity_id': ['light.test', 'sensor.bla', 'light.bla'] }) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index aa4bc9fdf8c..07c89b5dcd1 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -415,7 +415,7 @@ class TestComponentLogbook(unittest.TestCase): def test_home_assistant_start_stop_grouped(self): """Test if HA start and stop events are grouped. - Events that are occuring in the same minute. + Events that are occurring in the same minute. """ entries = list(logbook.humanify(( ha.Event(EVENT_HOMEASSISTANT_STOP), diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 11717c75e20..efa079a7e4a 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -183,7 +183,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert 2 == len(self.hass.states.entity_ids()) def test_update_state_adds_entities_with_update_befor_add_true(self): - """Test if call update befor add to state machine.""" + """Test if call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, self.hass) ent = EntityTest() @@ -196,7 +196,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert ent.update.called def test_update_state_adds_entities_with_update_befor_add_false(self): - """Test if not call update befor add to state machine.""" + """Test if not call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, self.hass) ent = EntityTest() @@ -209,7 +209,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert not ent.update.called def test_adds_entities_with_update_befor_add_true_deadlock_protect(self): - """Test if call update befor add to state machine. + """Test if call update before add to state machine. It need to run update inside executor and never call async_add_entities with True diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 1b0b808b9c4..918a684f322 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -54,8 +54,8 @@ class TestYaml(unittest.TestCase): patch_yaml_files(files): yaml.load_yaml(YAML_CONFIG_FILE) - def test_enviroment_variable(self): - """Test config file with enviroment variable.""" + def test_environment_variable(self): + """Test config file with environment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" with io.StringIO(conf) as file: @@ -70,8 +70,8 @@ class TestYaml(unittest.TestCase): doc = yaml.yaml.safe_load(file) assert doc['password'] == "secret_password" - def test_invalid_enviroment_variable(self): - """Test config file with no enviroment variable sat.""" + def test_invalid_environment_variable(self): + """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with self.assertRaises(HomeAssistantError): with io.StringIO(conf) as file: From 6c0f4c35f6e6ea8c2993c6582f2f983bc9c4c5ad Mon Sep 17 00:00:00 2001 From: Tom Matheussen Date: Sat, 23 Sep 2017 18:31:25 +0200 Subject: [PATCH 08/94] Catch no longer existing process in systemmonitor (#9535) * Catch no longer existing process in systemmonitor * Update log message * Again line length --- homeassistant/components/sensor/systemmonitor.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 1a8d67de93e..5fe1518a315 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -140,10 +140,16 @@ class SystemMonitorSensor(Entity): elif self.type == 'processor_use': self._state = round(psutil.cpu_percent(interval=None)) elif self.type == 'process': - if any(self.argument in l.name() for l in psutil.process_iter()): - self._state = STATE_ON - else: - self._state = STATE_OFF + for proc in psutil.process_iter(): + try: + if self.argument == proc.name(): + self._state = STATE_ON + return + except psutil.NoSuchProcess as err: + _LOGGER.warning( + "Failed to load process with id: %s, old name: %s", + err.pid, err.name) + self._state = STATE_OFF elif self.type == 'network_out' or self.type == 'network_in': counters = psutil.net_io_counters(pernic=True) if self.argument in counters: From f1aef33dd64bb138a5e3942844b8b47566047454 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 23 Sep 2017 18:32:29 +0200 Subject: [PATCH 09/94] Upgrade pyasn1 to 0.3.6 (#9548) --- homeassistant/components/notify/xmpp.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index bcd1c0f3434..fe19da49cb2 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.5', + 'pyasn1==0.3.6', 'pyasn1-modules==0.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 538644f6c28..6d0635eb340 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -557,7 +557,7 @@ pyarlo==0.0.4 pyasn1-modules==0.1.4 # homeassistant.components.notify.xmpp -pyasn1==0.3.5 +pyasn1==0.3.6 # homeassistant.components.apple_tv pyatv==0.3.4 From 499382a9a9eab53a25ec3f67587fb64723fcc931 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 23 Sep 2017 20:01:48 +0300 Subject: [PATCH 10/94] Add history_graph component (#9472) * Add support for multi-entity recent fetch of history. Add graph component * Rename graph to history_graph. Support fast fetch without current state. * Address comments --- homeassistant/components/history.py | 42 ++++++----- homeassistant/components/history_graph.py | 87 +++++++++++++++++++++++ tests/components/test_history.py | 56 ++++++++++++++- tests/components/test_history_graph.py | 46 ++++++++++++ 4 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/history_graph.py create mode 100644 tests/components/test_history_graph.py diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 5a3002c05f2..9863e823e06 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -48,8 +48,8 @@ def last_recorder_run(hass): return res -def get_significant_states(hass, start_time, end_time=None, entity_id=None, - filters=None): +def get_significant_states(hass, start_time, end_time=None, entity_ids=None, + filters=None, include_start_time_state=True): """ Return states changes during UTC period start_time - end_time. @@ -60,8 +60,6 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None, timer_start = time.perf_counter() from homeassistant.components.recorder.models import States - entity_ids = (entity_id.lower(), ) if entity_id is not None else None - with session_scope(hass=hass) as session: query = session.query(States).filter( (States.domain.in_(SIGNIFICANT_DOMAINS) | @@ -86,7 +84,9 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None, _LOGGER.debug( 'get_significant_states took %fs', elapsed) - return states_to_json(hass, states, start_time, entity_id, filters) + return states_to_json( + hass, states, start_time, entity_ids, filters, + include_start_time_state) def state_changes_during_period(hass, start_time, end_time=None, @@ -105,10 +105,12 @@ def state_changes_during_period(hass, start_time, end_time=None, if entity_id is not None: query = query.filter_by(entity_id=entity_id.lower()) + entity_ids = [entity_id] if entity_id is not None else None + states = execute( query.order_by(States.last_updated)) - return states_to_json(hass, states, start_time, entity_id) + return states_to_json(hass, states, start_time, entity_ids) def get_states(hass, utc_point_in_time, entity_ids=None, run=None, @@ -185,7 +187,13 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None, if not state.attributes.get(ATTR_HIDDEN, False)] -def states_to_json(hass, states, start_time, entity_id, filters=None): +def states_to_json( + hass, + states, + start_time, + entity_ids, + filters=None, + include_start_time_state=True): """Convert SQL results into JSON friendly data structure. This takes our state list and turns it into a JSON friendly data @@ -197,14 +205,13 @@ def states_to_json(hass, states, start_time, entity_id, filters=None): """ result = defaultdict(list) - entity_ids = [entity_id] if entity_id is not None else None - # Get the states at the start time timer_start = time.perf_counter() - for state in get_states(hass, start_time, entity_ids, filters=filters): - state.last_changed = start_time - state.last_updated = start_time - result[state.entity_id].append(state) + if include_start_time_state: + for state in get_states(hass, start_time, entity_ids, filters=filters): + state.last_changed = start_time + state.last_updated = start_time + result[state.entity_id].append(state) if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start @@ -250,7 +257,7 @@ class HistoryPeriodView(HomeAssistantView): extra_urls = ['/api/history/period/{datetime}'] def __init__(self, filters): - """Initilalize the history period view.""" + """Initialize the history period view.""" self.filters = filters @asyncio.coroutine @@ -282,11 +289,14 @@ class HistoryPeriodView(HomeAssistantView): return self.json_message('Invalid end_time', HTTP_BAD_REQUEST) else: end_time = start_time + one_day - entity_id = request.query.get('filter_entity_id') + entity_ids = request.query.get('filter_entity_id') + if entity_ids: + entity_ids = entity_ids.lower().split(',') + include_start_time_state = 'skip_initial_state' not in request.query result = yield from request.app['hass'].async_add_job( get_significant_states, request.app['hass'], start_time, end_time, - entity_id, self.filters) + entity_ids, self.filters, include_start_time_state) result = result.values() if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start diff --git a/homeassistant/components/history_graph.py b/homeassistant/components/history_graph.py new file mode 100644 index 00000000000..e6977d60c30 --- /dev/null +++ b/homeassistant/components/history_graph.py @@ -0,0 +1,87 @@ +""" +Support to graphs card in the UI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/history_graph/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_ENTITIES, CONF_NAME, ATTR_ENTITY_ID +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +DEPENDENCIES = ['history'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'history_graph' + +CONF_HOURS_TO_SHOW = 'hours_to_show' +CONF_REFRESH = 'refresh' +ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW +ATTR_REFRESH = CONF_REFRESH + + +GRAPH_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1), + vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0), +}) + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({cv.slug: GRAPH_SCHEMA}) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Load graph configurations.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass) + graphs = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME, object_id) + graph = HistoryGraphEntity(name, cfg) + graphs.append(graph) + + yield from component.async_add_entities(graphs) + + return True + + +class HistoryGraphEntity(Entity): + """Representation of a graph entity.""" + + def __init__(self, name, cfg): + """Initialize the graph.""" + self._name = name + self._hours = cfg[CONF_HOURS_TO_SHOW] + self._refresh = cfg[CONF_REFRESH] + self._entities = cfg[CONF_ENTITIES] + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = { + ATTR_HOURS_TO_SHOW: self._hours, + ATTR_REFRESH: self._refresh, + ATTR_ENTITY_ID: self._entities, + } + return attrs diff --git a/tests/components/test_history.py b/tests/components/test_history.py index d2ea03b1873..8484e2c536f 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -145,6 +145,48 @@ class TestComponentHistory(unittest.TestCase): self.hass, zero, four, filters=history.Filters()) assert states == hist + def test_get_significant_states_with_initial(self): + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + zero, four, states = self.record_states() + one = zero + timedelta(seconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + if entity_id == 'media_player.test': + states[entity_id] = states[entity_id][1:] + for state in states[entity_id]: + if state.last_changed == one: + state.last_changed = one_and_half + + hist = history.get_significant_states( + self.hass, one_and_half, four, filters=history.Filters(), + include_start_time_state=True) + assert states == hist + + def test_get_significant_states_without_initial(self): + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + zero, four, states = self.record_states() + one = zero + timedelta(seconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + states[entity_id] = list(filter( + lambda s: s.last_changed != one, states[entity_id])) + del states['media_player.test2'] + + hist = history.get_significant_states( + self.hass, one_and_half, four, filters=history.Filters(), + include_start_time_state=False) + assert states == hist + def test_get_significant_states_entity_id(self): """Test that only significant states are returned for one entity.""" zero, four, states = self.record_states() @@ -154,7 +196,19 @@ class TestComponentHistory(unittest.TestCase): del states['script.can_cancel_this_one'] hist = history.get_significant_states( - self.hass, zero, four, 'media_player.test', + self.hass, zero, four, ['media_player.test'], + filters=history.Filters()) + assert states == hist + + def test_get_significant_states_multiple_entity_ids(self): + """Test that only significant states are returned for one entity.""" + zero, four, states = self.record_states() + del states['media_player.test2'] + del states['thermostat.test2'] + del states['script.can_cancel_this_one'] + + hist = history.get_significant_states( + self.hass, zero, four, ['media_player.test', 'thermostat.test'], filters=history.Filters()) assert states == hist diff --git a/tests/components/test_history_graph.py b/tests/components/test_history_graph.py new file mode 100644 index 00000000000..554f7f29dd7 --- /dev/null +++ b/tests/components/test_history_graph.py @@ -0,0 +1,46 @@ +"""The tests the Graph component.""" + +import unittest + +from homeassistant.setup import setup_component +from tests.common import init_recorder_component, get_test_home_assistant + + +class TestGraph(unittest.TestCase): + """Test the Google component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_component(self): + """Test setup component.""" + self.init_recorder() + config = { + 'history': { + }, + 'history_graph': { + 'name_1': { + 'entities': 'test.test', + } + } + } + + self.assertTrue(setup_component(self.hass, 'history_graph', config)) + self.assertEqual( + dict(self.hass.states.get('history_graph.name_1').attributes), + { + 'entity_id': ['test.test'], + 'friendly_name': 'name_1', + 'hours_to_show': 24, + 'refresh': 0 + }) + + def init_recorder(self): + """Initialize the recorder.""" + init_recorder_component(self.hass) + self.hass.start() From 0d75cd484b11e2ca095e855bfb4cc72aca5899d6 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Sun, 24 Sep 2017 16:12:38 +1000 Subject: [PATCH 11/94] GeoRSS sensor (#9331) * new geo rss events sensor * SCAN_INTERVAL instead of DEFAULT_SCAN_INTERVAL * removed redefinition CONF_SCAN_INTERVAL * definition of self._name not required * removed unnecessary check and unnecessary parameter * changed log levels * fixed default name not used * streamlined sensor name and entity id generation, removed unnecessary parameter * fixed issue for entries without geometry data * fixed tests after code changes * simplified code * simplified code; removed unnecessary imports * fixed invalid variable name * shorter sensor name and in turn entity id * increasing test coverage for previously untested code * fixed indentation and variable usage * simplified test code * merged two similar tests * fixed an issue if no data could be fetched from external service; added test case for this case --- .../components/sensor/geo_rss_events.py | 243 ++++++++++++++++++ requirements_all.txt | 4 + requirements_test_all.txt | 7 + script/gen_requirements_all.py | 2 + .../components/sensor/test_geo_rss_events.py | 143 +++++++++++ tests/fixtures/geo_rss_events.xml | 76 ++++++ 6 files changed, 475 insertions(+) create mode 100644 homeassistant/components/sensor/geo_rss_events.py create mode 100644 tests/components/sensor/test_geo_rss_events.py create mode 100644 tests/fixtures/geo_rss_events.xml diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py new file mode 100644 index 00000000000..484dd67e0e4 --- /dev/null +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -0,0 +1,243 @@ +""" +Generic GeoRSS events service. + +Retrieves current events (typically incidents or alerts) in GeoRSS format, and +shows information on events filtered by distance to the HA instance's location +and grouped by category. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.geo_rss_events/ +""" + +import logging +from collections import namedtuple +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 (STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, + CONF_NAME) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['feedparser==5.2.1', 'haversine==0.4.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CATEGORY = 'category' +ATTR_DISTANCE = 'distance' +ATTR_TITLE = 'title' + +CONF_CATEGORIES = 'categories' +CONF_RADIUS = 'radius' +CONF_URL = 'url' + +DEFAULT_ICON = 'mdi:alert' +DEFAULT_NAME = "Event Service" +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'Events' + +DOMAIN = 'geo_rss_events' + +# Minimum time between updates from the source. +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, + [cv.string]), + vol.Optional(CONF_UNIT_OF_MEASUREMENT, + default=DEFAULT_UNIT_OF_MEASUREMENT): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the GeoRSS component.""" + # Grab location from config + home_latitude = hass.config.latitude + home_longitude = hass.config.longitude + url = config.get(CONF_URL) + radius_in_km = config.get(CONF_RADIUS) + name = config.get(CONF_NAME) + categories = config.get(CONF_CATEGORIES) + unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + + _LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s", + home_latitude, home_longitude, url, radius_in_km) + + # Initialise update service. + data = GeoRssServiceData(home_latitude, home_longitude, url, radius_in_km) + data.update() + + # Create all sensors based on categories. + devices = [] + if not categories: + device = GeoRssServiceSensor(None, data, name, unit_of_measurement) + devices.append(device) + else: + for category in categories: + device = GeoRssServiceSensor(category, data, name, + unit_of_measurement) + devices.append(device) + add_devices(devices, True) + + +class GeoRssServiceSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, category, data, service_name, unit_of_measurement): + """Initialize the sensor.""" + self._category = category + self._data = data + self._service_name = service_name + self._state = STATE_UNKNOWN + self._state_attributes = None + self._unit_of_measurement = unit_of_measurement + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self._service_name, + 'Any' if self._category is None + else self._category) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the default icon to use in the frontend.""" + return DEFAULT_ICON + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._state_attributes + + def update(self): + """Update this sensor from the GeoRSS service.""" + _LOGGER.debug("About to update sensor %s", self.entity_id) + self._data.update() + # If no events were found due to an error then just set state to zero. + if self._data.events is None: + self._state = 0 + else: + if self._category is None: + # Add all events regardless of category. + my_events = self._data.events + else: + # Only keep events that belong to sensor's category. + my_events = [event for event in self._data.events if + event[ATTR_CATEGORY] == self._category] + _LOGGER.debug("Adding events to sensor %s: %s", self.entity_id, + my_events) + self._state = len(my_events) + # And now compute the attributes from the filtered events. + matrix = {} + for event in my_events: + matrix[event[ATTR_TITLE]] = '{:.0f}km'.format( + event[ATTR_DISTANCE]) + self._state_attributes = matrix + + +class GeoRssServiceData(object): + """Provides access to GeoRSS feed and stores the latest data.""" + + def __init__(self, home_latitude, home_longitude, url, radius_in_km): + """Initialize the update service.""" + self._home_coordinates = [home_latitude, home_longitude] + self._url = url + self._radius_in_km = radius_in_km + self.events = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Retrieve data from GeoRSS feed and store events.""" + import feedparser + feed_data = feedparser.parse(self._url) + if not feed_data: + _LOGGER.error("Error fetching feed data from %s", self._url) + else: + events = self.filter_entries(feed_data) + self.events = events + + def filter_entries(self, feed_data): + """Filter entries by distance from home coordinates.""" + events = [] + _LOGGER.debug("%s entri(es) available in feed %s", + len(feed_data.entries), self._url) + for entry in feed_data.entries: + geometry = None + if hasattr(entry, 'where'): + geometry = entry.where + elif hasattr(entry, 'geo_lat') and hasattr(entry, 'geo_long'): + coordinates = (float(entry.geo_long), float(entry.geo_lat)) + point = namedtuple('Point', ['type', 'coordinates']) + geometry = point('Point', coordinates) + if geometry: + distance = self.calculate_distance_to_geometry(geometry) + if distance <= self._radius_in_km: + event = { + ATTR_CATEGORY: None if not hasattr( + entry, 'category') else entry.category, + ATTR_TITLE: None if not hasattr( + entry, 'title') else entry.title, + ATTR_DISTANCE: distance + } + events.append(event) + _LOGGER.debug("%s events found nearby", len(events)) + return events + + def calculate_distance_to_geometry(self, geometry): + """Calculate the distance between HA and provided geometry.""" + distance = float("inf") + if geometry.type == 'Point': + distance = self.calculate_distance_to_point(geometry) + elif geometry.type == 'Polygon': + distance = self.calculate_distance_to_polygon( + geometry.coordinates[0]) + else: + _LOGGER.warning("Not yet implemented: %s", geometry.type) + return distance + + def calculate_distance_to_point(self, point): + """Calculate the distance between HA and the provided point.""" + # Swap coordinates to match: (lat, lon). + coordinates = (point.coordinates[1], point.coordinates[0]) + return self.calculate_distance_to_coords(coordinates) + + def calculate_distance_to_coords(self, coordinates): + """Calculate the distance between HA and the provided coordinates.""" + # Expecting coordinates in format: (lat, lon). + from haversine import haversine + distance = haversine(coordinates, self._home_coordinates) + _LOGGER.debug("Distance from %s to %s: %s km", self._home_coordinates, + coordinates, distance) + return distance + + def calculate_distance_to_polygon(self, polygon): + """Calculate the distance between HA and the provided polygon.""" + distance = float("inf") + # Calculate distance from polygon by calculating the distance + # to each point of the polygon but not to each edge of the + # polygon; should be good enough + for polygon_point in polygon: + coordinates = (polygon_point[1], polygon_point[0]) + distance = min(distance, + self.calculate_distance_to_coords(coordinates)) + _LOGGER.debug("Distance from %s to %s: %s km", self._home_coordinates, + polygon, distance) + return distance diff --git a/requirements_all.txt b/requirements_all.txt index 6d0635eb340..a74f568c76c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -228,6 +228,7 @@ fastdotcom==0.0.1 fedexdeliverymanager==1.0.4 # homeassistant.components.feedreader +# homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 # homeassistant.components.sensor.fitbit @@ -286,6 +287,9 @@ ha-ffmpeg==1.7 # homeassistant.components.media_player.philips_js ha-philipsjs==0.0.1 +# homeassistant.components.sensor.geo_rss_events +haversine==0.4.5 + # homeassistant.components.mqtt.server hbmqtt==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5d6bbedca1..79e872ffa4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,6 +45,10 @@ ephem==3.7.6.0 # homeassistant.components.climate.honeywell evohomeclient==0.2.5 +# homeassistant.components.feedreader +# homeassistant.components.sensor.geo_rss_events +feedparser==5.2.1 + # homeassistant.components.conversation fuzzywuzzy==0.15.1 @@ -54,6 +58,9 @@ gTTS-token==1.1.1 # homeassistant.components.ffmpeg ha-ffmpeg==1.7 +# homeassistant.components.sensor.geo_rss_events +haversine==0.4.5 + # homeassistant.components.mqtt.server hbmqtt==0.8 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 99bcf80288b..dd1602fba6f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -39,10 +39,12 @@ TEST_REQUIREMENTS = ( 'dsmr_parser', 'ephem', 'evohomeclient', + 'feedparser', 'forecastio', 'fuzzywuzzy', 'gTTS-token', 'ha-ffmpeg', + 'haversine', 'hbmqtt', 'holidays', 'influxdb', diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py new file mode 100644 index 00000000000..557def8225b --- /dev/null +++ b/tests/components/sensor/test_geo_rss_events.py @@ -0,0 +1,143 @@ +"""The test for the geo rss events sensor platform.""" +import unittest +from unittest import mock + +from homeassistant.setup import setup_component +from tests.common import load_fixture, get_test_home_assistant +import homeassistant.components.sensor.geo_rss_events as geo_rss_events + +URL = 'http://geo.rss.local/geo_rss_events.xml' +VALID_CONFIG_WITH_CATEGORIES = { + 'platform': 'geo_rss_events', + geo_rss_events.CONF_URL: URL, + geo_rss_events.CONF_CATEGORIES: [ + 'Category 1', + 'Category 2' + ] +} +VALID_CONFIG_WITHOUT_CATEGORIES = { + 'platform': 'geo_rss_events', + geo_rss_events.CONF_URL: URL +} + + +class TestGeoRssServiceUpdater(unittest.TestCase): + """Test the GeoRss service updater.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG_WITHOUT_CATEGORIES + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_with_categories(self): + """Test the general setup of this sensor.""" + self.config = VALID_CONFIG_WITH_CATEGORIES + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + self.assertIsNotNone( + self.hass.states.get('sensor.event_service_category_1')) + self.assertIsNotNone( + self.hass.states.get('sensor.event_service_category_2')) + + def test_setup_without_categories(self): + """Test the general setup of this sensor.""" + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + self.assertIsNotNone(self.hass.states.get('sensor.event_service_any')) + + def setup_data(self, url='url'): + """Set up data object for use by sensors.""" + home_latitude = -33.865 + home_longitude = 151.209444 + radius_in_km = 500 + data = geo_rss_events.GeoRssServiceData(home_latitude, + home_longitude, url, + radius_in_km) + return data + + def test_update_sensor_with_category(self): + """Test updating sensor object.""" + raw_data = load_fixture('geo_rss_events.xml') + # Loading raw data from fixture and plug in to data object as URL + # works since the third-party feedparser library accepts a URL + # as well as the actual data. + data = self.setup_data(raw_data) + category = "Category 1" + name = "Name 1" + unit_of_measurement = "Unit 1" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 1 Category 1" + assert sensor.unit_of_measurement == "Unit 1" + assert sensor.icon == "mdi:alert" + assert len(sensor._data.events) == 4 + assert sensor.state == 1 + assert sensor.device_state_attributes == {'Title 1': "117km"} + # Check entries of first hit + assert sensor._data.events[0][geo_rss_events.ATTR_TITLE] == "Title 1" + assert sensor._data.events[0][ + geo_rss_events.ATTR_CATEGORY] == "Category 1" + self.assertAlmostEqual(sensor._data.events[0][ + geo_rss_events.ATTR_DISTANCE], 116.586, 0) + + def test_update_sensor_without_category(self): + """Test updating sensor object.""" + raw_data = load_fixture('geo_rss_events.xml') + data = self.setup_data(raw_data) + category = None + name = "Name 2" + unit_of_measurement = "Unit 2" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 2 Any" + assert sensor.unit_of_measurement == "Unit 2" + assert sensor.icon == "mdi:alert" + assert len(sensor._data.events) == 4 + assert sensor.state == 4 + assert sensor.device_state_attributes == {'Title 1': "117km", + 'Title 2': "302km", + 'Title 3': "204km", + 'Title 6': "48km"} + + def test_update_sensor_without_data(self): + """Test updating sensor object.""" + data = self.setup_data() + category = None + name = "Name 3" + unit_of_measurement = "Unit 3" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 3 Any" + assert sensor.unit_of_measurement == "Unit 3" + assert sensor.icon == "mdi:alert" + assert len(sensor._data.events) == 0 + assert sensor.state == 0 + + @mock.patch('feedparser.parse', return_value=None) + def test_update_sensor_with_none_result(self, parse_function): + """Test updating sensor object.""" + data = self.setup_data("http://invalid.url/") + category = None + name = "Name 4" + unit_of_measurement = "Unit 4" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 4 Any" + assert sensor.unit_of_measurement == "Unit 4" + assert sensor.state == 0 diff --git a/tests/fixtures/geo_rss_events.xml b/tests/fixtures/geo_rss_events.xml new file mode 100644 index 00000000000..212994756d2 --- /dev/null +++ b/tests/fixtures/geo_rss_events.xml @@ -0,0 +1,76 @@ + + + + + + Title 1 + Description 1 + Category 1 + Sun, 30 Jul 2017 09:00:00 UTC + GUID 1 + -32.916667 151.75 + + + + Title 2 + Description 2 + Category 2 + Sun, 30 Jul 2017 09:05:00 GMT + GUID 2 + 148.601111 + -32.256944 + + + + Title 3 + Description 3 + Category 3 + Sun, 30 Jul 2017 09:05:00 GMT + GUID 3 + + -33.283333 149.1 + -33.2999997 149.1 + -33.2999997 149.1166663888889 + -33.283333 149.1166663888889 + -33.283333 149.1 + + + + + Title 4 + Description 4 + Category 4 + Sun, 30 Jul 2017 09:15:00 GMT + GUID 4 + 52.518611 13.408333 + + + + Title 5 + Description 5 + Category 5 + Sun, 30 Jul 2017 09:20:00 GMT + GUID 5 + + + + + Title 6 + Description 6 + Category 6 + 2017-07-30T09:25:00.000Z + Link 6 + -33.75801 150.70544 + + + + Title 1 + Description 1 + Category 1 + Sun, 30 Jul 2017 09:00:00 UTC + GUID 1 + 45.256 -110.45 46.46 -109.48 43.84 -109.86 + + + \ No newline at end of file From e2ce1d05aeb825efcc324d0e4ac38fd868e80875 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Sat, 23 Sep 2017 23:22:15 -0700 Subject: [PATCH 12/94] Fixed bug with all switch devices being excluded (#9555) --- homeassistant/components/switch/abode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py index 63fe6b9f7b8..0ce1ddc59f8 100644 --- a/homeassistant/components/switch/abode.py +++ b/homeassistant/components/switch/abode.py @@ -27,7 +27,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Get all regular switches that are not excluded or marked as lights for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH): - if data.is_excluded(device) or not data.is_light(device): + if data.is_excluded(device) or data.is_light(device): continue devices.append(AbodeSwitch(data, device)) From 84524e0712b476207c50a03e2936c309c9f9ce7d Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Sun, 24 Sep 2017 02:28:11 -0400 Subject: [PATCH 13/94] fix usps? (#9557) --- homeassistant/components/sensor/usps.py | 2 +- homeassistant/components/usps.py | 7 +++++-- requirements_all.txt | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/usps.py b/homeassistant/components/sensor/usps.py index cf7378186f4..a789f566896 100644 --- a/homeassistant/components/sensor/usps.py +++ b/homeassistant/components/sensor/usps.py @@ -57,7 +57,7 @@ class USPSPackageSensor(Entity): for package in self._usps.packages: status = slugify(package['primary_status']) if status == STATUS_DELIVERED and \ - package['date'] < now().date(): + package['delivery_date'] < now().date(): continue status_counts[status] += 1 self._attributes = { diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py index 21a2700cd5c..0c4eba54e35 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.2.1'] +REQUIREMENTS = ['myusps==1.2.2'] _LOGGER = logging.getLogger(__name__) @@ -23,6 +23,7 @@ DOMAIN = 'usps' DATA_USPS = 'data_usps' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) COOKIE = 'usps_cookies.pickle' +CACHE = 'usps_cache' USPS_TYPE = ['sensor', 'camera'] @@ -45,7 +46,9 @@ def setup(hass, config): import myusps try: cookie = hass.config.path(COOKIE) - session = myusps.get_session(username, password, cookie_path=cookie) + cache = hass.config.path(CACHE) + session = myusps.get_session(username, password, + cookie_path=cookie, cache_path=cache) except myusps.USPSError: _LOGGER.exception('Could not connect to My USPS') return False diff --git a/requirements_all.txt b/requirements_all.txt index a74f568c76c..1bf5d6149fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -432,7 +432,7 @@ mutagen==1.38 mycroftapi==2.0 # homeassistant.components.usps -myusps==1.2.1 +myusps==1.2.2 # homeassistant.components.media_player.nad # homeassistant.components.media_player.nadtcp From bbf6e9ea47b39f36d1c535c9640eea4e373b7c4b Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Sat, 23 Sep 2017 23:57:37 -0700 Subject: [PATCH 14/94] Added support for ARM_NIGHT for manual_mqtt alarm (#9358) * - Added support for ARM_NIGHT for manual_mqtt alarm * - port "Add post_pending_state attribute to manual alarm_control_panel #9291" to manuql_mqtt * - port "Fixed manual alarm not re-arm after 2nd trigger #9249" to manuql_mqtt * - port "Add manual alarm_control_panel pending time per state #9264" to manuql_mqtt * - Updated test_trigger_with_specific_pending to simulate real scenario e.g. arm the system then trigger --- .../alarm_control_panel/manual_mqtt.py | 126 ++++-- .../alarm_control_panel/test_manual_mqtt.py | 363 +++++++++++++++++- 2 files changed, 453 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index b554a667b2a..44247616b59 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ """ import asyncio +import copy import datetime import logging @@ -13,9 +14,9 @@ import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, - CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt @@ -28,6 +29,7 @@ from homeassistant.helpers.event import track_point_in_time CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' +CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_PENDING_TIME = 60 @@ -35,11 +37,32 @@ DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + DEPENDENCIES = ['mqtt'] -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -49,12 +72,17 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, -}) +}), _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -73,7 +101,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(mqtt.CONF_QOS), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY))]) + config.get(CONF_PAYLOAD_ARM_AWAY), + config.get(CONF_PAYLOAD_ARM_NIGHT), + config)]) class ManualMQTTAlarm(alarm.AlarmControlPanel): @@ -89,7 +119,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): def __init__(self, hass, name, code, pending_time, trigger_time, disarm_after_trigger, state_topic, command_topic, qos, - payload_disarm, payload_arm_home, payload_arm_away): + payload_disarm, payload_arm_home, payload_arm_away, + payload_arm_night, config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -101,12 +132,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + self._state_topic = state_topic self._command_topic = command_topic self._qos = qos self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away + self._payload_arm_night = payload_arm_night @property def should_poll(self): @@ -121,23 +158,27 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -157,44 +198,47 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() + self._update_state(STATE_ALARM_ARMED_AWAY) - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" @@ -203,6 +247,16 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Invalid code given for %s", state) return check + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + def async_added_to_hass(self): """Subscribe mqtt events. @@ -221,6 +275,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self.async_alarm_arm_home(self._code) elif payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) + elif payload == self._payload_arm_night: + self.async_alarm_arm_night(self._code) else: _LOGGER.warning("Received unexpected payload: %s", payload) return diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index c4dcd57ca39..5210c616f9c 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) from homeassistant.components import alarm_control_panel import homeassistant.util.dt as dt_util @@ -100,6 +100,11 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_HOME)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): @@ -184,6 +189,11 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_AWAY)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): @@ -218,6 +228,95 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_arm_night_no_pending(self): + """Test arm night method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_night(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_arm_night_with_pending(self): + """Test arm night method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_night(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_NIGHT)) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_arm_night_with_invalid_code(self): + """Attempt to arm night without a valid code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_night(self.hass, CODE + '2') + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_no_pending(self): """Test triggering when no pending submitted method.""" self.assertTrue(setup_component( @@ -276,6 +375,11 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_TRIGGERED)) + future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): @@ -328,6 +432,61 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): + """Test no disarm after back to back trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(setup_component( @@ -407,6 +566,160 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_armed_home_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + }, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_armed_away_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + }, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_away(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_armed_night_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + }, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_night(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=10) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + def test_arm_home_via_command_topic(self): """Test arming home via command topic.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -475,6 +788,40 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_AWAY, self.hass.states.get(entity_id).state) + def test_arm_night_via_command_topic(self): + """Test arming night via command topic.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 1, + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'payload_arm_night': 'ARM_NIGHT', + } + }) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + # Fire the arm command via MQTT; ensure state changes to pending + fire_mqtt_message(self.hass, 'alarm/command', 'ARM_NIGHT') + self.hass.block_till_done() + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + def test_disarm_pending_via_command_topic(self): """Test disarming pending alarm via command topic.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -552,6 +899,20 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(('alarm/state', STATE_ALARM_ARMED_AWAY, 0, True), self.mock_publish.mock_calls[-2][1]) + # Arm in night mode + alarm_control_panel.alarm_arm_night(self.hass) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), + self.mock_publish.mock_calls[-2][1]) + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True), + self.mock_publish.mock_calls[-2][1]) + # Disarm alarm_control_panel.alarm_disarm(self.hass) self.hass.block_till_done() From aa0fc339c0ad0ca3f3a456b49487b7f2b1e92b24 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 24 Sep 2017 13:25:18 -0600 Subject: [PATCH 15/94] Various AirVisual bugfixes (#9554) * Various AirVisual bugfixes * Updating requirements * Added better logging for failed data retrieval --- homeassistant/components/sensor/airvisual.py | 30 +++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 5e88dfa8bc9..7e14ec6eff4 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -13,14 +13,14 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, - CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, - CONF_STATE) +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle _LOGGER = getLogger(__name__) -REQUIREMENTS = ['pyairvisual==0.1.0'] +REQUIREMENTS = ['pyairvisual==1.0.0'] ATTR_CITY = 'city' ATTR_COUNTRY = 'country' @@ -135,11 +135,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): country = config.get(CONF_COUNTRY) if city and state and country: - _LOGGER.debug('Constructing sensors based on city, state, and country') + _LOGGER.debug('Using city, state, and country: %s, %s, %s', city, + state, country) data = AirVisualData( pav.Client(api_key), city=city, state=state, country=country) else: - _LOGGER.debug('Constructing sensors based on latitude and longitude') + _LOGGER.debug('Using latitude and longitude: %s, %s', latitude, + longitude) data = AirVisualData( pav.Client(api_key), latitude=latitude, @@ -181,6 +183,8 @@ class AirVisualBaseSensor(Entity): ATTR_CITY: self._data.city, ATTR_COUNTRY: self._data.country, ATTR_REGION: self._data.state, + ATTR_LATITUDE: self._data.latitude, + ATTR_LONGITUDE: self._data.longitude, ATTR_TIMESTAMP: self._data.pollution_info.get('ts') } @@ -285,7 +289,7 @@ class AirVisualData(object): self.latitude = kwargs.get(CONF_LATITUDE) self.longitude = kwargs.get(CONF_LONGITUDE) - self.radius = kwargs.get(CONF_RADIUS) + self._radius = kwargs.get(CONF_RADIUS) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -296,13 +300,19 @@ class AirVisualData(object): if self.city and self.state and self.country: resp = self._client.city(self.city, self.state, self.country).get('data') + self.longitude, self.latitude = resp.get('location').get( + 'coordinates') else: resp = self._client.nearest_city(self.latitude, self.longitude, - self.radius).get('data') + self._radius).get('data') _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') self.pollution_info = resp.get('current', {}).get('pollution', {}) except exceptions.HTTPError as exc_info: - _LOGGER('Unable to retrieve data from the API') - _LOGGER.error("There is likely no data on this location") + _LOGGER.error('Unable to retrieve data on this location: %s', + self.__dict__) _LOGGER.debug(exc_info) self.pollution_info = {} diff --git a/requirements_all.txt b/requirements_all.txt index 1bf5d6149fe..4e82ee2a661 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -549,7 +549,7 @@ pyRFXtrx==0.20.1 pyW215==0.6.0 # homeassistant.components.sensor.airvisual -pyairvisual==0.1.0 +pyairvisual==1.0.0 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 From 1b91218a6073b0a8f741c69b3e5db78b00a03957 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 24 Sep 2017 13:44:34 -0600 Subject: [PATCH 16/94] Updated Arlo cameras with new attributes (#9565) --- homeassistant/components/arlo.py | 2 +- homeassistant/components/camera/arlo.py | 38 +++++++++++++++++++++++++ requirements_all.txt | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 1ba2acb4fe0..0ab629cfbd4 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.0.4'] +REQUIREMENTS = ['pyarlo==0.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 80833e34b20..d473fa42d9d 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -14,15 +14,31 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_BATTERY_LEVEL DEPENDENCIES = ['arlo', 'ffmpeg'] _LOGGER = logging.getLogger(__name__) +ATTR_BRIGHTNESS = 'brightness' +ATTR_FLIPPED = 'flipped' +ATTR_MIRRORED = 'mirrored' +ATTR_MOTION_SENSITIVITY = 'motion_detection_sensitivity' +ATTR_POWER_SAVE_MODE = 'power_save_mode' +ATTR_SIGNAL_STRENGTH = 'signal_strength' +ATTR_UNSEEN_VIDEOS = 'unseen_videos' + CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' +POWERSAVE_MODE_MAPPING = { + 1: 'best_battery_life', + 2: 'optimized', + 3: 'best_video' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, }) @@ -80,6 +96,28 @@ class ArloCam(Camera): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_BATTERY_LEVEL: + self._camera.get_battery_level, + ATTR_BRIGHTNESS: + self._camera.get_brightness, + ATTR_FLIPPED: + self._camera.get_flip_state, + ATTR_MIRRORED: + self._camera.get_mirror_state, + ATTR_MOTION_SENSITIVITY: + self._camera.get_motion_detection_sensitivity, + ATTR_POWER_SAVE_MODE: + POWERSAVE_MODE_MAPPING[self._camera.get_powersave_mode], + ATTR_SIGNAL_STRENGTH: + self._camera.get_signal_strength, + ATTR_UNSEEN_VIDEOS: + self._camera.unseen_videos + } + @property def model(self): """Camera model.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4e82ee2a661..e6ea4b71f96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -555,7 +555,7 @@ pyairvisual==1.0.0 pyalarmdotcom==0.3.0 # homeassistant.components.arlo -pyarlo==0.0.4 +pyarlo==0.0.6 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.4 From 350b8e09e6b1dabbba2bd5fb5db996dacfa782d0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 24 Sep 2017 13:08:58 -0700 Subject: [PATCH 17/94] Allow specifying multiple ports for UPNP component (#9560) * Update UPNP component * Bump dep * Fix flakiness in test --- .coveragerc | 3 +- homeassistant/components/upnp.py | 83 +++++++++++------- requirements_all.txt | 2 +- tests/components/test_upnp.py | 142 +++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 33 deletions(-) create mode 100644 tests/components/test_upnp.py diff --git a/.coveragerc b/.coveragerc index 60375fbb97e..52ffc3da56a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -52,7 +52,7 @@ omit = homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py - + homeassistant/components/doorbird.py homeassistant/components/*/doorbird.py @@ -581,7 +581,6 @@ omit = homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py homeassistant/components/tts/picotts.py - homeassistant/components/upnp.py homeassistant/components/vacuum/roomba.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 9e45def63db..87990495cf4 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -4,16 +4,18 @@ Will open a port in your router for Home Assistant and provide statistics. For more details about this component, please refer to the documentation at https://home-assistant.io/components/upnp/ """ +from ipaddress import ip_address import logging -from urllib.parse import urlsplit import voluptuous as vol from homeassistant.const import (EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery +from homeassistant.util import get_local_ip -REQUIREMENTS = ['miniupnpc==1.9'] +REQUIREMENTS = ['miniupnpc==2.0.2'] +DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -22,9 +24,11 @@ DOMAIN = 'upnp' DATA_UPNP = 'UPNP' +CONF_LOCAL_IP = 'local_ip' CONF_ENABLE_PORT_MAPPING = 'port_mapping' -CONF_EXTERNAL_PORT = 'external_port' +CONF_PORTS = 'ports' CONF_UNITS = 'unit' +CONF_HASS = 'hass' NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP Setup' @@ -39,8 +43,10 @@ UNITS = { CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_ENABLE_PORT_MAPPING, default=True): cv.boolean, - vol.Optional(CONF_EXTERNAL_PORT, default=0): cv.positive_int, vol.Optional(CONF_UNITS, default="MBytes"): vol.In(UNITS), + vol.Optional(CONF_LOCAL_IP): ip_address, + vol.Optional(CONF_PORTS): + vol.Schema({vol.Any(CONF_HASS, cv.positive_int): cv.positive_int}) }), }, extra=vol.ALLOW_EXTRA) @@ -48,6 +54,19 @@ CONFIG_SCHEMA = vol.Schema({ # pylint: disable=import-error, no-member, broad-except def setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" + config = config[DOMAIN] + host = config.get(CONF_LOCAL_IP) + + if host is not None: + host = str(host) + else: + host = get_local_ip() + + if host == '127.0.0.1': + _LOGGER.error( + 'Unable to determine local IP. Add it to your configuration.') + return False + import miniupnpc upnp = miniupnpc.UPnP() @@ -61,40 +80,44 @@ def setup(hass, config): _LOGGER.exception("Error when attempting to discover an UPnP IGD") return False - unit = config[DOMAIN].get(CONF_UNITS) + unit = config.get(CONF_UNITS) discovery.load_platform(hass, 'sensor', DOMAIN, {'unit': unit}, config) - port_mapping = config[DOMAIN].get(CONF_ENABLE_PORT_MAPPING) + port_mapping = config.get(CONF_ENABLE_PORT_MAPPING) if not port_mapping: return True - base_url = urlsplit(hass.config.api.base_url) - host = base_url.hostname - internal_port = base_url.port - external_port = int(config[DOMAIN].get(CONF_EXTERNAL_PORT)) + internal_port = hass.http.server_port - if external_port == 0: - external_port = internal_port + ports = config.get(CONF_PORTS) + if ports is None: + ports = {CONF_HASS: internal_port} - try: - upnp.addportmapping( - external_port, 'TCP', host, internal_port, 'Home Assistant', '') + registered = [] + for internal, external in ports.items(): + if internal == CONF_HASS: + internal = internal_port + try: + upnp.addportmapping( + external, 'TCP', host, internal, 'Home Assistant', '') + registered.append(external) + except Exception: + _LOGGER.exception("UPnP failed to configure port mapping for %s", + external) + hass.components.persistent_notification.create( + 'ERROR: tcp port {} is already mapped in your router.' + '
Please disable port_mapping in the upnp ' + 'configuration section.
' + 'You will need to restart hass after fixing.' + ''.format(external), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) - def deregister_port(event): - """De-register the UPnP port mapping.""" - upnp.deleteportmapping(external_port, 'TCP') + def deregister_port(event): + """De-register the UPnP port mapping.""" + for external in registered: + upnp.deleteportmapping(external, 'TCP') - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) - except Exception as ex: - _LOGGER.error("UPnP failed to configure port mapping: %s", str(ex)) - hass.components.persistent_notification.create( - 'ERROR: tcp port {} is already mapped in your router.' - '
Please disable port_mapping in the upnp ' - 'configuration section.
' - 'You will need to restart hass after fixing.' - ''.format(external_port), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False return True diff --git a/requirements_all.txt b/requirements_all.txt index e6ea4b71f96..d94c6059a5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -420,7 +420,7 @@ mficlient==0.3.0 miflora==0.1.16 # homeassistant.components.upnp -miniupnpc==1.9 +miniupnpc==2.0.2 # homeassistant.components.sensor.mopar motorparts==1.0.2 diff --git a/tests/components/test_upnp.py b/tests/components/test_upnp.py new file mode 100644 index 00000000000..e2096d28e58 --- /dev/null +++ b/tests/components/test_upnp.py @@ -0,0 +1,142 @@ +"""Test the UPNP component.""" +import asyncio +from collections import OrderedDict +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_miniupnpc(): + """Mock miniupnpc.""" + mock = MagicMock() + + with patch.dict('sys.modules', {'miniupnpc': mock}): + yield mock.UPnP() + + +@pytest.fixture +def mock_local_ip(): + """Mock get_local_ip.""" + with patch('homeassistant.components.upnp.get_local_ip', + return_value='192.168.0.10'): + yield + + +@pytest.fixture(autouse=True) +def mock_discovery(): + """Mock discovery of upnp sensor.""" + with patch('homeassistant.components.upnp.discovery'): + yield + + +@asyncio.coroutine +def test_setup_fail_if_no_ip(hass): + """Test setup fails if we can't find a local IP.""" + with patch('homeassistant.components.upnp.get_local_ip', + return_value='127.0.0.1'): + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': {} + }) + + assert not result + + +@asyncio.coroutine +def test_setup_fail_if_cannot_select_igd(hass, mock_local_ip, mock_miniupnpc): + """Test setup fails if we can't find an UPnP IGD.""" + mock_miniupnpc.selectigd.side_effect = Exception + + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': {} + }) + + assert not result + + +@asyncio.coroutine +def test_setup_succeeds_if_specify_ip(hass, mock_miniupnpc): + """Test setup succeeds if we specify IP and can't find a local IP.""" + with patch('homeassistant.components.upnp.get_local_ip', + return_value='127.0.0.1'): + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10' + } + }) + + assert result + + +@asyncio.coroutine +def test_no_config_maps_hass_local_to_remote_port(hass, mock_miniupnpc): + """Test by default we map local to remote port.""" + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10' + } + }) + + assert result + assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[0][1] + assert host == '192.168.0.10' + assert external == 8123 + assert internal == 8123 + + +@asyncio.coroutine +def test_map_hass_to_remote_port(hass, mock_miniupnpc): + """Test mapping hass to remote port.""" + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10', + 'ports': { + 'hass': 1000 + } + } + }) + + assert result + assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[0][1] + assert external == 1000 + assert internal == 8123 + + +@asyncio.coroutine +def test_map_internal_to_remote_ports(hass, mock_miniupnpc): + """Test mapping local to remote ports.""" + ports = OrderedDict() + ports['hass'] = 1000 + ports[1883] = 3883 + + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10', + 'ports': ports + } + }) + + assert result + assert len(mock_miniupnpc.addportmapping.mock_calls) == 2 + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[0][1] + assert external == 1000 + assert internal == 8123 + + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[1][1] + assert external == 3883 + assert internal == 1883 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + yield from hass.async_block_till_done() + assert len(mock_miniupnpc.deleteportmapping.mock_calls) == 2 + assert mock_miniupnpc.deleteportmapping.mock_calls[0][1][0] == 1000 + assert mock_miniupnpc.deleteportmapping.mock_calls[1][1][0] == 3883 From ff7db218b163b01edcb1d0dacc20081e682e9765 Mon Sep 17 00:00:00 2001 From: Paul Sokolovsky Date: Sun, 24 Sep 2017 23:19:05 +0300 Subject: [PATCH 18/94] Update yeelight to 0.3.3. (#9561) Fixes basic light control in case complex transition effects were defined on a light (possibly, externally to Home Assistant): https://gitlab.com/stavros/python-yeelight/issues/17 --- homeassistant/components/light/yeelight.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 1f7ee2ba5f9..82436334072 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -23,7 +23,7 @@ from homeassistant.components.light import ( Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yeelight==0.3.2'] +REQUIREMENTS = ['yeelight==0.3.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d94c6059a5b..9e5bf2e9a24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1048,7 +1048,7 @@ yahoo-finance==1.4.0 yahooweather==0.8 # homeassistant.components.light.yeelight -yeelight==0.3.2 +yeelight==0.3.3 # homeassistant.components.light.yeelightsunflower yeelightsunflower==0.0.8 From 515d1bdbd3029221c4fc9b85898ab705f0ec160c Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 25 Sep 2017 00:47:59 +0200 Subject: [PATCH 19/94] Add test cases and fix for device_defaults fire_event option. (#9567) * Add test cases and fix for device_defaults fire_event option. * Also for light. * Change docstring mood. --- homeassistant/components/light/rflink.py | 2 +- homeassistant/components/switch/rflink.py | 2 +- tests/components/switch/test_rflink.py | 83 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index 4308be107dd..a05822ed8d1 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -48,7 +48,7 @@ PLATFORM_SCHEMA = vol.Schema({ 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_FIRE_EVENT): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), vol.Optional(CONF_GROUP, default=True): cv.boolean, # deprecated config options diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 29e93342f66..366cb397d5b 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = vol.Schema({ 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_FIRE_EVENT): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), vol.Optional(CONF_GROUP, default=True): cv.boolean, # deprecated config options diff --git a/tests/components/switch/test_rflink.py b/tests/components/switch/test_rflink.py index f215b16d746..77a6b572e96 100644 --- a/tests/components/switch/test_rflink.py +++ b/tests/components/switch/test_rflink.py @@ -7,8 +7,10 @@ control of Rflink switch devices. import asyncio +from homeassistant.components.rflink import EVENT_BUTTON_PRESSED from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) +from homeassistant.core import callback from ..test_rflink import mock_rflink @@ -227,3 +229,84 @@ def test_nogroup_device_id(hass, monkeypatch): yield from hass.async_block_till_done() # should affect state assert hass.states.get(DOMAIN + '.test').state == 'on' + + +@asyncio.coroutine +def test_device_defaults(hass, monkeypatch): + """Event should fire if device_defaults config says so.""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'device_defaults': { + 'fire_event': True, + }, + 'devices': { + 'protocol_0_0': { + 'name': 'test', + 'aliases': ['test_alias_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + calls = [] + + @callback + def listener(event): + calls.append(event) + hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener) + + # test event for new unconfigured sensor + event_callback({ + 'id': 'protocol_0_0', + 'command': 'off', + }) + yield from hass.async_block_till_done() + + assert calls[0].data == {'state': 'off', 'entity_id': DOMAIN + '.test'} + + +@asyncio.coroutine +def test_not_firing_default(hass, monkeypatch): + """By default no bus events should be fired.""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'devices': { + 'protocol_0_0': { + 'name': 'test', + 'aliases': ['test_alias_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + calls = [] + + @callback + def listener(event): + calls.append(event) + hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener) + + # test event for new unconfigured sensor + event_callback({ + 'id': 'protocol_0_0', + 'command': 'off', + }) + yield from hass.async_block_till_done() + + assert not calls, 'an event has been fired' From 2486c9af35f0ef65d7af99771a1c934931033582 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 25 Sep 2017 00:48:30 +0200 Subject: [PATCH 20/94] Use simplepush module, enable event, and allow encrypted communication (#9568) * Use simplepush module, enable event, and allow encrypted communication * Fix check --- homeassistant/components/notify/simplepush.py | 49 ++++++++++--------- requirements_all.txt | 3 ++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/notify/simplepush.py b/homeassistant/components/notify/simplepush.py index cda6a6952e0..b4c65d116c4 100644 --- a/homeassistant/components/notify/simplepush.py +++ b/homeassistant/components/notify/simplepush.py @@ -6,49 +6,54 @@ https://home-assistant.io/components/notify.simplepush/ """ import logging -import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PASSWORD + +REQUIREMENTS = ['simplepush==1.1.3'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://api.simplepush.io/send' + +ATTR_ENCRYPTED = 'encrypted' CONF_DEVICE_KEY = 'device_key' - -DEFAULT_TIMEOUT = 10 +CONF_EVENT = 'event' +CONF_SALT = 'salt' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_KEY): cv.string, + vol.Optional(CONF_EVENT): cv.string, + vol.Inclusive(CONF_PASSWORD, ATTR_ENCRYPTED): cv.string, + vol.Inclusive(CONF_SALT, ATTR_ENCRYPTED): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the Simplepush notification service.""" - return SimplePushNotificationService(config.get(CONF_DEVICE_KEY)) + return SimplePushNotificationService(config) class SimplePushNotificationService(BaseNotificationService): - """Implementation of the notification service for SimplePush.""" + """Implementation of the notification service for Simplepush.""" - def __init__(self, device_key): - """Initialize the service.""" - self._device_key = device_key + def __init__(self, config): + """Initialize the Simplepush notification service.""" + self._device_key = config.get(CONF_DEVICE_KEY) + self._event = config.get(CONF_EVENT) + self._password = config.get(CONF_PASSWORD) + self._salt = config.get(CONF_SALT) def send_message(self, message='', **kwargs): - """Send a message to a user.""" + """Send a message to a Simplepush user.""" + from simplepush import send, send_encrypted + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - # Upstream bug will be fixed soon, but no dead-line available. - # payload = 'key={}&title={}&msg={}'.format( - # self._device_key, title, message).replace(' ', '%') - # response = requests.get( - # _RESOURCE, data=payload, timeout=DEFAULT_TIMEOUT) - response = requests.get( - '{}/{}/{}/{}'.format(_RESOURCE, self._device_key, title, message), - timeout=DEFAULT_TIMEOUT) - - if response.json()['status'] != 'OK': - _LOGGER.error("Not possible to send notification") + if self._password: + send_encrypted(self._device_key, self._password, self._salt, title, + message, event=self._event) + else: + send(self._device_key, title, message, event=self._event) diff --git a/requirements_all.txt b/requirements_all.txt index 9e5bf2e9a24..d091eb358ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -909,6 +909,9 @@ sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan shodan==1.7.5 +# homeassistant.components.notify.simplepush +simplepush==1.1.3 + # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 From fc4cd39cdd4c38ea00e1b343c7237df29d8671cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 24 Sep 2017 15:48:45 -0700 Subject: [PATCH 21/94] Add DuckDNS component (#9556) * Add DuckDNS component * Address comments --- homeassistant/components/duckdns.py | 102 ++++++++++++++++++++++++++ tests/components/test_duckdns.py | 106 ++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 homeassistant/components/duckdns.py create mode 100644 tests/components/test_duckdns.py diff --git a/homeassistant/components/duckdns.py b/homeassistant/components/duckdns.py new file mode 100644 index 00000000000..0045b9421a2 --- /dev/null +++ b/homeassistant/components/duckdns.py @@ -0,0 +1,102 @@ +"""Integrate with DuckDNS.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DOMAIN = 'duckdns' +UPDATE_URL = 'https://www.duckdns.org/update' +INTERVAL = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) +SERVICE_SET_TXT = 'set_txt' +ATTR_TXT = 'txt' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +@bind_hass +@asyncio.coroutine +def async_set_txt(hass, txt): + """Set the txt record. Pass in None to remove it.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, { + ATTR_TXT: txt + }, blocking=True) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_duckdns(session, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token) + + @asyncio.coroutine + def update_domain_service(call): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token, + txt=call.data[ATTR_TXT]) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, + schema=SERVICE_TXT_SCHEMA) + + return result + + +_SENTINEL = object() + + +@asyncio.coroutine +def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """Update DuckDNS.""" + params = { + 'domains': domain, + 'token': token, + } + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params['txt'] = '' + clear = True + else: + params['txt'] = txt + + if clear: + params['clear'] = 'true' + + resp = yield from session.get(UPDATE_URL, params=params) + body = yield from resp.text() + + if body != 'OK': + _LOGGER.warning('Updating DuckDNS domain %s failed', domain) + return False + + return True diff --git a/tests/components/test_duckdns.py b/tests/components/test_duckdns.py new file mode 100644 index 00000000000..d64ffbca81f --- /dev/null +++ b/tests/components/test_duckdns.py @@ -0,0 +1,106 @@ +"""Test the DuckDNS component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import duckdns +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +DOMAIN = 'bla' +TOKEN = 'abcdefgh' + + +@pytest.fixture +def setup_duckdns(hass, aioclient_mock): + """Fixture that sets up DuckDNS.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='OK') + + hass.loop.run_until_complete(async_setup_component( + hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='OK') + + result = yield from async_setup_component(hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(hass, aioclient_mock): + """Test setup fails if first update fails.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='KO') + + result = yield from async_setup_component(hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert not result + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_service_set_txt(hass, aioclient_mock, setup_duckdns): + """Test set txt service call.""" + # Empty the fixture mock requests + aioclient_mock.clear_requests() + + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN, + 'txt': 'some-txt', + }, text='OK') + + assert aioclient_mock.call_count == 0 + yield from hass.components.duckdns.async_set_txt('some-txt') + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_service_clear_txt(hass, aioclient_mock, setup_duckdns): + """Test clear txt service call.""" + # Empty the fixture mock requests + aioclient_mock.clear_requests() + + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN, + 'txt': '', + 'clear': 'true', + }, text='OK') + + assert aioclient_mock.call_count == 0 + yield from hass.components.duckdns.async_set_txt(None) + assert aioclient_mock.call_count == 1 From 1baf0da62780f96aa02d5168a984048c3215276e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 25 Sep 2017 09:05:09 -0700 Subject: [PATCH 22/94] Clean up OwnTracks (#9569) * Clean up OwnTracks * Address comments --- .../components/device_tracker/owntracks.py | 572 +++++++++--------- homeassistant/util/decorator.py | 14 + .../device_tracker/test_owntracks.py | 72 ++- .../device_tracker/test_upc_connect.py | 20 +- 4 files changed, 372 insertions(+), 306 deletions(-) create mode 100644 homeassistant/util/decorator.py diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 5c5c3c7c92e..1c773f97692 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -16,7 +16,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME -from homeassistant.util import convert, slugify +from homeassistant.util import slugify, decorator from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA @@ -25,6 +25,8 @@ REQUIREMENTS = ['libnacl==1.5.2'] _LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + BEACON_DEV_ID = 'beacon' CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' @@ -32,17 +34,7 @@ CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -EVENT_TOPIC = 'owntracks/+/+/event' - -LOCATION_TOPIC = 'owntracks/+/+' - -VALIDATE_LOCATION = 'location' -VALIDATE_TRANSITION = 'transition' -VALIDATE_WAYPOINTS = 'waypoints' - -WAYPOINT_LAT_KEY = 'lat' -WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints' +OWNTRACKS_TOPIC = 'owntracks/#' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), @@ -77,295 +69,61 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) secret = config.get(CONF_SECRET) - mobile_beacons_active = defaultdict(list) - regions_entered = defaultdict(list) + context = OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist) - def decrypt_payload(topic, ciphertext): - """Decrypt encrypted payload.""" + @asyncio.coroutine + def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - ciphertext = base64.b64decode(ciphertext) - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None - - def validate_payload(topic, payload, data_type): - """Validate the OwnTracks payload.""" - try: - data = json.loads(payload) + message = json.loads(payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return None - if isinstance(data, dict) and \ - data.get('_type') == 'encrypted' and \ - 'data' in data: - plaintext_payload = decrypt_payload(topic, data['data']) - if plaintext_payload is None: - return None - return validate_payload(topic, plaintext_payload, data_type) + message['topic'] = topic - if not isinstance(data, dict) or data.get('_type') != data_type: - _LOGGER.debug("Skipping %s update for following data " - "because of missing or malformatted data: %s", - data_type, data) - return None - if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS: - return data - if max_gps_accuracy is not None and \ - convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - data_type, max_gps_accuracy, payload) - return None - if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - data_type, payload) - return None - - return data - - @callback - def async_owntracks_location_update(topic, payload, qos): - """MQTT message received.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typelocation - data = validate_payload(topic, payload, VALIDATE_LOCATION) - if not data: - return - - dev_id, kwargs = _parse_see_args(topic, data) - - if regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - regions_entered[-1]) - return - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - @callback - def async_owntracks_event_update(topic, payload, qos): - """Handle MQTT event (geofences).""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typetransition - data = validate_payload(topic, payload, VALIDATE_TRANSITION) - if not data: - return - - if data.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") - if location.lower() == 'home': - location = STATE_HOME - - dev_id, kwargs = _parse_see_args(topic, data) - - def enter_event(): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - if zone is None and data.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile - beacons = mobile_beacons_active[dev_id] - if location not in beacons: - beacons.append(location) - _LOGGER.info("Added beacon %s", location) - else: - # Normal region - regions = regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - def leave_event(): - """Execute leave event.""" - regions = regions_entered[dev_id] - if location in regions: - regions.remove(location) - new_region = regions[-1] if regions else None - - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - else: - _LOGGER.info("Exit to GPS") - # Check for GPS accuracy - valid_gps = True - if 'acc' in data: - if data['acc'] == 0.0: - valid_gps = False - _LOGGER.warning( - "Ignoring GPS in region exit because accuracy" - "is zero: %s", payload) - if (max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - valid_gps = False - _LOGGER.info( - "Ignoring GPS in region exit because expected " - "GPS accuracy %s is not met: %s", - max_gps_accuracy, payload) - if valid_gps: - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - beacons = mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - - if data['event'] == 'enter': - enter_event() - elif data['event'] == 'leave': - leave_event() - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - data['event']) - return - - @callback - def async_owntracks_waypoint_update(topic, payload, qos): - """List of waypoints published by a user.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typewaypoints - data = validate_payload(topic, payload, VALIDATE_WAYPOINTS) - if not data: - return - - wayps = data['waypoints'] - _LOGGER.info("Got %d waypoints from %s", len(wayps), topic) - for wayp in wayps: - name = wayp['desc'] - pretty_name = parse_topic(topic, True)[1] + ' - ' + name - lat = wayp[WAYPOINT_LAT_KEY] - lon = wayp[WAYPOINT_LON_KEY] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - hass.async_add_job(zone.async_update_ha_state()) - - @callback - def async_see_beacons(dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - # the battery state applies to the tracking device, not the beacon - kwargs.pop('battery', None) - for beacon in mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - hass.async_add_job(async_see(**kwargs)) + yield from async_handle_message(hass, context, message) yield from mqtt.async_subscribe( - hass, LOCATION_TOPIC, async_owntracks_location_update, 1) - yield from mqtt.async_subscribe( - hass, EVENT_TOPIC, async_owntracks_event_update, 1) - - if waypoint_import: - if waypoint_whitelist is None: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format('+', '+'), - async_owntracks_waypoint_update, 1) - else: - for whitelist_user in waypoint_whitelist: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format(whitelist_user, '+'), - async_owntracks_waypoint_update, 1) + hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) return True -def parse_topic(topic, pretty=False): +def _parse_topic(topic): """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. Async friendly. """ - parts = topic.split('/') - dev_id_format = '' - if pretty: - dev_id_format = '{} {}' - else: - dev_id_format = '{}_{}' - dev_id = slugify(dev_id_format.format(parts[1], parts[2])) - host_name = parts[1] - return (host_name, dev_id) + _, user, device, *_ = topic.split('/', 3) + + return user, device -def _parse_see_args(topic, data): +def _parse_see_args(message): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - (host_name, dev_id) = parse_topic(topic, False) + user, device = _parse_topic(message['topic']) + dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, - 'host_name': host_name, - 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]), + 'host_name': user, + 'gps': (message['lat'], message['lon']), 'attributes': {} } - if 'acc' in data: - kwargs['gps_accuracy'] = data['acc'] - if 'batt' in data: - kwargs['battery'] = data['batt'] - if 'vel' in data: - kwargs['attributes']['velocity'] = data['vel'] - if 'tid' in data: - kwargs['attributes']['tid'] = data['tid'] - if 'addr' in data: - kwargs['attributes']['address'] = data['addr'] + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] return dev_id, kwargs @@ -382,3 +140,269 @@ def _set_gps_from_zone(kwargs, location, zone): kwargs['gps_accuracy'] = zone.attributes['radius'] kwargs['location_name'] = location return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + ciphertext = base64.b64decode(ciphertext) + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist): + """Initialize an OwnTracks context.""" + self.async_see = async_see + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(list) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + @asyncio.coroutine + def async_see_beacons(self, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + # the battery state applies to the tracking device, not the beacon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + yield from self.async_see(**kwargs) + + +@HANDLERS.register('location') +@asyncio.coroutine +def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + dev_id, kwargs = _parse_see_args(message) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so assume mobile + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.append(location) + _LOGGER.info("Added beacon %s", location) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + new_region = regions[-1] if regions else None + + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + return + + else: + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + + +@HANDLERS.register('transition') +@asyncio.coroutine +def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + yield from _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + yield from _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +@HANDLERS.register('waypoints') +@asyncio.coroutine +def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'])[0] + + if user not in context.waypoint_whitelist: + return + + wayps = message['waypoints'] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'])) + + for wayp in wayps: + name = wayp['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = wayp['lat'] + lon = wayp['lon'] + rad = wayp['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + continue + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('encrypted') +@asyncio.coroutine +def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + plaintext_payload = _decrypt_payload(context.secret, message['topic'], + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + decrypted['topic'] = message['topic'] + + yield from async_handle_message(hass, context, decrypted) + + +@asyncio.coroutine +def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + handler = HANDLERS.get(msgtype) + + if handler is None: + error = 'Received unsupported message type: {}.'.format(msgtype) + _LOGGER.warning(error) + + yield from handler(hass, context, message) diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py new file mode 100644 index 00000000000..c26606d52cf --- /dev/null +++ b/homeassistant/util/decorator.py @@ -0,0 +1,14 @@ +"""Decorator utility functions.""" + + +class Registry(dict): + """Registry of items.""" + + def register(self, name): + """Return decorator to register item with a specific name.""" + def decorator(func): + """Register decorated function.""" + self[name] = func + return func + + return decorator diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index e4944035261..3a23fe61d41 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,13 +1,12 @@ """The tests for the Owntracks device tracker.""" import asyncio import json -import os -from collections import defaultdict import unittest from unittest.mock import patch -from tests.common import (assert_setup_component, fire_mqtt_message, - get_test_home_assistant, mock_mqtt_component) +from tests.common import (assert_setup_component, fire_mqtt_message, mock_coro, + get_test_home_assistant, mock_mqtt_component, + mock_component) import homeassistant.components.device_tracker.owntracks as owntracks from homeassistant.setup import setup_component @@ -20,9 +19,9 @@ DEVICE = 'phone' LOCATION_TOPIC = 'owntracks/{}/{}'.format(USER, DEVICE) EVENT_TOPIC = 'owntracks/{}/{}/event'.format(USER, DEVICE) -WAYPOINT_TOPIC = owntracks.WAYPOINT_TOPIC.format(USER, DEVICE) +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE) USER_BLACKLIST = 'ram' -WAYPOINT_TOPIC_BLOCKED = owntracks.WAYPOINT_TOPIC.format( +WAYPOINT_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( USER_BLACKLIST, DEVICE) DEVICE_TRACKER_STATE = 'device_tracker.{}_{}'.format(USER, DEVICE) @@ -252,7 +251,26 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_mqtt_component(self.hass) - with assert_setup_component(1, device_tracker.DOMAIN): + mock_component(self.hass, 'group') + mock_component(self.hass, 'zone') + + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() + self.addCleanup(patcher.stop) + + orig_context = owntracks.OwnTracksContext + + def store_context(*args): + self.context = orig_context(*args) + return self.context + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', store_context), \ + assert_setup_component(1, device_tracker.DOMAIN): assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', @@ -290,18 +308,11 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): # Clear state between teste self.hass.states.set(DEVICE_TRACKER_STATE, None) - owntracks.REGIONS_ENTERED = defaultdict(list) - owntracks.MOBILE_BEACONS_ACTIVE = defaultdict(list) def teardown_method(self, _): """Stop everything that was started.""" self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - def assert_tracker_state(self, location): """Test the assertion of a tracker state.""" state = self.hass.states.get(REGION_TRACKER_STATE) @@ -372,7 +383,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assert_location_state('outer') # Left clean zone state - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_with_spaces(self): """Test the entry event.""" @@ -386,7 +397,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(EVENT_TOPIC, message) # Left clean zone state - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_entry_exit_inaccurate(self): """Test the event for inaccurate exit.""" @@ -405,7 +416,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assert_location_state('inner') # But does exit region correctly - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_entry_exit_zero_accuracy(self): """Test entry/exit events with accuracy zero.""" @@ -424,7 +435,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assert_location_state('inner') # But does exit region correctly - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_exit_outside_zone_sets_away(self): """Test the event for exit zone.""" @@ -604,7 +615,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.hass.block_till_done() self.send_message(EVENT_TOPIC, exit_message) - self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + self.assertEqual(self.context.mobile_beacons_active['greg_phone'], []) def test_mobile_multiple_enter_exit(self): """Test the multiple entering.""" @@ -618,7 +629,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(EVENT_TOPIC, enter_message) self.send_message(EVENT_TOPIC, exit_message) - self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + self.assertEqual(self.context.mobile_beacons_active['greg_phone'], []) def test_waypoint_import_simple(self): """Test a simple import of list of waypoints.""" @@ -706,6 +717,19 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_mqtt_component(self.hass) + mock_component(self.hass, 'group') + mock_component(self.hass, 'zone') + + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() + self.addCleanup(patch_load.stop) + + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() + self.addCleanup(patch_save.stop) def teardown_method(self, method): """Tear down resources.""" @@ -749,7 +773,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): # key missing }}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -762,7 +786,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): CONF_SECRET: 'wrong key', }}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -776,7 +800,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): LOCATION_TOPIC: 'wrong key' }}}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -790,7 +814,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' }}}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None try: import libnacl diff --git a/tests/components/device_tracker/test_upc_connect.py b/tests/components/device_tracker/test_upc_connect.py index 1ef3aefa6a4..396d2b88b19 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -1,11 +1,11 @@ """The tests for the UPC ConnextBox device tracker platform.""" import asyncio -import os from unittest.mock import patch import logging +import pytest + from homeassistant.setup import setup_component -from homeassistant.components import device_tracker from homeassistant.const import ( CONF_PLATFORM, CONF_HOST) from homeassistant.components.device_tracker import DOMAIN @@ -14,7 +14,7 @@ from homeassistant.util.async import run_coroutine_threadsafe from tests.common import ( get_test_home_assistant, assert_setup_component, load_fixture, - mock_component) + mock_component, mock_coro) _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,14 @@ def async_scan_devices_mock(scanner): return [] +@pytest.fixture(autouse=True) +def mock_load_config(): + """Mock device tracker loading config.""" + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])): + yield + + class TestUPCConnect(object): """Tests for the Ddwrt device tracker platform.""" @@ -32,16 +40,12 @@ class TestUPCConnect(object): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_component(self.hass, 'zone') + mock_component(self.hass, 'group') self.host = "127.0.0.1" def teardown_method(self): """Stop everything that was started.""" - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - self.hass.stop() @patch('homeassistant.components.device_tracker.upc_connect.' From a298b0790b8f10e7de2a80d885f2cc87a898758c Mon Sep 17 00:00:00 2001 From: marthoc <30442019+marthoc@users.noreply.github.com> Date: Mon, 25 Sep 2017 13:35:11 -0400 Subject: [PATCH 23/94] MQTT Cover: Add availability topic and configurable payloads (#9445) * MQTT Cover - Add availability_topic for online/offline status Added topic, configurable payloads, and tests. * Merge branch 'dev' into mqtt-cover-availability * Revert "Merge branch 'dev' into mqtt-cover-availability" This reverts commit 46d29794ba959e0394ff5c9904ae039a6df1d22e. * Added newline at end of test_mqtt.py * Fixed lint issue (newline at EOF) * Fixed lint issue (newline at EOF) * Updated call signature for other tests * Fixed availability message callback --- homeassistant/components/cover/mqtt.py | 54 +++++++++-- tests/components/cover/test_mqtt.py | 129 ++++++++++++++++++++----- 2 files changed, 150 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 8e197cc2e02..d10166a9469 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -21,8 +21,8 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - valid_publish_topic, valid_subscribe_topic) + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, + CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,8 @@ CONF_SET_POSITION_TEMPLATE = 'set_position_template' CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_STATE_OPEN = 'state_open' CONF_STATE_CLOSED = 'state_closed' CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' @@ -50,6 +52,8 @@ DEFAULT_NAME = 'MQTT Cover' DEFAULT_PAYLOAD_OPEN = 'OPEN' DEFAULT_PAYLOAD_CLOSE = 'CLOSE' DEFAULT_PAYLOAD_STOP = 'STOP' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' DEFAULT_OPTIMISTIC = False DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 @@ -69,11 +73,16 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_AVAILABILITY_TOPIC, default=None): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -106,6 +115,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_TILT_COMMAND_TOPIC), config.get(CONF_TILT_STATUS_TOPIC), config.get(CONF_QOS), @@ -115,6 +125,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OPEN), config.get(CONF_PAYLOAD_CLOSE), config.get(CONF_PAYLOAD_STOP), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_OPTIMISTIC), value_template, config.get(CONF_TILT_OPEN_POSITION), @@ -131,9 +143,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttCover(CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, command_topic, tilt_command_topic, - tilt_status_topic, qos, retain, state_open, state_closed, - payload_open, payload_close, payload_stop, + def __init__(self, name, state_topic, command_topic, availability_topic, + tilt_command_topic, tilt_status_topic, qos, retain, + state_open, state_closed, payload_open, payload_close, + payload_stop, payload_available, payload_not_available, optimistic, value_template, tilt_open_position, tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template): @@ -143,12 +156,16 @@ class MqttCover(CoverDevice): self._name = name self._state_topic = state_topic self._command_topic = command_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._tilt_command_topic = tilt_command_topic self._tilt_status_topic = tilt_status_topic self._qos = qos self._payload_open = payload_open self._payload_close = payload_close self._payload_stop = payload_stop + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._state_open = state_open self._state_closed = state_closed self._retain = retain @@ -181,8 +198,8 @@ class MqttCover(CoverDevice): self.async_schedule_update_ha_state() @callback - def message_received(topic, payload, qos): - """Handle new MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle new MQTT state messages.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -205,12 +222,28 @@ class MqttCover(CoverDevice): self.async_schedule_update_ha_state() + @callback + def availability_message_received(topic, payload, qos): + """Handle new MQTT availability messages.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + if self._state_topic is None: # Force into optimistic mode. self._optimistic = True else: yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self.hass, self._state_topic, + state_message_received, self._qos) + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) if self._tilt_status_topic is None: self._tilt_optimistic = True @@ -230,6 +263,11 @@ class MqttCover(CoverDevice): """Return the name of the cover.""" return self._name + @property + def available(self) -> bool: + """Return if cover is available.""" + return self._available + @property def is_closed(self): """Return if the cover is closed.""" diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 8b6202acdff..0b49e21674e 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -2,7 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN,\ + STATE_UNAVAILABLE import homeassistant.components.cover as cover from homeassistant.components.cover.mqtt import MqttCover @@ -570,71 +571,149 @@ class TestCoverMQTT(unittest.TestCase): def test_find_percentage_in_range_defaults(self): """Test find percentage in range with default range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, False, None, None) self.assertEqual(44, mqtt_cover.find_percentage_in_range(44)) def test_find_percentage_in_range_altered(self): """Test find percentage in range with altered range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, False, None, None) self.assertEqual(40, mqtt_cover.find_percentage_in_range(120)) def test_find_percentage_in_range_defaults_inverted(self): """Test find percentage in range with default range but inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, True, None, None) self.assertEqual(56, mqtt_cover.find_percentage_in_range(44)) def test_find_percentage_in_range_altered_inverted(self): """Test find percentage in range with altered range and inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, True, None, None) self.assertEqual(60, mqtt_cover.find_percentage_in_range(120)) def test_find_in_range_defaults(self): """Test find in range with default range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, False, None, None) self.assertEqual(44, mqtt_cover.find_in_range_from_percent(44)) def test_find_in_range_altered(self): """Test find in range with altered range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, False, None, None) self.assertEqual(120, mqtt_cover.find_in_range_from_percent(40)) def test_find_in_range_defaults_inverted(self): """Test find in range with default range but inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, True, None, None) self.assertEqual(44, mqtt_cover.find_in_range_from_percent(56)) def test_find_in_range_altered_inverted(self): """Test find in range with altered range and inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, True, None, None) self.assertEqual(120, mqtt_cover.find_in_range_from_percent(60)) + + def test_availability_without_topic(self): + """Test availability without defined availability topic.""" + self.assertTrue(setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic' + } + })) + + state = self.hass.states.get('cover.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_defaults(self): + """Test availability by defaults with defined topic.""" + self.assertTrue(setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_custom_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) From fafc4a60429b7e0cfe5e7c08b239f920e8d07b49 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 25 Sep 2017 22:19:44 +0200 Subject: [PATCH 24/94] Upgrade dsmr_parser to 0.11 (#9576) --- homeassistant/components/sensor/dsmr.py | 51 ++++++++----------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 17 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 4f360e860bd..2b303ac3c71 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -1,29 +1,8 @@ """ -Support for Dutch Smart Meter Requirements. - -Also known as: Smartmeter or P1 port. +Support for Dutch Smart Meter (also known as Smartmeter or P1 port). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.dsmr/ - -Technical overview: - -DSMR is a standard to which Dutch smartmeters must comply. It specifies that -the smartmeter must send out a 'telegram' every 10 seconds over a serial port. - -The contents of this telegram differ between version but they generally consist -of lines with 'obis' (Object Identification System, a numerical ID for a value) -followed with the value and unit. - -This module sets up a asynchronous reading loop using the `dsmr_parser` module -which waits for a complete telegram, parser it and puts it on an async queue as -a dictionary of `obis`/object mapping. The numeric value and unit of each value -can be read from the objects attributes. Because the `obis` are know for each -DSMR version the Entities for this component are create during bootstrap. - -Another loop (DSMR class) is setup which reads the telegram queue, -stores/caches the latest telegram and notifies the Entities that the telegram -has been updated. """ import asyncio from datetime import timedelta @@ -40,7 +19,7 @@ import voluptuous as vol _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['dsmr_parser==0.8'] +REQUIREMENTS = ['dsmr_parser==0.11'] CONF_DSMR_VERSION = 'dsmr_version' CONF_RECONNECT_INTERVAL = 'reconnect_interval' @@ -54,6 +33,7 @@ ICON_POWER = 'mdi:flash' # Smart meter sends telegram every 10 seconds MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + RECONNECT_INTERVAL = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -98,7 +78,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): else: gas_obis = obis_ref.GAS_METER_READING - # add gas meter reading and derivative for usage + # Add gas meter reading and derivative for usage devices += [ DSMREntity('Gas Consumption', gas_obis), DerivativeDSMREntity('Hourly Gas Consumption', gas_obis), @@ -107,7 +87,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices(devices) def update_entities_telegram(telegram): - """Update entities with latests telegram & trigger state update.""" + """Update entities with latests telegram and trigger state update.""" # Make all device entities aware of new telegram for device in devices: device.telegram = telegram @@ -127,7 +107,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @asyncio.coroutine def connect_and_reconnect(): - """Connect to DSMR and keep reconnecting until HA stops.""" + """Connect to DSMR and keep reconnecting until Home Assistant stops.""" while hass.state != CoreState.stopping: # Start DSMR asyncio.Protocol reader try: @@ -135,26 +115,26 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): reader_factory()) except (serial.serialutil.SerialException, ConnectionRefusedError, TimeoutError): - # log any error while establishing connection and drop to retry + # Log any error while establishing connection and drop to retry # connection wait _LOGGER.exception("Error connecting to DSMR") transport = None if transport: - # register listener to close transport on HA shutdown + # Register listener to close transport on HA shutdown stop_listerer = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, transport.close) - # wait for reader to close + # Wait for reader to close yield from protocol.wait_closed() if hass.state != CoreState.stopping: - # unexpected disconnect + # Unexpected disconnect if transport: # remove listerer stop_listerer() - # reflect disconnect state in devices state by setting an + # Reflect disconnect state in devices state by setting an # empty telegram resulting in `unknown` states update_entities_telegram({}) @@ -162,7 +142,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from asyncio.sleep(config[CONF_RECONNECT_INTERVAL], loop=hass.loop) - # Cannot be hass.async_add_job because job runs forever + # Can't be hass.async_add_job because job runs forever hass.loop.create_task(connect_and_reconnect()) @@ -181,7 +161,7 @@ class DSMREntity(Entity): if self._obis not in self.telegram: return None - # get the attribute value if the object has it + # Get the attribute value if the object has it dsmr_object = self.telegram[self._obis] return getattr(dsmr_object, attribute, None) @@ -237,7 +217,6 @@ class DerivativeDSMREntity(DSMREntity): Gas readings are only reported per hour and don't offer a rate only the current meter reading. This entity converts subsequents readings into a hourly rate. - """ _previous_reading = None @@ -265,11 +244,11 @@ class DerivativeDSMREntity(DSMREntity): current_reading = self.get_dsmr_object_attr('value') if self._previous_reading is None: - # can't calculate rate without previous datapoint + # Can't calculate rate without previous datapoint # just store current point pass else: - # recalculate the rate + # Recalculate the rate diff = current_reading - self._previous_reading self._state = diff diff --git a/requirements_all.txt b/requirements_all.txt index d091eb358ff..1ed0f287101 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ dnspython3==1.15.0 dovado==0.4.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.8 +dsmr_parser==0.11 # homeassistant.components.dweet # homeassistant.components.sensor.dweet diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79e872ffa4c..3a6cbacd6e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -37,7 +37,7 @@ aiohttp_cors==0.5.3 apns2==0.1.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.8 +dsmr_parser==0.11 # homeassistant.components.sensor.season ephem==3.7.6.0 From 896ba7e3fab22e503c342be99ccd359c4196d77a Mon Sep 17 00:00:00 2001 From: Timo S Date: Mon, 25 Sep 2017 22:27:27 +0200 Subject: [PATCH 25/94] Added new statistic attributes (#9433) * Added new statistic attributes Added new attributes: - Cleaning count - Total cleaning time - Total cleaning area - Time left to change main brush, side brush and filter * Code corrections Code corrections * Remove wronge hanging indentation * Added new attributes ATTR_MAIN_BRUSH_LEFT ATTR_SIDE_BRUSH_LEFT ATTR_FILTER_LEFT ATTR_CLEANING_COUNT ATTR_CLEANED_TOTAL_AREA ATTR_CLEANING_TOTAL_TIME * Remove trailing white space * Corrections of the unit test for new attributes * Hound corrections * Init self.clean_history, self.consumable_state * Hound correction * - Cleaning time and total cleaning time shown in minutes - Cleaned area and total cleaned area shown in square meters - Main brush left, side brush left, filter left time shown in hours - Display of the unit of measurement * Remove trailing white spaces * Fixed wrong continued indentation * Fixed Hound * Fixed Hound * Added new statistic attributes Added new attributes: - Cleaning count - Total cleaning time - Total cleaning area - Time left to change main brush, side brush and filter * Code corrections Code corrections * Remove wronge hanging indentation * Init self.clean_history, self.consumable_state * Hound correction * Remove UOM * Merge * Init self.clean_history, self.consumable_state * Hound correction * Init self.clean_history, self.consumable_state * Hound correction * Removed double declarations --- .../components/vacuum/xiaomi_miio.py | 33 ++- tests/components/vacuum/test_xiaomi_miio.py | 190 ++++++++++++++---- 2 files changed, 177 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 8e00c21877c..5747dd1dc9e 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -48,6 +48,12 @@ FAN_SPEEDS = { ATTR_CLEANING_TIME = 'cleaning_time' ATTR_DO_NOT_DISTURB = 'do_not_disturb' +ATTR_MAIN_BRUSH_LEFT = 'main_brush_left' +ATTR_SIDE_BRUSH_LEFT = 'side_brush_left' +ATTR_FILTER_LEFT = 'filter_left' +ATTR_CLEANING_COUNT = 'cleaning_count' +ATTR_CLEANED_TOTAL_AREA = 'total_cleaned_area' +ATTR_CLEANING_TOTAL_TIME = 'total_cleaning_time' ATTR_ERROR = 'error' ATTR_RC_DURATION = 'duration' ATTR_RC_ROTATION = 'rotation' @@ -147,6 +153,9 @@ class MiroboVacuum(VacuumDevice): self._is_on = False self._available = False + self.consumable_state = None + self.clean_history = None + @property def name(self): """Return the name of the device.""" @@ -194,8 +203,24 @@ class MiroboVacuum(VacuumDevice): STATE_ON if self.vacuum_state.dnd else STATE_OFF, # Not working --> 'Cleaning mode': # STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF, - ATTR_CLEANING_TIME: str(self.vacuum_state.clean_time), - ATTR_CLEANED_AREA: round(self.vacuum_state.clean_area, 2)}) + ATTR_CLEANING_TIME: int( + self.vacuum_state.clean_time.total_seconds() + / 60), + ATTR_CLEANED_AREA: int(self.vacuum_state.clean_area), + ATTR_CLEANING_COUNT: int(self.clean_history.count), + ATTR_CLEANED_TOTAL_AREA: int(self.clean_history.total_area), + ATTR_CLEANING_TOTAL_TIME: int( + self.clean_history.total_duration.total_seconds() + / 60), + ATTR_MAIN_BRUSH_LEFT: int( + self.consumable_state.main_brush_left.total_seconds() + / 3600), + ATTR_SIDE_BRUSH_LEFT: int( + self.consumable_state.side_brush_left.total_seconds() + / 3600), + ATTR_FILTER_LEFT: int( + self.consumable_state.filter_left.total_seconds() + / 3600)}) if self.vacuum_state.got_error: attrs[ATTR_ERROR] = self.vacuum_state.error @@ -346,6 +371,10 @@ class MiroboVacuum(VacuumDevice): state = yield from self.hass.async_add_job(self._vacuum.status) _LOGGER.debug("Got new state from the vacuum: %s", state.data) self.vacuum_state = state + self.consumable_state = yield from self.hass.async_add_job( + self._vacuum.consumable_status) + self.clean_history = yield from self.hass.async_add_job( + self._vacuum.clean_history) self._is_on = state.is_on self._available = True except OSError as exc: diff --git a/tests/components/vacuum/test_xiaomi_miio.py b/tests/components/vacuum/test_xiaomi_miio.py index 2693eaef833..bdb85abb057 100644 --- a/tests/components/vacuum/test_xiaomi_miio.py +++ b/tests/components/vacuum/test_xiaomi_miio.py @@ -13,6 +13,8 @@ from homeassistant.components.vacuum import ( SERVICE_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.components.vacuum.xiaomi_miio import ( ATTR_CLEANED_AREA, ATTR_CLEANING_TIME, ATTR_DO_NOT_DISTURB, ATTR_ERROR, + ATTR_MAIN_BRUSH_LEFT, ATTR_SIDE_BRUSH_LEFT, ATTR_FILTER_LEFT, + ATTR_CLEANING_COUNT, ATTR_CLEANED_TOTAL_AREA, ATTR_CLEANING_TOTAL_TIME, CONF_HOST, CONF_NAME, CONF_TOKEN, PLATFORM, SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP, SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL) @@ -36,6 +38,16 @@ def mock_mirobo_is_off(): mock_vacuum.Vacuum().status().clean_area = 123.43218 mock_vacuum.Vacuum().status().clean_time = timedelta( hours=2, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().main_brush_left = timedelta( + hours=12, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().side_brush_left = timedelta( + hours=12, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().filter_left = timedelta( + hours=12, minutes=35, seconds=34) + mock_vacuum.Vacuum().clean_history().count = '35' + mock_vacuum.Vacuum().clean_history().total_area = 123.43218 + mock_vacuum.Vacuum().clean_history().total_duration = timedelta( + hours=11, minutes=35, seconds=34) mock_vacuum.Vacuum().status().state = 'Test Xiaomi Charging' with mock.patch.dict('sys.modules', { @@ -57,6 +69,16 @@ def mock_mirobo_is_on(): mock_vacuum.Vacuum().status().clean_area = 133.43218 mock_vacuum.Vacuum().status().clean_time = timedelta( hours=2, minutes=55, seconds=34) + mock_vacuum.Vacuum().consumable_status().main_brush_left = timedelta( + hours=11, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().side_brush_left = timedelta( + hours=11, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().filter_left = timedelta( + hours=11, minutes=35, seconds=34) + mock_vacuum.Vacuum().clean_history().count = '41' + mock_vacuum.Vacuum().clean_history().total_area = 323.43218 + mock_vacuum.Vacuum().clean_history().total_duration = timedelta( + hours=11, minutes=15, seconds=34) mock_vacuum.Vacuum().status().state = 'Test Xiaomi Cleaning' with mock.patch.dict('sys.modules', { @@ -117,65 +139,111 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): assert state.attributes.get(ATTR_ERROR) == 'Error message' assert (state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-charging-80') - assert state.attributes.get(ATTR_CLEANING_TIME) == '2:35:34' - assert state.attributes.get(ATTR_CLEANED_AREA) == 123.43 + assert state.attributes.get(ATTR_CLEANING_TIME) == 155 + assert state.attributes.get(ATTR_CLEANED_AREA) == 123 assert state.attributes.get(ATTR_FAN_SPEED) == 'Quiet' assert (state.attributes.get(ATTR_FAN_SPEED_LIST) == ['Quiet', 'Balanced', 'Turbo', 'Max']) + assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 12 + assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 12 + assert state.attributes.get(ATTR_FILTER_LEFT) == 12 + assert state.attributes.get(ATTR_CLEANING_COUNT) == 35 + assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 123 + assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 695 # Call services yield from hass.services.async_call( DOMAIN, SERVICE_TURN_ON, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().start()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().start()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().home()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().home()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_TOGGLE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().start()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().start()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_STOP, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().stop()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().stop()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_START_PAUSE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().start()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().start()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_RETURN_TO_BASE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().home()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().home()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_LOCATE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().find()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().find()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_CLEAN_SPOT, {}, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().spot()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().spot()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') # Set speed service: yield from hass.services.async_call( DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": 60}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().set_fan_speed(60)') - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": "turbo"}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().set_fan_speed(77)') - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') assert 'ERROR' not in caplog.text yield from hass.services.async_call( @@ -185,16 +253,24 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): yield from hass.services.async_call( DOMAIN, SERVICE_SEND_COMMAND, {"command": "raw"}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == "call.Vacuum().raw_command('raw', None)") - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_SEND_COMMAND, {"command": "raw", "params": {"k1": 2}}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == "call.Vacuum().raw_command('raw', {'k1': 2})") - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') @asyncio.coroutine @@ -220,48 +296,74 @@ def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): assert state.attributes.get(ATTR_ERROR) is None assert (state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-30') - assert state.attributes.get(ATTR_CLEANING_TIME) == '2:55:34' - assert state.attributes.get(ATTR_CLEANED_AREA) == 133.43 + assert state.attributes.get(ATTR_CLEANING_TIME) == 175 + assert state.attributes.get(ATTR_CLEANED_AREA) == 133 assert state.attributes.get(ATTR_FAN_SPEED) == 99 assert (state.attributes.get(ATTR_FAN_SPEED_LIST) == ['Quiet', 'Balanced', 'Turbo', 'Max']) + assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 11 + assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 11 + assert state.attributes.get(ATTR_FILTER_LEFT) == 11 + assert state.attributes.get(ATTR_CLEANING_COUNT) == 41 + assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 323 + assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 675 # Check setting pause yield from hass.services.async_call( DOMAIN, SERVICE_START_PAUSE, blocking=True) - assert str(mock_mirobo_is_on.mock_calls[-2]) == 'call.Vacuum().pause()' - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_on.mock_calls[-4]) == 'call.Vacuum().pause()' + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') # Xiaomi vacuum specific services: yield from hass.services.async_call( DOMAIN, SERVICE_START_REMOTE_CONTROL, {ATTR_ENTITY_ID: entity_id}, blocking=True) - assert (str(mock_mirobo_is_on.mock_calls[-2]) + assert (str(mock_mirobo_is_on.mock_calls[-4]) == "call.Vacuum().manual_start()") - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_MOVE_REMOTE_CONTROL, {"duration": 1000, "rotation": -40, "velocity": -0.1}, blocking=True) assert ('call.Vacuum().manual_control(' - in str(mock_mirobo_is_on.mock_calls[-2])) - assert 'duration=1000' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'rotation=-40' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'velocity=-0.1' in str(mock_mirobo_is_on.mock_calls[-2]) - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + in str(mock_mirobo_is_on.mock_calls[-4])) + assert 'duration=1000' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'rotation=-40' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'velocity=-0.1' in str(mock_mirobo_is_on.mock_calls[-4]) + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_STOP_REMOTE_CONTROL, {}, blocking=True) - assert (str(mock_mirobo_is_on.mock_calls[-2]) + assert (str(mock_mirobo_is_on.mock_calls[-4]) == "call.Vacuum().manual_stop()") - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_MOVE_REMOTE_CONTROL_STEP, {"duration": 2000, "rotation": 120, "velocity": 0.1}, blocking=True) assert ('call.Vacuum().manual_control_once(' - in str(mock_mirobo_is_on.mock_calls[-2])) - assert 'duration=2000' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'rotation=120' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'velocity=0.1' in str(mock_mirobo_is_on.mock_calls[-2]) - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + in str(mock_mirobo_is_on.mock_calls[-4])) + assert 'duration=2000' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'rotation=120' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'velocity=0.1' in str(mock_mirobo_is_on.mock_calls[-4]) + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') From 4a6a53c1adb0af94361acaf307372ee6eb3f05ae Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 25 Sep 2017 22:34:05 +0200 Subject: [PATCH 26/94] Upgrade youtube_dl to 2017.9.24 (#9575) --- 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 353eeae1607..188330de1c6 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.15'] +REQUIREMENTS = ['youtube_dl==2017.9.24'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1ed0f287101..43bc62cd0a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1057,7 +1057,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.9.15 +youtube_dl==2017.9.24 # homeassistant.components.light.zengge zengge==0.2 From cf8e6d8d86e49e7cf69b669b8e1ddfd1dadfcdc1 Mon Sep 17 00:00:00 2001 From: Enrique Gonzalez Date: Mon, 25 Sep 2017 13:34:48 -0700 Subject: [PATCH 27/94] Upgrade lyft_rides to 0.2 (#9578) --- homeassistant/components/sensor/lyft.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index 11ca07f7fb8..0efc4063dc2 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['lyft_rides==0.1.0b0'] +REQUIREMENTS = ['lyft_rides==0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 43bc62cd0a0..659de15a860 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -401,7 +401,7 @@ liveboxplaytv==1.4.9 lmnotify==0.0.4 # homeassistant.components.sensor.lyft -lyft_rides==0.1.0b0 +lyft_rides==0.2 # homeassistant.components.notify.matrix matrix-client==0.0.6 From bf176c405ab7f22fa98fa04bb802d93298f8a3a8 Mon Sep 17 00:00:00 2001 From: joe248 Date: Tue, 26 Sep 2017 01:43:02 -0500 Subject: [PATCH 28/94] Increase Comed timeout since it sometimes takes a long time for the API to respond (#9536) * Increase Comed timeout since it sometimes takes a long time for the API to respond * Rewrite ComEd sensor to use asyncio * Fix whitespace and build issues --- .../components/sensor/comed_hourly_pricing.py | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sensor/comed_hourly_pricing.py b/homeassistant/components/sensor/comed_hourly_pricing.py index c6a4a38c3b2..01e9f443e0e 100644 --- a/homeassistant/components/sensor/comed_hourly_pricing.py +++ b/homeassistant/components/sensor/comed_hourly_pricing.py @@ -6,14 +6,17 @@ https://home-assistant.io/components/sensor.comed_hourly_pricing/ """ from datetime import timedelta import logging +import asyncio +import json +import async_timeout +import aiohttp import voluptuous as vol -from requests import RequestException, get - import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://hourlypricing.comed.com/api' @@ -46,22 +49,27 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -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 ComEd Hourly Pricing sensor.""" + websession = async_get_clientsession(hass) dev = [] + for variable in config[CONF_MONITORED_FEEDS]: dev.append(ComedHourlyPricingSensor( - variable[CONF_SENSOR_TYPE], variable[CONF_OFFSET], - variable.get(CONF_NAME))) + hass.loop, websession, variable[CONF_SENSOR_TYPE], + variable[CONF_OFFSET], variable.get(CONF_NAME))) - add_devices(dev, True) + async_add_devices(dev, True) class ComedHourlyPricingSensor(Entity): """Implementation of a ComEd Hourly Pricing sensor.""" - def __init__(self, sensor_type, offset, name): + def __init__(self, loop, websession, sensor_type, offset, name): """Initialize the sensor.""" + self.loop = loop + self.websession = websession if name: self._name = name else: @@ -92,20 +100,30 @@ class ComedHourlyPricingSensor(Entity): attrs = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} return attrs - def update(self): + @asyncio.coroutine + def async_update(self): """Get the ComEd Hourly Pricing data from the web service.""" try: - if self.type == CONF_FIVE_MINUTE: - url_string = _RESOURCE + '?type=5minutefeed' - response = get(url_string, timeout=10) - self._state = round( - float(response.json()[0]['price']) + self.offset, 2) - elif self.type == CONF_CURRENT_HOUR_AVERAGE: - url_string = _RESOURCE + '?type=currenthouraverage' - response = get(url_string, timeout=10) - self._state = round( - float(response.json()[0]['price']) + self.offset, 2) + if self.type == CONF_FIVE_MINUTE or \ + self.type == CONF_CURRENT_HOUR_AVERAGE: + url_string = _RESOURCE + if self.type == CONF_FIVE_MINUTE: + url_string += '?type=5minutefeed' + else: + url_string += '?type=currenthouraverage' + + with async_timeout.timeout(60, loop=self.loop): + response = yield from self.websession.get(url_string) + # The API responds with MIME type 'text/html' + text = yield from response.text() + data = json.loads(text) + self._state = round( + float(data[0]['price']) + self.offset, 2) + else: self._state = STATE_UNKNOWN - except (RequestException, ValueError, KeyError): + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Could not get data from ComEd API: %s", err) + except (ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) From 8a3dcbf10f7918d48894ce08766f643ccc0e04c1 Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Tue, 26 Sep 2017 00:03:40 -0700 Subject: [PATCH 29/94] Allow customizable turn on action for LG WebOS tv (#9206) * allow customizable action for webos tv turn on as not all models allow for WOL * trying to fix the houndci-bot * last few fixes hopefully * I guess not * last time! * This is a breaking change. I have removed the build-in wake-on-lan functionality and have opted for a script which can be a wake-on-lan switch. I have also removed any reference to wol. * hoping to fix formatting * linter errors --- .../components/media_player/webostv.py | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 65a999528c3..8df8ceb0a8e 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -19,10 +19,11 @@ from homeassistant.components.media_player import ( SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_CUSTOMIZE, CONF_TIMEOUT, STATE_OFF, + CONF_HOST, CONF_CUSTOMIZE, CONF_TIMEOUT, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import Script REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==3.2', @@ -32,6 +33,7 @@ _CONFIGURING = {} # type: Dict[str, str] _LOGGER = logging.getLogger(__name__) CONF_SOURCES = 'sources' +CONF_ON_ACTION = 'turn_on_action' DEFAULT_NAME = 'LG webOS Smart TV' @@ -53,10 +55,10 @@ CUSTOMIZE_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, }) @@ -76,15 +78,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if host in _CONFIGURING: return - mac = config.get(CONF_MAC) name = config.get(CONF_NAME) customize = config.get(CONF_CUSTOMIZE) timeout = config.get(CONF_TIMEOUT) + turn_on_action = config.get(CONF_ON_ACTION) + config = hass.config.path(config.get(CONF_FILENAME)) - setup_tv(host, mac, name, customize, config, timeout, hass, add_devices) + + setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action) -def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): +def setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action): """Set up a LG WebOS TV based on host parameter.""" from pylgtv import WebOsClient from pylgtv import PyLGTVPairException @@ -108,7 +114,8 @@ def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): # Not registered, request configuration. _LOGGER.warning("LG webOS TV %s needs to be paired", host) request_configuration( - host, mac, name, customize, config, timeout, hass, add_devices) + host, name, customize, config, timeout, hass, + add_devices, turn_on_action) return # If we came here and configuring this host, mark as done. @@ -117,12 +124,13 @@ def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): configurator = hass.components.configurator configurator.request_done(request_id) - add_devices([LgWebOSDevice(host, mac, name, customize, config, timeout)], - True) + add_devices([LgWebOSDevice(host, name, customize, config, timeout, + hass, turn_on_action)], True) def request_configuration( - host, mac, name, customize, config, timeout, hass, add_devices): + host, name, customize, config, timeout, hass, + add_devices, turn_on_action): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -135,8 +143,8 @@ def request_configuration( # pylint: disable=unused-argument def lgtv_configuration_callback(data): """The actions to do when our configuration callback is called.""" - setup_tv(host, mac, name, customize, config, timeout, hass, - add_devices) + setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action) _CONFIGURING[host] = configurator.request_config( name, lgtv_configuration_callback, @@ -149,13 +157,12 @@ def request_configuration( class LgWebOSDevice(MediaPlayerDevice): """Representation of a LG WebOS TV.""" - def __init__(self, host, mac, name, customize, config, timeout): + def __init__(self, host, name, customize, config, timeout, + hass, on_action): """Initialize the webos device.""" from pylgtv import WebOsClient - from wakeonlan import wol self._client = WebOsClient(host, config, timeout) - self._wol = wol - self._mac = mac + self._on_script = Script(hass, on_action) if on_action else None self._customize = customize self._name = name @@ -273,7 +280,7 @@ class LgWebOSDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - if self._mac: + if self._on_script: return SUPPORT_WEBOSTV | SUPPORT_TURN_ON return SUPPORT_WEBOSTV @@ -289,8 +296,8 @@ class LgWebOSDevice(MediaPlayerDevice): def turn_on(self): """Turn on the media player.""" - if self._mac: - self._wol.send_magic_packet(self._mac) + if self._on_script: + self._on_script.run() def volume_up(self): """Volume up the media player.""" From 154b070eaebca02d58a36931daa50548e793db31 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 26 Sep 2017 09:26:26 +0200 Subject: [PATCH 30/94] IMAP Unread sensor updated for async and push (#9562) * IMAP Unread sensor updated for async and push * Implement renames suggested in review * Use async_timeout * Keep push capability in a variable * Reword for Hound --- homeassistant/components/sensor/imap.py | 155 ++++++++++++++++++------ requirements_all.txt | 3 + 2 files changed, 118 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index 849f3fd8100..9d66537079f 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -5,20 +5,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.imap/ """ import logging +import asyncio +import async_timeout import voluptuous as vol from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD) + CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_SERVER = "server" +REQUIREMENTS = ['aioimaplib==0.7.12'] + +CONF_SERVER = 'server' DEFAULT_PORT = 993 + ICON = 'mdi:email-outline' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -30,17 +37,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the IMAP platform.""" - sensor = ImapSensor( - config.get(CONF_NAME, None), config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), config.get(CONF_SERVER), - config.get(CONF_PORT)) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup the IMAP platform.""" + sensor = ImapSensor(config.get(CONF_NAME), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_SERVER), + config.get(CONF_PORT)) - if sensor.connection: - add_devices([sensor], True) - else: - return False + if not (yield from sensor.connection()): + raise PlatformNotReady + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.shutdown()) + async_add_devices([sensor], True) class ImapSensor(Entity): @@ -54,45 +64,110 @@ class ImapSensor(Entity): self._server = server self._port = port self._unread_count = 0 - self.connection = self._login() + self._connection = None + self._does_push = None + self._idle_loop_task = None - def _login(self): - """Login and return an IMAP connection.""" - import imaplib - try: - connection = imaplib.IMAP4_SSL(self._server, self._port) - connection.login(self._user, self._password) - return connection - except imaplib.IMAP4.error: - _LOGGER.error("Failed to login to %s.", self._server) - return False + @asyncio.coroutine + def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + if not self.should_poll: + self._idle_loop_task = self.hass.loop.create_task(self.idle_loop()) @property def name(self): """Return the name of the sensor.""" return self._name + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + @property def state(self): """Return the number of unread emails.""" return self._unread_count - def update(self): - """Check the number of unread emails.""" - import imaplib - try: - self.connection.select() - self._unread_count = len(self.connection.search( - None, 'UnSeen UnDeleted')[1][0].split()) - except imaplib.IMAP4.error: - _LOGGER.info("Connection to %s lost, attempting to reconnect", - self._server) - try: - self.connection = self._login() - except imaplib.IMAP4.error: - _LOGGER.error("Failed to reconnect.") + @property + def available(self): + """Return the availability of the device.""" + return self._connection is not None @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON + def should_poll(self): + """Return if polling is needed.""" + return not self._does_push + + @asyncio.coroutine + def connection(self): + """Return a connection to the server, establishing it if necessary.""" + import aioimaplib + + if self._connection is None: + try: + self._connection = aioimaplib.IMAP4_SSL( + self._server, self._port) + yield from self._connection.wait_hello_from_server() + yield from self._connection.login(self._user, self._password) + yield from self._connection.select() + self._does_push = self._connection.has_capability('IDLE') + except (aioimaplib.AioImapException, asyncio.TimeoutError): + self._connection = None + + return self._connection + + @asyncio.coroutine + def idle_loop(self): + """Wait for data pushed from server.""" + import aioimaplib + + while True: + try: + if (yield from self.connection()): + yield from self.refresh_unread_count() + yield from self.async_update_ha_state() + + idle = yield from self._connection.idle_start() + yield from self._connection.wait_server_push() + self._connection.idle_done() + with async_timeout.timeout(10): + yield from idle + else: + yield from self.async_update_ha_state() + except (aioimaplib.AioImapException, asyncio.TimeoutError): + self.disconnected() + + @asyncio.coroutine + def async_update(self): + """Periodic polling of state.""" + import aioimaplib + + try: + if (yield from self.connection()): + yield from self.refresh_unread_count() + except (aioimaplib.AioImapException, asyncio.TimeoutError): + self.disconnected() + + @asyncio.coroutine + def refresh_unread_count(self): + """Check the number of unread emails.""" + if self._connection: + yield from self._connection.noop() + _, lines = yield from self._connection.search('UnSeen UnDeleted') + self._unread_count = len(lines[0].split()) + + def disconnected(self): + """Forget the connection after it was lost.""" + _LOGGER.warning("Lost %s (will attempt to reconnect)", self._server) + self._connection = None + + @asyncio.coroutine + def shutdown(self): + """Close resources.""" + if self._connection: + if self._connection.has_pending_idle(): + self._connection.idle_done() + yield from self._connection.logout() + if self._idle_loop_task: + self._idle_loop_task.cancel() diff --git a/requirements_all.txt b/requirements_all.txt index 659de15a860..022e88861af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -57,6 +57,9 @@ aiodns==1.1.1 # homeassistant.components.http aiohttp_cors==0.5.3 +# homeassistant.components.sensor.imap +aioimaplib==0.7.12 + # homeassistant.components.light.lifx aiolifx==0.6.0 From fd9ceb73811f6698afe60318728e6cdc0dfba0cf Mon Sep 17 00:00:00 2001 From: rbflurry Date: Tue, 26 Sep 2017 03:31:35 -0400 Subject: [PATCH 31/94] Replace emulated_hue: with emulated_hue_hidden: for consistency. (#9382) * Update __init__.py * fix lint errors * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py Lint errors * use get_deprecated instead to log old attr * Updated tests to hide fan.ceiling_fan * remove space fix lint --- .../components/emulated_hue/__init__.py | 16 +++++++++++++--- tests/components/emulated_hue/test_hue_api.py | 9 +++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 2feea724cb7..a83f5337cae 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.components.http import REQUIREMENTS # NOQA from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv from .hue_api import ( HueUsernameView, HueAllLightsStateView, HueOneLightStateView, @@ -66,6 +67,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) ATTR_EMULATED_HUE = 'emulated_hue' +ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' def setup(hass, yaml_config): @@ -223,7 +225,15 @@ class Config(object): domain = entity.domain.lower() explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) - + explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None) + if explicit_expose is True or explicit_hidden is False: + expose = True + elif explicit_expose is False or explicit_hidden is True: + expose = False + else: + expose = None + get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN, + ATTR_EMULATED_HUE, None) domain_exposed_by_default = \ self.expose_by_default and domain in self.exposed_domains @@ -231,9 +241,9 @@ class Config(object): # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed is_default_exposed = \ - domain_exposed_by_default and explicit_expose is not False + domain_exposed_by_default and expose is not False - return is_default_exposed or explicit_expose + return is_default_exposed or expose def _load_numbers_json(self): """Set up helper method to load numbers json.""" diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 0d2f0d24da0..cc03324a638 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -99,6 +99,14 @@ def hass_hue(loop, hass): kitchen_light_entity.entity_id, kitchen_light_entity.state, attributes=attrs) + # Ceiling Fan is explicitly excluded from being exposed + ceiling_fan_entity = hass.states.get('fan.ceiling_fan') + attrs = dict(ceiling_fan_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = True + hass.states.async_set( + ceiling_fan_entity.entity_id, ceiling_fan_entity.state, + attributes=attrs) + # Expose the script script_entity = hass.states.get('script.set_kitchen_light') attrs = dict(script_entity.attributes) @@ -146,6 +154,7 @@ def test_discover_lights(hue_client): assert 'media_player.walkman' in devices assert 'media_player.lounge_room' in devices assert 'fan.living_room_fan' in devices + assert 'fan.ceiling_fan' not in devices @asyncio.coroutine From 475f6f5f8230c64221a7a6470c640f3f38ad6c53 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 26 Sep 2017 13:11:10 +0200 Subject: [PATCH 32/94] Upgrade Sphinx to 1.6.4 (#9584) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index ef9487836c0..0d1f2a95fa2 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.3 +Sphinx==1.6.4 sphinx-autodoc-typehints==1.2.3 sphinx-autodoc-annotation==1.0.post1 From 9d839f1f53b4a1b07e924cad9e3b6a5cef59b0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 26 Sep 2017 21:01:17 +0200 Subject: [PATCH 33/94] Bump pyatv to 0.3.5 (#9586) --- homeassistant/components/apple_tv.py | 2 +- homeassistant/components/media_player/apple_tv.py | 3 ++- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 4fce508ba7e..5e02f80f229 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.4'] +REQUIREMENTS = ['pyatv==0.3.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 6bd962ef443..c2c70984734 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -96,7 +96,8 @@ class AppleTvDevice(MediaPlayerDevice): if self._playing: from pyatv import const state = self._playing.play_state - if state == const.PLAY_STATE_NO_MEDIA or \ + if state == const.PLAY_STATE_IDLE or \ + state == const.PLAY_STATE_NO_MEDIA or \ state == const.PLAY_STATE_LOADING: return STATE_IDLE elif state == const.PLAY_STATE_PLAYING: diff --git a/requirements_all.txt b/requirements_all.txt index 022e88861af..b3f3398501a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -567,7 +567,7 @@ pyasn1-modules==0.1.4 pyasn1==0.3.6 # homeassistant.components.apple_tv -pyatv==0.3.4 +pyatv==0.3.5 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From 312de6b3a369bdc58dda94b2a78167ef38821cee Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Wed, 27 Sep 2017 02:17:55 -0400 Subject: [PATCH 34/94] New Wink services. pair new device, rename, and delete, add new lock key code. Add water heater support (#9303) * Pair new device, rename, delete, and lock key code services. Also add water heater support. * Fixed tox --- .../components/binary_sensor/wink.py | 5 +- homeassistant/components/climate/__init__.py | 6 + homeassistant/components/climate/demo.py | 4 +- homeassistant/components/climate/wink.py | 251 +++++++++++------- homeassistant/components/lock/services.yaml | 23 +- homeassistant/components/lock/wink.py | 35 ++- homeassistant/components/services.yaml | 52 +++- homeassistant/components/wink.py | 109 +++++++- requirements_all.txt | 2 +- 9 files changed, 363 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index b4910687da7..05de0b51aa8 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -136,8 +136,9 @@ class WinkHub(WinkBinarySensorDevice): def device_state_attributes(self): """Return the state attributes.""" return { - 'update needed': self.wink.update_needed(), - 'firmware version': self.wink.firmware_version() + 'update_needed': self.wink.update_needed(), + 'firmware_version': self.wink.firmware_version(), + 'pairing_mode': self.wink.pairing_mode() } diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 8ccc3b2d663..53e60380a38 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -44,6 +44,12 @@ STATE_IDLE = 'idle' STATE_AUTO = 'auto' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' ATTR_CURRENT_TEMPERATURE = 'current_temperature' ATTR_MAX_TEMP = 'max_temp' diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 0880cb3db8f..377985aaa12 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -114,7 +114,7 @@ class DemoClimate(ClimateDevice): @property def is_aux_heat_on(self): - """Return true if away mode is on.""" + """Return true if aux heat is on.""" return self._aux @property @@ -183,7 +183,7 @@ class DemoClimate(ClimateDevice): self.schedule_update_ha_state() def turn_aux_heat_on(self): - """Turn away auxiliary heater on.""" + """Turn auxillary heater on.""" self._aux = True self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 90b101e1b7b..f72cefc0841 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -1,30 +1,45 @@ """ -Support for Wink thermostats. +Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ +import logging import asyncio from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, - ATTR_CURRENT_HUMIDITY) + ATTR_TEMPERATURE, STATE_FAN_ONLY, + ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC, + STATE_PERFORMANCE, STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, STATE_GAS) from homeassistant.const import ( TEMP_CELSIUS, STATE_ON, STATE_OFF, STATE_UNKNOWN) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['wink'] -STATE_AUX = 'aux' -STATE_ECO = 'eco' -STATE_FAN = 'fan' SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' +HA_STATE_TO_WINK = {STATE_AUTO: 'auto', + STATE_ECO: 'eco', + STATE_FAN_ONLY: 'fan_only', + STATE_HEAT: 'heat_only', + STATE_COOL: 'cool_only', + STATE_PERFORMANCE: 'performance', + STATE_HIGH_DEMAND: 'high_demand', + STATE_HEAT_PUMP: 'heat_pump', + STATE_ELECTRIC: 'electric_only', + STATE_GAS: 'gas', + STATE_OFF: 'off'} +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} + ATTR_EXTERNAL_TEMPERATURE = "external_temperature" ATTR_SMART_TEMPERATURE = "smart_temperature" ATTR_ECO_TARGET = "eco_target" @@ -32,28 +47,26 @@ ATTR_OCCUPIED = "occupied" def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Wink thermostat.""" + """Set up the Wink climate devices.""" import pywink - temp_unit = hass.config.units.temperature_unit for climate in pywink.get_thermostats(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkThermostat(climate, hass, temp_unit)]) + add_devices([WinkThermostat(climate, hass)]) for climate in pywink.get_air_conditioners(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkAC(climate, hass, temp_unit)]) + add_devices([WinkAC(climate, hass)]) + for water_heater in pywink.get_water_heaters(): + _id = water_heater.object_id() + water_heater.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkWaterHeater(water_heater, hass)]) # pylint: disable=abstract-method class WinkThermostat(WinkDevice, ClimateDevice): """Representation of a Wink thermostat.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @asyncio.coroutine def async_added_to_hass(self): """Callback when entity is added to hass.""" @@ -139,18 +152,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_hvac_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_hvac_mode() == 'heat_only': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'aux': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'auto': - current_op = STATE_AUTO - elif self.wink.current_hvac_mode() == 'eco': - current_op = STATE_ECO else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op == 'aux': + return STATE_HEAT + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -199,11 +206,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): @property def is_aux_heat_on(self): """Return true if aux heater.""" - if self.wink.current_hvac_mode() == 'aux' and self.wink.is_on(): + if 'aux' not in self.wink.hvac_modes(): + return None + + if self.wink.current_hvac_mode() == 'aux': return True - elif self.wink.current_hvac_mode() == 'aux' and not self.wink.is_on(): - return False - return None + return False def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -223,32 +231,27 @@ class WinkThermostat(WinkDevice, ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_HEAT: - self.wink.set_operation_mode('heat_only') - elif operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_AUTO: - self.wink.set_operation_mode('auto') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_AUX: - self.wink.set_operation_mode('aux') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('eco') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + # The only way to disable aux heat is with the toggle + if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT: + return + self.wink.set_operation_mode(op_mode_to_set) @property def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.hvac_modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'heat_only' in modes or 'aux' in modes: - op_list.append(STATE_HEAT) - if 'auto' in modes: - op_list.append(STATE_AUTO) - if 'eco' in modes: - op_list.append(STATE_ECO) + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def turn_away_mode_on(self): @@ -282,11 +285,11 @@ class WinkThermostat(WinkDevice, ClimateDevice): def turn_aux_heat_on(self): """Turn auxiliary heater on.""" - self.set_operation_mode(STATE_AUX) + self.wink.set_operation_mode('aux') def turn_aux_heat_off(self): """Turn auxiliary heater off.""" - self.set_operation_mode(STATE_AUTO) + self.set_operation_mode(STATE_HEAT) @property def min_temp(self): @@ -344,11 +347,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): class WinkAC(WinkDevice, ClimateDevice): """Representation of a Wink air conditioner.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @property def temperature_unit(self): """Return the unit of measurement.""" @@ -382,14 +380,10 @@ class WinkAC(WinkDevice, ClimateDevice): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_mode() == 'auto_eco': - current_op = STATE_ECO - elif self.wink.current_mode() == 'fan_only': - current_op = STATE_FAN else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -397,12 +391,14 @@ class WinkAC(WinkDevice, ClimateDevice): """List of available operation modes.""" op_list = ['off'] modes = self.wink.modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'auto_eco' in modes: - op_list.append(STATE_ECO) - if 'fan_only' in modes: - op_list.append(STATE_FAN) + for mode in modes: + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def set_temperature(self, **kwargs): @@ -412,30 +408,16 @@ class WinkAC(WinkDevice, ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('auto_eco') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_FAN: - self.wink.set_operation_mode('fan_only') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + if op_mode_to_set == 'eco': + op_mode_to_set = 'auto_eco' + self.wink.set_operation_mode(op_mode_to_set) @property def target_temperature(self): """Return the temperature we try to reach.""" return self.wink.current_max_set_point() - @property - def target_temperature_low(self): - """Only supports cool.""" - return None - - @property - def target_temperature_high(self): - """Only supports cool.""" - return None - @property def current_fan_mode(self): """Return the current fan mode.""" @@ -453,12 +435,97 @@ class WinkAC(WinkDevice, ClimateDevice): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, mode): + def set_fan_mode(self, fan): """Set fan speed.""" - if mode == SPEED_LOW: + if fan == SPEED_LOW: speed = 0.4 - elif mode == SPEED_MEDIUM: + elif fan == SPEED_MEDIUM: speed = 0.8 - elif mode == SPEED_HIGH: + elif fan == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) + + +class WinkWaterHeater(WinkDevice, ClimateDevice): + """Representation of a Wink water heater.""" + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + # The Wink API always returns temp in Celsius + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + data = {} + data["vacation_mode"] = self.wink.vacation_mode_enabled() + data["rheem_type"] = self.wink.rheem_type() + + return data + + @property + def current_operation(self): + """ + Return current operation one of the following. + + ["eco", "performance", "heat_pump", + "high_demand", "electric_only", "gas] + """ + if not self.wink.is_on(): + current_op = STATE_OFF + else: + current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) + if current_op is None: + current_op = STATE_UNKNOWN + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = ['off'] + modes = self.wink.modes() + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + self.wink.set_temperature(target_temp) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + self.wink.set_operation_mode(op_mode_to_set) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.wink.current_set_point() + + def turn_away_mode_on(self): + """Turn away on.""" + self.wink.set_vacation_mode(True) + + def turn_away_mode_off(self): + """Turn away off.""" + self.wink.set_vacation_mode(False) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.wink.min_set_point() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.wink.max_set_point() diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 3fde6a2d8ad..810ef5a2e5b 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -83,7 +83,7 @@ wink_set_lock_vacation_mode: description: Name of lock to unlock example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true wink_set_lock_alarm_mode: @@ -94,7 +94,7 @@ wink_set_lock_alarm_mode: description: Name of lock to unlock example: 'lock.front_door' mode: - description: One of tamper, activity, or forced_entry + description: One of tamper, activity, or forced_entry example: tamper wink_set_lock_alarm_sensitivity: @@ -105,7 +105,7 @@ wink_set_lock_alarm_sensitivity: description: Name of lock to unlock example: 'lock.front_door' sensitivity: - description: One of low, medium_low, medium, medium_high, high + description: One of low, medium_low, medium, medium_high, high example: medium wink_set_lock_alarm_state: @@ -116,7 +116,7 @@ wink_set_lock_alarm_state: description: Name of lock to unlock example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true wink_set_lock_beeper_state: @@ -127,6 +127,19 @@ wink_set_lock_beeper_state: description: Name of lock to unlock example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true +wink_add_new_lock_key_code: + description: Add a new user key code. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + name: + description: name of the new key code. + example: Bob + code: + description: new key code, length must match length of other codes. Default length is 4. + example: 1234 diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index 020fc00ab9a..502592ac6f3 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.lock import LockDevice from homeassistant.components.wink import WinkDevice, DOMAIN import homeassistant.helpers.config_validation as cv -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, ATTR_CODE from homeassistant.config import load_yaml_config_file DEPENDENCIES = ['wink'] @@ -25,10 +25,12 @@ SERVICE_SET_ALARM_MODE = 'wink_set_lock_alarm_mode' SERVICE_SET_ALARM_SENSITIVITY = 'wink_set_lock_alarm_sensitivity' SERVICE_SET_ALARM_STATE = 'wink_set_lock_alarm_state' SERVICE_SET_BEEPER_STATE = 'wink_set_lock_beeper_state' +SERVICE_ADD_KEY = 'wink_add_new_lock_key_code' ATTR_ENABLED = 'enabled' ATTR_SENSITIVITY = 'sensitivity' ATTR_MODE = 'mode' +ATTR_NAME = 'name' ALARM_SENSITIVITY_MAP = {"low": 0.2, "medium_low": 0.4, "medium": 0.6, "medium_high": 0.8, @@ -53,6 +55,12 @@ SET_ALARM_MODES_SCHEMA = vol.Schema({ vol.Required(ATTR_MODE): vol.In(ALARM_MODES_MAP) }) +ADD_KEY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_CODE): cv.positive_int, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -86,6 +94,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): lock.set_alarm_mode(service.data.get(ATTR_MODE)) elif service.service == SERVICE_SET_ALARM_SENSITIVITY: lock.set_alarm_sensitivity(service.data.get(ATTR_SENSITIVITY)) + elif service.service == SERVICE_ADD_KEY: + name = service.data.get(ATTR_NAME) + code = service.data.get(ATTR_CODE) + lock.add_new_key(code, name) descriptions = load_yaml_config_file( path.join(path.dirname(__file__), 'services.yaml')) @@ -115,6 +127,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_SET_ALARM_SENSITIVITY), schema=SET_SENSITIVITY_SCHEMA) + hass.services.register(DOMAIN, SERVICE_ADD_KEY, + service_handle, + descriptions.get(SERVICE_ADD_KEY), + schema=ADD_KEY_SCHEMA) + class WinkLockDevice(WinkDevice, LockDevice): """Representation of a Wink lock.""" @@ -149,6 +166,10 @@ class WinkLockDevice(WinkDevice, LockDevice): """Set lock's beeper mode.""" self.wink.set_beeper_mode(enabled) + def add_new_key(self, code, name): + """Add a new user key code.""" + self.wink.add_new_key(code, name) + def set_alarm_sensitivity(self, sensitivity): """ Set lock's alarm sensitivity. @@ -176,14 +197,14 @@ class WinkLockDevice(WinkDevice, LockDevice): super_attrs = super().device_state_attributes sensitivity = dict_value_to_key(ALARM_SENSITIVITY_MAP, self.wink.alarm_sensitivity()) - super_attrs['alarm sensitivity'] = sensitivity - super_attrs['vacation mode'] = self.wink.vacation_mode_enabled() - super_attrs['beeper mode'] = self.wink.beeper_enabled() - super_attrs['auto lock'] = self.wink.auto_lock_enabled() + super_attrs['alarm_sensitivity'] = sensitivity + super_attrs['vacation_mode'] = self.wink.vacation_mode_enabled() + super_attrs['beeper_mode'] = self.wink.beeper_enabled() + super_attrs['auto_lock'] = self.wink.auto_lock_enabled() alarm_mode = dict_value_to_key(ALARM_MODES_MAP, self.wink.alarm_mode()) - super_attrs['alarm mode'] = alarm_mode - super_attrs['alarm enabled'] = self.wink.alarm_enabled() + super_attrs['alarm_mode'] = alarm_mode + super_attrs['alarm_enabled'] = self.wink.alarm_enabled() return super_attrs diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 0c9f1daf70f..69a5982caeb 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -1,3 +1,4 @@ + foursquare: checkin: description: Check a user into a Foursquare venue @@ -115,7 +116,7 @@ persistent_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 @@ -580,22 +581,22 @@ abode: setting: description: Setting to change. example: 'beeper_mute' - + value: description: Value of the setting. example: '1' capture_image: description: Request a new image capture from a camera device. - + fields: entity_id: description: Entity id of the camera to request an image. example: 'camera.downstairs_motion_camera' - + trigger_quick_action: description: Trigger an Abode quick action. - + fields: entity_id: description: Entity id of the quick action to trigger. @@ -620,8 +621,47 @@ input_boolean: turn_on: description: Turns ON an input boolean - + fields: entity_id: description: Entity id of the input boolean to turn on example: 'input_boolean.notify_alerts' + +wink: + pair_new_device: + description: Pair a new device to a Wink Hub. + + fields: + hub_name: + description: The name of the hub to pair a new device to. + example: 'My hub' + pairing_mode: + description: One of ["zigbee", "zwave", "zwave_exclusion", "zwave_network_rediscovery", "lutron", "bluetooth", "kidde"] + example: 'zigbee' + kidde_radio_code: + description: A string of 8 1s and 0s one for each dip switch on the kidde device left --> right = 1 --> 8 + example: '10101010' + + rename_wink_device: + description: Rename the provided device. + + fields: + entity_id: + description: The entity_id of the device to rename. + example: binary_sensor.front_door_opened + name: + description: The name to change it to. + example: back_door + + delete_wink_device: + description: Remove/unpair device from Wink. + + fields: + entity_id: + description: The entity_id of the device to delete. + + pull_newly_added_devices_from_wink: + description: Pull newly pair devices from Wink. + + refresh_state_from_wink: + description: Pull the latest states for every device. diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 23eb90daa89..0b3a006a8d2 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -20,11 +20,12 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, __version__) + EVENT_HOMEASSISTANT_STOP, __version__, ATTR_ENTITY_ID) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['python-wink==1.5.1', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.6.0', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -45,6 +46,10 @@ ATTR_ACCESS_TOKEN = 'access_token' ATTR_REFRESH_TOKEN = 'refresh_token' ATTR_CLIENT_ID = 'client_id' ATTR_CLIENT_SECRET = 'client_secret' +ATTR_NAME = 'name' +ATTR_PAIRING_MODE = 'pairing_mode' +ATTR_KIDDE_RADIO_CODE = 'kidde_radio_code' +ATTR_HUB_NAME = 'hub_name' WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback' WINK_AUTH_START = '/auth/wink' @@ -56,9 +61,12 @@ DEFAULT_CONFIG = { 'client_secret': 'CLIENT_SECRET_HERE' } -SERVICE_ADD_NEW_DEVICES = 'add_new_devices' +SERVICE_ADD_NEW_DEVICES = 'pull_newly_added_devices_from_wink' SERVICE_REFRESH_STATES = 'refresh_state_from_wink' -SERVICE_KEEP_ALIVE = 'keep_pubnub_updates_flowing' +SERVICE_RENAME_DEVICE = 'rename_wink_device' +SERVICE_DELETE_DEVICE = 'delete_wink_device' +SERVICE_SET_PAIRING_MODE = 'pair_new_device' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -74,11 +82,29 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) + +RENAME_DEVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string +}, extra=vol.ALLOW_EXTRA) + +DELETE_DEVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids +}, extra=vol.ALLOW_EXTRA) + +SET_PAIRING_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_HUB_NAME): cv.string, + vol.Required(ATTR_PAIRING_MODE): cv.string, + vol.Optional(ATTR_KIDDE_RADIO_CODE): cv.string +}, extra=vol.ALLOW_EXTRA) + WINK_COMPONENTS = [ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate', 'fan', 'alarm_control_panel', 'scene' ] +WINK_HUBS = [] + def _write_config_file(file_path, config): try: @@ -177,6 +203,9 @@ def setup(hass, config): import pywink from pubnubsubhandler import PubNubSubscriptionHandler + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')).get(DOMAIN) + if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = { 'unique_ids': [], @@ -313,6 +342,7 @@ def setup(hass, config): def stop_subscription(event): """Stop the pubnub subscription.""" hass.data[DOMAIN]['pubnub'].unsubscribe() + hass.data[DOMAIN]['pubnub'] = None hass.bus.listen(EVENT_HOMEASSISTANT_STOP, stop_subscription) @@ -333,7 +363,9 @@ def setup(hass, config): for entity in entity_list: time.sleep(1) entity.schedule_update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update) + + hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update, + descriptions.get(SERVICE_REFRESH_STATES)) def pull_new_devices(call): """Pull new devices added to users Wink account since startup.""" @@ -341,12 +373,71 @@ def setup(hass, config): for _component in WINK_COMPONENTS: discovery.load_platform(hass, _component, DOMAIN, {}, config) - hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices) + hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices, + descriptions.get(SERVICE_ADD_NEW_DEVICES)) + + def set_pairing_mode(call): + """Put the hub in provided pairing mode.""" + hub_name = call.data.get('hub_name') + pairing_mode = call.data.get('pairing_mode') + kidde_code = call.data.get('kidde_radio_code') + for hub in WINK_HUBS: + if hub.name() == hub_name: + hub.pair_new_device(pairing_mode, + kidde_radio_code=kidde_code) + + def rename_device(call): + """Set specified device's name.""" + # This should only be called on one device at a time. + found_device = None + entity_id = call.data.get('entity_id')[0] + all_devices = [] + for list_of_devices in hass.data[DOMAIN]['entities'].values(): + all_devices += list_of_devices + for device in all_devices: + if device.entity_id == entity_id: + found_device = device + if found_device is not None: + name = call.data.get('name') + found_device.wink.set_name(name) + + hass.services.register(DOMAIN, SERVICE_RENAME_DEVICE, rename_device, + descriptions.get(SERVICE_RENAME_DEVICE), + schema=RENAME_DEVICE_SCHEMA) + + def delete_device(call): + """Delete specified device.""" + # This should only be called on one device at a time. + found_device = None + entity_id = call.data.get('entity_id')[0] + all_devices = [] + for list_of_devices in hass.data[DOMAIN]['entities'].values(): + all_devices += list_of_devices + for device in all_devices: + if device.entity_id == entity_id: + found_device = device + if found_device is not None: + found_device.wink.remove_device() + + hass.services.register(DOMAIN, SERVICE_DELETE_DEVICE, delete_device, + descriptions.get(SERVICE_DELETE_DEVICE), + schema=DELETE_DEVICE_SCHEMA) + + hubs = pywink.get_hubs() + for hub in hubs: + if hub.device_manufacturer() == 'wink': + WINK_HUBS.append(hub) + + if WINK_HUBS: + hass.services.register( + DOMAIN, SERVICE_SET_PAIRING_MODE, set_pairing_mode, + descriptions.get(SERVICE_SET_PAIRING_MODE), + schema=SET_PAIRING_MODE_SCHEMA) # Load components for the devices in Wink that we support - for component in WINK_COMPONENTS: - hass.data[DOMAIN]['entities'][component] = [] - discovery.load_platform(hass, component, DOMAIN, {}, config) + for wink_component in WINK_COMPONENTS: + hass.data[DOMAIN]['entities'][wink_component] = [] + discovery.load_platform(hass, wink_component, DOMAIN, {}, config) return True diff --git a/requirements_all.txt b/requirements_all.txt index b3f3398501a..a5aab8adf00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -807,7 +807,7 @@ python-velbus==2.0.11 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.5.1 +python-wink==1.6.0 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.0.2 From d499c18e6330b824768adc17114f1b0643aea717 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Sep 2017 11:44:32 -0600 Subject: [PATCH 35/94] 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 36/94] 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 37/94] 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 38/94] 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 39/94] 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 40/94] 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 41/94] 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 42/94] 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 43/94] 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 44/94] 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 45/94] 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 46/94] 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 47/94] 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 48/94] 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 49/94] 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 50/94] 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 51/94] 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 52/94] 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 53/94] 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 54/94] 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 55/94] 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 56/94] 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 57/94] 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 58/94] 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 59/94] 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 60/94] 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 61/94] 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 62/94] 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 63/94] 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 64/94] 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 65/94] 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 66/94] 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 67/94] 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 68/94] 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 69/94] 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 70/94] 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 71/94] 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 72/94] 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 73/94] 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 74/94] 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 75/94] 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 76/94] 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 77/94] 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 78/94] 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 79/94] 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 80/94] 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 81/94] 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 82/94] 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 83/94] 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 84/94] 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 85/94] 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 86/94] 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 87/94] [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 88/94] 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 89/94] 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 90/94] 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;}