From 75e6ed87d6f60ab5419cdfe5ac4ec71295be3b02 Mon Sep 17 00:00:00 2001 From: NMA Date: Fri, 12 Aug 2016 14:48:28 +0530 Subject: [PATCH 001/208] Backend support for importing waypoints from owntracks as HA zones --- .../components/device_tracker/owntracks.py | 69 +++++++++++++------ homeassistant/components/zone.py | 23 +++++-- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 00ba8c68556..13cc918a436 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -12,6 +12,7 @@ from collections import defaultdict import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME from homeassistant.util import convert, slugify +from homeassistant.components import zone DEPENDENCIES = ['mqtt'] @@ -22,17 +23,19 @@ BEACON_DEV_ID = 'beacon' LOCATION_TOPIC = 'owntracks/+/+' EVENT_TOPIC = 'owntracks/+/+/event' +WAYPOINT_TOPIC = 'owntracks/{}/+/waypoint' _LOGGER = logging.getLogger(__name__) LOCK = threading.Lock() CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' - +CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import_user = config.get(CONF_WAYPOINT_IMPORT_USER) def validate_payload(payload, data_type): """Validate OwnTracks payload.""" @@ -47,17 +50,18 @@ def setup_scanner(hass, config, see): 'because of missing or malformatted data: %s', data_type, data) return None - if max_gps_accuracy is not None and \ - convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.debug('Skipping %s update because expected GPS ' - 'accuracy %s is not met: %s', - data_type, max_gps_accuracy, data) - return None - if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.debug('Skipping %s update because GPS accuracy' - 'is zero', - data_type) - return None + if data_type != 'waypoints': + if max_gps_accuracy is not None and \ + convert(data.get('acc'), float, 0.0) > max_gps_accuracy: + _LOGGER.debug('Skipping %s update because expected GPS ' + 'accuracy %s is not met: %s', + data_type, max_gps_accuracy, data) + return None + if convert(data.get('acc'), float, 1.0) == 0.0: + _LOGGER.debug('Skipping %s update because GPS accuracy' + 'is zero', + data_type) + return None return data @@ -105,9 +109,9 @@ def setup_scanner(hass, config, see): def enter_event(): """Execute enter event.""" - zone = hass.states.get("zone.{}".format(location)) + _zone = hass.states.get("zone.{}".format(location)) with LOCK: - if zone is None and data.get('t') == 'b': + 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: @@ -119,7 +123,7 @@ def setup_scanner(hass, config, see): if location not in regions: regions.append(location) _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) + _set_gps_from_zone(kwargs, location, _zone) see(**kwargs) see_beacons(dev_id, kwargs) @@ -134,8 +138,8 @@ def setup_scanner(hass, config, see): if new_region: # Exit to previous region - zone = hass.states.get("zone.{}".format(new_region)) - _set_gps_from_zone(kwargs, new_region, zone) + _zone = hass.states.get("zone.{}".format(new_region)) + _set_gps_from_zone(kwargs, new_region, _zone) _LOGGER.info("Exit to %s", new_region) see(**kwargs) see_beacons(dev_id, kwargs) @@ -167,6 +171,23 @@ def setup_scanner(hass, config, see): data['event']) return + def 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(payload, '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'] + lat = wayp['lat'] + lon = wayp['lon'] + rad = wayp['rad'] + zone.add_zone(hass, name, lat, lon, rad) + def see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() @@ -180,6 +201,10 @@ def setup_scanner(hass, config, see): mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1) + if waypoint_import_user is not None: + mqtt.subscribe(hass, WAYPOINT_TOPIC.format(waypoint_import_user), + owntracks_waypoint_update, 1) + return True @@ -200,12 +225,12 @@ def _parse_see_args(topic, data): return dev_id, kwargs -def _set_gps_from_zone(kwargs, location, zone): +def _set_gps_from_zone(kwargs, location, _zone): """Set the see parameters from the zone parameters.""" - if zone is not None: + if _zone is not None: kwargs['gps'] = ( - zone.attributes['latitude'], - zone.attributes['longitude']) - kwargs['gps_accuracy'] = zone.attributes['radius'] + _zone.attributes['latitude'], + _zone.attributes['longitude']) + kwargs['gps_accuracy'] = _zone.attributes['radius'] kwargs['location_name'] = location return kwargs diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index db57b387c9f..ee4ff8a48a6 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -27,7 +27,10 @@ ATTR_PASSIVE = 'passive' DEFAULT_PASSIVE = False ICON_HOME = 'mdi:home' +ICON_IMPORT = 'mdi:import' +entities = set() +_LOGGER = logging.getLogger(__name__) def active_zone(hass, latitude, longitude, radius=0): """Find the active zone for given latitude, longitude.""" @@ -70,7 +73,6 @@ def in_zone(zone, latitude, longitude, radius=0): def setup(hass, config): """Setup zone.""" - entities = set() for key in extract_domain_configs(config, DOMAIN): entries = config[key] @@ -90,7 +92,7 @@ def setup(hass, config): 'Each zone needs a latitude and longitude.') continue - zone = Zone(hass, name, latitude, longitude, radius, icon, passive) + zone = Zone(hass, name, latitude, longitude, radius, icon, passive, False) zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, entities) zone.update_ha_state() @@ -98,18 +100,30 @@ def setup(hass, config): if ENTITY_ID_HOME not in entities: zone = Zone(hass, hass.config.location_name, hass.config.latitude, - hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False) + hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False, False) zone.entity_id = ENTITY_ID_HOME zone.update_ha_state() return True +# Add a zone to the existing set +def add_zone(hass, name, latitude, longitude, radius): + _LOGGER.info("Adding new zone %s", name) + if name not in entities: + zone = Zone(hass, name, latitude, longitude, radius, ICON_IMPORT, + False, True) + zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, + entities) + zone.update_ha_state() + entities.add(zone.entity_id) + else: + _LOGGER.info("Zone already exists") class Zone(Entity): """Representation of a Zone.""" # pylint: disable=too-many-arguments, too-many-instance-attributes - def __init__(self, hass, name, latitude, longitude, radius, icon, passive): + def __init__(self, hass, name, latitude, longitude, radius, icon, passive, imported): """Initialize the zone.""" self.hass = hass self._name = name @@ -118,6 +132,7 @@ class Zone(Entity): self._radius = radius self._icon = icon self._passive = passive + self._imported = imported @property def name(self): From 2bea5a484f462da496b5e8a6c16a92721d46f487 Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 16:47:34 +0530 Subject: [PATCH 002/208] Added test for Owntracks waypoints import --- .../components/device_tracker/owntracks.py | 3 +- .../device_tracker/test_owntracks.py | 37 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 13cc918a436..9f505685721 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -32,6 +32,7 @@ LOCK = threading.Lock() CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' + def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) @@ -203,7 +204,7 @@ def setup_scanner(hass, config, see): if waypoint_import_user is not None: mqtt.subscribe(hass, WAYPOINT_TOPIC.format(waypoint_import_user), - owntracks_waypoint_update, 1) + owntracks_waypoint_update, 1) return True diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 16fb1c4a4ce..f6f1fc58147 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -17,6 +17,7 @@ DEVICE = 'phone' LOCATION_TOPIC = "owntracks/{}/{}".format(USER, DEVICE) EVENT_TOPIC = "owntracks/{}/{}/event".format(USER, DEVICE) +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'.format(USER, DEVICE) DEVICE_TRACKER_STATE = "device_tracker.{}_{}".format(USER, DEVICE) @@ -24,6 +25,7 @@ IBEACON_DEVICE = 'keys' REGION_TRACKER_STATE = "device_tracker.beacon_{}".format(IBEACON_DEVICE) CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' LOCATION_MESSAGE = { 'batt': 92, @@ -107,6 +109,28 @@ REGION_LEAVE_INACCURATE_MESSAGE = { 'lat': 20.0, '_type': 'transition'} +WAYPOINTS_EXPORTED_MESSAGE = { + "_type": "waypoints", + "_creator": "test", + "waypoints": [ + { + "_type": "waypoint", + "tst": 3, + "lat": 47, + "lon": 9, + "rad": 10, + "desc": "exp_wayp1" + }, + { + "_type": "waypoint", + "tst": 4, + "lat": 3, + "lon": 9, + "rad": 500, + "desc": "exp_wayp2" + } + ] +} class TestDeviceTrackerOwnTracks(unittest.TestCase): """Test the OwnTrack sensor.""" @@ -118,7 +142,8 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assertTrue(device_tracker.setup(self.hass, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200 + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT_USER: USER }})) self.hass.states.set( @@ -486,3 +511,13 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.send_message(EVENT_TOPIC, exit_message) self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + + def test_waypoint_import_simple(self): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get('zone.exp_wayp1') + self.assertTrue(wayp != None) + wayp = self.hass.states.get('zone.exp_wayp2') + self.assertTrue(wayp != None) From 1ada7d621121a2d4ac7ade047032621b6de2ec30 Mon Sep 17 00:00:00 2001 From: NMA Date: Fri, 12 Aug 2016 14:48:28 +0530 Subject: [PATCH 003/208] Backend support for importing waypoints from owntracks as HA zones --- .../components/device_tracker/owntracks.py | 47 ++++++++++++++----- homeassistant/components/zone.py | 23 +++++++-- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index cdb1f90ba8a..9739643f884 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -12,6 +12,7 @@ from collections import defaultdict import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME from homeassistant.util import convert, slugify +from homeassistant.components import zone DEPENDENCIES = ['mqtt'] @@ -22,6 +23,7 @@ BEACON_DEV_ID = 'beacon' LOCATION_TOPIC = 'owntracks/+/+' EVENT_TOPIC = 'owntracks/+/+/event' +WAYPOINT_TOPIC = 'owntracks/{}/+/waypoint' _LOGGER = logging.getLogger(__name__) @@ -32,10 +34,12 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' VALIDATE_LOCATION = 'location' VALIDATE_TRANSITION = 'transition' +CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import_user = config.get(CONF_WAYPOINT_IMPORT_USER) def validate_payload(payload, data_type): """Validate OwnTracks payload.""" @@ -50,7 +54,7 @@ def setup_scanner(hass, config, see): 'because of missing or malformatted data: %s', data_type, data) return None - if data_type == VALIDATE_TRANSITION: + if data_type == VALIDATE_TRANSITION or data_type == 'waypoints': return data if max_gps_accuracy is not None and \ convert(data.get('acc'), float, 0.0) > max_gps_accuracy: @@ -110,9 +114,9 @@ def setup_scanner(hass, config, see): def enter_event(): """Execute enter event.""" - zone = hass.states.get("zone.{}".format(location)) + _zone = hass.states.get("zone.{}".format(location)) with LOCK: - if zone is None and data.get('t') == 'b': + 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: @@ -124,7 +128,7 @@ def setup_scanner(hass, config, see): if location not in regions: regions.append(location) _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) + _set_gps_from_zone(kwargs, location, _zone) see(**kwargs) see_beacons(dev_id, kwargs) @@ -139,8 +143,8 @@ def setup_scanner(hass, config, see): if new_region: # Exit to previous region - zone = hass.states.get("zone.{}".format(new_region)) - _set_gps_from_zone(kwargs, new_region, zone) + _zone = hass.states.get("zone.{}".format(new_region)) + _set_gps_from_zone(kwargs, new_region, _zone) _LOGGER.info("Exit to %s", new_region) see(**kwargs) see_beacons(dev_id, kwargs) @@ -182,6 +186,23 @@ def setup_scanner(hass, config, see): data['event']) return + def 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(payload, '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'] + lat = wayp['lat'] + lon = wayp['lon'] + rad = wayp['rad'] + zone.add_zone(hass, name, lat, lon, rad) + def see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() @@ -195,6 +216,10 @@ def setup_scanner(hass, config, see): mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1) + if waypoint_import_user is not None: + mqtt.subscribe(hass, WAYPOINT_TOPIC.format(waypoint_import_user), + owntracks_waypoint_update, 1) + return True @@ -215,12 +240,12 @@ def _parse_see_args(topic, data): return dev_id, kwargs -def _set_gps_from_zone(kwargs, location, zone): +def _set_gps_from_zone(kwargs, location, _zone): """Set the see parameters from the zone parameters.""" - if zone is not None: + if _zone is not None: kwargs['gps'] = ( - zone.attributes['latitude'], - zone.attributes['longitude']) - kwargs['gps_accuracy'] = zone.attributes['radius'] + _zone.attributes['latitude'], + _zone.attributes['longitude']) + kwargs['gps_accuracy'] = _zone.attributes['radius'] kwargs['location_name'] = location return kwargs diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index db57b387c9f..ee4ff8a48a6 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -27,7 +27,10 @@ ATTR_PASSIVE = 'passive' DEFAULT_PASSIVE = False ICON_HOME = 'mdi:home' +ICON_IMPORT = 'mdi:import' +entities = set() +_LOGGER = logging.getLogger(__name__) def active_zone(hass, latitude, longitude, radius=0): """Find the active zone for given latitude, longitude.""" @@ -70,7 +73,6 @@ def in_zone(zone, latitude, longitude, radius=0): def setup(hass, config): """Setup zone.""" - entities = set() for key in extract_domain_configs(config, DOMAIN): entries = config[key] @@ -90,7 +92,7 @@ def setup(hass, config): 'Each zone needs a latitude and longitude.') continue - zone = Zone(hass, name, latitude, longitude, radius, icon, passive) + zone = Zone(hass, name, latitude, longitude, radius, icon, passive, False) zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, entities) zone.update_ha_state() @@ -98,18 +100,30 @@ def setup(hass, config): if ENTITY_ID_HOME not in entities: zone = Zone(hass, hass.config.location_name, hass.config.latitude, - hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False) + hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False, False) zone.entity_id = ENTITY_ID_HOME zone.update_ha_state() return True +# Add a zone to the existing set +def add_zone(hass, name, latitude, longitude, radius): + _LOGGER.info("Adding new zone %s", name) + if name not in entities: + zone = Zone(hass, name, latitude, longitude, radius, ICON_IMPORT, + False, True) + zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, + entities) + zone.update_ha_state() + entities.add(zone.entity_id) + else: + _LOGGER.info("Zone already exists") class Zone(Entity): """Representation of a Zone.""" # pylint: disable=too-many-arguments, too-many-instance-attributes - def __init__(self, hass, name, latitude, longitude, radius, icon, passive): + def __init__(self, hass, name, latitude, longitude, radius, icon, passive, imported): """Initialize the zone.""" self.hass = hass self._name = name @@ -118,6 +132,7 @@ class Zone(Entity): self._radius = radius self._icon = icon self._passive = passive + self._imported = imported @property def name(self): From e6b7511e7d40600655dd00d46e22d64b3807baad Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 16:47:34 +0530 Subject: [PATCH 004/208] Added test for Owntracks waypoints import --- .../components/device_tracker/owntracks.py | 3 +- .../device_tracker/test_owntracks.py | 37 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 9739643f884..93d217da5cf 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -36,6 +36,7 @@ VALIDATE_TRANSITION = 'transition' CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' + def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) @@ -218,7 +219,7 @@ def setup_scanner(hass, config, see): if waypoint_import_user is not None: mqtt.subscribe(hass, WAYPOINT_TOPIC.format(waypoint_import_user), - owntracks_waypoint_update, 1) + owntracks_waypoint_update, 1) return True diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 393b61a3134..e998afdd9c0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -17,6 +17,7 @@ DEVICE = 'phone' LOCATION_TOPIC = "owntracks/{}/{}".format(USER, DEVICE) EVENT_TOPIC = "owntracks/{}/{}/event".format(USER, DEVICE) +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'.format(USER, DEVICE) DEVICE_TRACKER_STATE = "device_tracker.{}_{}".format(USER, DEVICE) @@ -24,6 +25,7 @@ IBEACON_DEVICE = 'keys' REGION_TRACKER_STATE = "device_tracker.beacon_{}".format(IBEACON_DEVICE) CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' LOCATION_MESSAGE = { 'batt': 92, @@ -107,6 +109,28 @@ REGION_LEAVE_INACCURATE_MESSAGE = { 'lat': 20.0, '_type': 'transition'} +WAYPOINTS_EXPORTED_MESSAGE = { + "_type": "waypoints", + "_creator": "test", + "waypoints": [ + { + "_type": "waypoint", + "tst": 3, + "lat": 47, + "lon": 9, + "rad": 10, + "desc": "exp_wayp1" + }, + { + "_type": "waypoint", + "tst": 4, + "lat": 3, + "lon": 9, + "rad": 500, + "desc": "exp_wayp2" + } + ] +} REGION_ENTER_ZERO_MESSAGE = { 'lon': 1.0, @@ -143,7 +167,8 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assertTrue(device_tracker.setup(self.hass, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200 + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT_USER: USER }})) self.hass.states.set( @@ -530,3 +555,13 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.send_message(EVENT_TOPIC, exit_message) self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + + def test_waypoint_import_simple(self): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get('zone.exp_wayp1') + self.assertTrue(wayp != None) + wayp = self.hass.states.get('zone.exp_wayp2') + self.assertTrue(wayp != None) From 95b7a8c4b90397f3a606d128f30af110446311c0 Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 17:07:53 +0530 Subject: [PATCH 005/208] Removed redundant assignment to CONF_WAYPOINT_IMPORT_USER --- homeassistant/components/device_tracker/owntracks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 612f9423a38..1e47ed6f83c 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -35,8 +35,6 @@ CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' VALIDATE_LOCATION = 'location' VALIDATE_TRANSITION = 'transition' -CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' - def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" From 2ca3541eaceede2bb51e48eb5543a190f873e084 Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 21:33:07 +0530 Subject: [PATCH 006/208] Fixed zone test break and code style issues --- .../components/device_tracker/owntracks.py | 24 +++++++++---------- homeassistant/components/zone.py | 14 +++++++---- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 1e47ed6f83c..3d95e0e0268 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -12,7 +12,7 @@ from collections import defaultdict import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME from homeassistant.util import convert, slugify -from homeassistant.components import zone +from homeassistant.components import zone as zone_comp DEPENDENCIES = ['mqtt'] @@ -114,9 +114,9 @@ def setup_scanner(hass, config, see): def enter_event(): """Execute enter event.""" - _zone = hass.states.get("zone.{}".format(location)) + zone = hass.states.get("zone.{}".format(location)) with LOCK: - if _zone is None and data.get('t') == 'b': + 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: @@ -128,7 +128,7 @@ def setup_scanner(hass, config, see): if location not in regions: regions.append(location) _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, _zone) + _set_gps_from_zone(kwargs, location, zone) see(**kwargs) see_beacons(dev_id, kwargs) @@ -143,8 +143,8 @@ def setup_scanner(hass, config, see): if new_region: # Exit to previous region - _zone = hass.states.get("zone.{}".format(new_region)) - _set_gps_from_zone(kwargs, new_region, _zone) + zone = hass.states.get("zone.{}".format(new_region)) + _set_gps_from_zone(kwargs, new_region, zone) _LOGGER.info("Exit to %s", new_region) see(**kwargs) see_beacons(dev_id, kwargs) @@ -201,7 +201,7 @@ def setup_scanner(hass, config, see): lat = wayp['lat'] lon = wayp['lon'] rad = wayp['rad'] - zone.add_zone(hass, name, lat, lon, rad) + zone_comp.add_zone(hass, name, lat, lon, rad) def see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" @@ -240,12 +240,12 @@ def _parse_see_args(topic, data): return dev_id, kwargs -def _set_gps_from_zone(kwargs, location, _zone): +def _set_gps_from_zone(kwargs, location, zone): """Set the see parameters from the zone parameters.""" - if _zone is not None: + if zone is not None: kwargs['gps'] = ( - _zone.attributes['latitude'], - _zone.attributes['longitude']) - kwargs['gps_accuracy'] = _zone.attributes['radius'] + zone.attributes['latitude'], + zone.attributes['longitude']) + kwargs['gps_accuracy'] = zone.attributes['radius'] kwargs['location_name'] = location return kwargs diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index ee4ff8a48a6..ba63de00823 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -73,7 +73,7 @@ def in_zone(zone, latitude, longitude, radius=0): def setup(hass, config): """Setup zone.""" - + entities = set() for key in extract_domain_configs(config, DOMAIN): entries = config[key] if not isinstance(entries, list): @@ -92,7 +92,8 @@ def setup(hass, config): 'Each zone needs a latitude and longitude.') continue - zone = Zone(hass, name, latitude, longitude, radius, icon, passive, False) + zone = Zone(hass, name, latitude, longitude, radius, icon, + passive, False) zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, entities) zone.update_ha_state() @@ -100,7 +101,8 @@ def setup(hass, config): if ENTITY_ID_HOME not in entities: zone = Zone(hass, hass.config.location_name, hass.config.latitude, - hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False, False) + hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, + False, False) zone.entity_id = ENTITY_ID_HOME zone.update_ha_state() @@ -108,12 +110,13 @@ def setup(hass, config): # Add a zone to the existing set def add_zone(hass, name, latitude, longitude, radius): + """Add a zone from other components""" _LOGGER.info("Adding new zone %s", name) if name not in entities: zone = Zone(hass, name, latitude, longitude, radius, ICON_IMPORT, False, True) zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, - entities) + entities) zone.update_ha_state() entities.add(zone.entity_id) else: @@ -123,7 +126,8 @@ class Zone(Entity): """Representation of a Zone.""" # pylint: disable=too-many-arguments, too-many-instance-attributes - def __init__(self, hass, name, latitude, longitude, radius, icon, passive, imported): + def __init__(self, hass, name, latitude, longitude, radius, icon, passive, + imported): """Initialize the zone.""" self.hass = hass self._name = name From ca73295dd1d5978357d884dc813425980dc99abd Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 21:35:04 +0530 Subject: [PATCH 007/208] Fixed style issues --- homeassistant/components/zone.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index ba63de00823..a32947d180c 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -32,6 +32,7 @@ ICON_IMPORT = 'mdi:import' entities = set() _LOGGER = logging.getLogger(__name__) + def active_zone(hass, latitude, longitude, radius=0): """Find the active zone for given latitude, longitude.""" # Sort entity IDs so that we are deterministic if equal distance to 2 zones @@ -108,6 +109,7 @@ def setup(hass, config): return True + # Add a zone to the existing set def add_zone(hass, name, latitude, longitude, radius): """Add a zone from other components""" @@ -122,6 +124,7 @@ def add_zone(hass, name, latitude, longitude, radius): else: _LOGGER.info("Zone already exists") + class Zone(Entity): """Representation of a Zone.""" From 47a9313fdb4daf67c4c053cf8ef8c0b1a84f58dd Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 22:15:31 +0530 Subject: [PATCH 008/208] Fixed variable scope issues for entities --- homeassistant/components/zone.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index a32947d180c..fa095399920 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -29,7 +29,6 @@ DEFAULT_PASSIVE = False ICON_HOME = 'mdi:home' ICON_IMPORT = 'mdi:import' -entities = set() _LOGGER = logging.getLogger(__name__) @@ -112,9 +111,11 @@ def setup(hass, config): # Add a zone to the existing set def add_zone(hass, name, latitude, longitude, radius): - """Add a zone from other components""" + """Add a zone from other components.""" _LOGGER.info("Adding new zone %s", name) - if name not in entities: + entities = set() + + if hass.states.get('zone.' + name) is None: zone = Zone(hass, name, latitude, longitude, radius, ICON_IMPORT, False, True) zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, @@ -124,7 +125,6 @@ def add_zone(hass, name, latitude, longitude, radius): else: _LOGGER.info("Zone already exists") - class Zone(Entity): """Representation of a Zone.""" From ed872f6054e91ad18559f9380d18ee64a27a9a1e Mon Sep 17 00:00:00 2001 From: NMA Date: Thu, 25 Aug 2016 22:29:16 +0530 Subject: [PATCH 009/208] Fixed E302 --- homeassistant/components/zone.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index fa095399920..d097b0b76ef 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -125,6 +125,7 @@ def add_zone(hass, name, latitude, longitude, radius): else: _LOGGER.info("Zone already exists") + class Zone(Entity): """Representation of a Zone.""" From 62ba0fa7a28dd0da273669c48cc32b576cd64135 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 25 Aug 2016 23:23:14 -0700 Subject: [PATCH 010/208] Do not install pip packages in tests --- tests/common.py | 1 + tests/test_bootstrap.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/common.py b/tests/common.py index 5d1f485d7fe..a92178f8c71 100644 --- a/tests/common.py +++ b/tests/common.py @@ -44,6 +44,7 @@ def get_test_home_assistant(num_threads=None): hass.config.elevation = 0 hass.config.time_zone = date_util.get_time_zone('US/Pacific') hass.config.units = METRIC_SYSTEM + hass.config.skip_pip = True if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: loader.prepare(hass) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d41dc60ee15..f9abe764866 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -177,6 +177,7 @@ class TestBootstrap: return_value=False) def test_component_not_installed_if_requirement_fails(self, mock_install): """Component setup should fail if requirement can't install.""" + self.hass.config.skip_pip = False loader.set_component( 'comp', MockModule('comp', requirements=['package==0.0.1'])) From d9ecc4af64af7bc2d4d7d5b7c1b1cd60991d3f54 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 25 Aug 2016 23:25:35 -0700 Subject: [PATCH 011/208] EventBus: return function to unlisten --- homeassistant/components/automation/state.py | 16 ++-- homeassistant/components/cover/demo.py | 27 +++---- homeassistant/components/group.py | 8 +- homeassistant/components/mqtt/__init__.py | 7 +- .../components/rollershutter/demo.py | 15 ++-- homeassistant/core.py | 6 ++ homeassistant/helpers/event.py | 63 ++++++++------- homeassistant/helpers/script.py | 15 ++-- tests/components/mqtt/test_init.py | 9 ++- tests/helpers/test_event.py | 77 ++++++++++++++++--- tests/test_core.py | 23 ++++++ 11 files changed, 183 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 03902c1d6e2..d0044bc8c4b 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -7,8 +7,7 @@ at https://home-assistant.io/components/automation/#state-trigger import voluptuous as vol import homeassistant.util.dt as dt_util -from homeassistant.const import ( - EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, CONF_PLATFORM) +from homeassistant.const import MATCH_ALL, CONF_PLATFORM from homeassistant.helpers.event import track_state_change, track_point_in_time import homeassistant.helpers.config_validation as cv @@ -60,23 +59,20 @@ def trigger(hass, config, action): def state_for_listener(now): """Fire on state changes after a delay and calls action.""" - hass.bus.remove_listener( - EVENT_STATE_CHANGED, attached_state_for_cancel) + remove_state_for_cancel() call_action() def state_for_cancel_listener(entity, inner_from_s, inner_to_s): """Fire on changes and cancel for listener if changed.""" if inner_to_s.state == to_s.state: return - hass.bus.remove_listener(EVENT_TIME_CHANGED, - attached_state_for_listener) - hass.bus.remove_listener(EVENT_STATE_CHANGED, - attached_state_for_cancel) + remove_state_for_listener() + remove_state_for_cancel() - attached_state_for_listener = track_point_in_time( + remove_state_for_listener = track_point_in_time( hass, state_for_listener, dt_util.utcnow() + time_delta) - attached_state_for_cancel = track_state_change( + remove_state_for_cancel = track_state_change( hass, entity, state_for_cancel_listener) track_state_change( diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 1f1c666f339..acddfcf7c73 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.cover import CoverDevice -from homeassistant.const import EVENT_TIME_CHANGED from homeassistant.helpers.event import track_utc_time_change @@ -32,8 +31,8 @@ class DemoCover(CoverDevice): self._tilt_position = tilt_position self._closing = True self._closing_tilt = True - self._listener_cover = None - self._listener_cover_tilt = None + self._unsub_listener_cover = None + self._unsub_listener_cover_tilt = None @property def name(self): @@ -120,10 +119,9 @@ class DemoCover(CoverDevice): """Stop the cover.""" if self._position is None: return - if self._listener_cover is not None: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, - self._listener_cover) - self._listener_cover = None + if self._unsub_listener_cover is not None: + self._unsub_listener_cover() + self._unsub_listener_cover = None self._set_position = None def stop_cover_tilt(self, **kwargs): @@ -131,16 +129,15 @@ class DemoCover(CoverDevice): if self._tilt_position is None: return - if self._listener_cover_tilt is not None: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, - self._listener_cover_tilt) - self._listener_cover_tilt = None + if self._unsub_listener_cover_tilt is not None: + self._unsub_listener_cover_tilt() + self._unsub_listener_cover_tilt = None self._set_tilt_position = None def _listen_cover(self): """Listen for changes in cover.""" - if self._listener_cover is None: - self._listener_cover = track_utc_time_change( + if self._unsub_listener_cover is None: + self._unsub_listener_cover = track_utc_time_change( self.hass, self._time_changed_cover) def _time_changed_cover(self, now): @@ -156,8 +153,8 @@ class DemoCover(CoverDevice): def _listen_cover_tilt(self): """Listen for changes in cover tilt.""" - if self._listener_cover_tilt is None: - self._listener_cover_tilt = track_utc_time_change( + if self._unsub_listener_cover_tilt is None: + self._unsub_listener_cover_tilt = track_utc_time_change( self.hass, self._time_changed_cover_tilt) def _time_changed_cover_tilt(self, now): diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index be998b48f23..4444b97ebe2 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -175,6 +175,7 @@ class Group(Entity): self.group_off = None self._assumed_state = False self._lock = threading.Lock() + self._unsub_state_changed = None if entity_ids is not None: self.update_tracked_entity_ids(entity_ids) @@ -236,15 +237,16 @@ class Group(Entity): def start(self): """Start tracking members.""" - track_state_change( + self._unsub_state_changed = track_state_change( self.hass, self.tracking, self._state_changed_listener) def stop(self): """Unregister the group from Home Assistant.""" self.hass.states.remove(self.entity_id) - self.hass.bus.remove_listener( - ha.EVENT_STATE_CHANGED, self._state_changed_listener) + if self._unsub_state_changed: + self._unsub_state_changed() + self._unsub_state_changed = None def update(self): """Query all members and determine current group state.""" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6db231f6bd7..18e95a1f65b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -170,9 +170,14 @@ def subscribe(hass, topic, callback, qos=DEFAULT_QOS): callback(event.data[ATTR_TOPIC], event.data[ATTR_PAYLOAD], event.data[ATTR_QOS]) - hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, mqtt_topic_subscriber) + remove = hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, + mqtt_topic_subscriber) + + # Future: track subscriber count and unsubscribe in remove MQTT_CLIENT.subscribe(topic, qos) + return remove + def _setup_server(hass, config): """Try to start embedded MQTT broker.""" diff --git a/homeassistant/components/rollershutter/demo.py b/homeassistant/components/rollershutter/demo.py index 31915019c5e..6799d062e43 100644 --- a/homeassistant/components/rollershutter/demo.py +++ b/homeassistant/components/rollershutter/demo.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.rollershutter import RollershutterDevice -from homeassistant.const import EVENT_TIME_CHANGED from homeassistant.helpers.event import track_utc_time_change @@ -27,7 +26,7 @@ class DemoRollershutter(RollershutterDevice): self._name = name self._position = position self._moving_up = True - self._listener = None + self._unsub_listener = None @property def name(self): @@ -70,15 +69,15 @@ class DemoRollershutter(RollershutterDevice): def stop(self, **kwargs): """Stop the roller shutter.""" - if self._listener is not None: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, self._listener) - self._listener = None + if self._unsub_listener is not None: + self._unsub_listener() + self._unsub_listener = None def _listen(self): """Listen for changes.""" - if self._listener is None: - self._listener = track_utc_time_change(self.hass, - self._time_changed) + if self._unsub_listener is None: + self._unsub_listener = track_utc_time_change(self.hass, + self._time_changed) def _time_changed(self, now): """Track time changes.""" diff --git a/homeassistant/core.py b/homeassistant/core.py index b77d8356a35..dad7313bb82 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -297,6 +297,12 @@ class EventBus(object): else: self._listeners[event_type] = [listener] + def remove_listener(): + """Remove the listener.""" + self.remove_listener(event_type, listener) + + return remove_listener + def listen_once(self, event_type, listener): """Listen once for event of a specific type. diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9bc6910c685..ff81b693704 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -14,8 +14,7 @@ def track_state_change(hass, entity_ids, action, from_state=None, entity_ids, from_state and to_state can be string or list. Use list to match multiple. - Returns the listener that listens on the bus for EVENT_STATE_CHANGED. - Pass the return value into hass.bus.remove_listener to remove it. + Returns a function that can be called to remove the listener. """ from_state = _process_state_match(from_state) to_state = _process_state_match(to_state) @@ -50,9 +49,7 @@ def track_state_change(hass, entity_ids, action, from_state=None, event.data.get('old_state'), event.data.get('new_state')) - hass.bus.listen(EVENT_STATE_CHANGED, state_change_listener) - - return state_change_listener + return hass.bus.listen(EVENT_STATE_CHANGED, state_change_listener) def track_point_in_time(hass, action, point_in_time): @@ -77,23 +74,20 @@ def track_point_in_utc_time(hass, action, point_in_time): """Listen for matching time_changed events.""" now = event.data[ATTR_NOW] - if now >= point_in_time and \ - not hasattr(point_in_time_listener, 'run'): + if now < point_in_time or hasattr(point_in_time_listener, 'run'): + return - # Set variable so that we will never run twice. - # Because the event bus might have to wait till a thread comes - # available to execute this listener it might occur that the - # listener gets lined up twice to be executed. This will make - # sure the second time it does nothing. - point_in_time_listener.run = True + # Set variable so that we will never run twice. + # Because the event bus might have to wait till a thread comes + # available to execute this listener it might occur that the + # listener gets lined up twice to be executed. This will make + # sure the second time it does nothing. + point_in_time_listener.run = True + remove() + action(now) - hass.bus.remove_listener(EVENT_TIME_CHANGED, - point_in_time_listener) - - action(now) - - hass.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) - return point_in_time_listener + remove = hass.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) + return remove def track_sunrise(hass, action, offset=None): @@ -112,10 +106,18 @@ def track_sunrise(hass, action, offset=None): def sunrise_automation_listener(now): """Called when it's time for action.""" + nonlocal remove track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) action() - track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + remove = track_point_in_utc_time(hass, sunrise_automation_listener, + next_rise()) + + def remove_listener(): + """Remove sunrise listener.""" + remove() + + return remove_listener def track_sunset(hass, action, offset=None): @@ -134,10 +136,19 @@ def track_sunset(hass, action, offset=None): def sunset_automation_listener(now): """Called when it's time for action.""" - track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + nonlocal remove + remove = track_point_in_utc_time(hass, sunset_automation_listener, + next_set()) action() - track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + remove = track_point_in_utc_time(hass, sunset_automation_listener, + next_set()) + + def remove_listener(): + """Remove sunset listener.""" + remove() + + return remove_listener # pylint: disable=too-many-arguments @@ -152,8 +163,7 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, """Fire every time event that comes in.""" action(event.data[ATTR_NOW]) - hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) - return time_change_listener + return hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) pmp = _process_time_match year, month, day = pmp(year), pmp(month), pmp(day) @@ -178,8 +188,7 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, action(now) - hass.bus.listen(EVENT_TIME_CHANGED, pattern_time_change_listener) - return pattern_time_change_listener + return hass.bus.listen(EVENT_TIME_CHANGED, pattern_time_change_listener) # pylint: disable=too-many-arguments diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 008fdb9374d..73ef08ce1ff 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -7,7 +7,7 @@ from typing import Optional, Sequence import voluptuous as vol from homeassistant.core import HomeAssistant -from homeassistant.const import EVENT_TIME_CHANGED, CONF_CONDITION +from homeassistant.const import CONF_CONDITION from homeassistant.helpers import ( service, condition, template, config_validation as cv) from homeassistant.helpers.event import track_point_in_utc_time @@ -47,7 +47,7 @@ class Script(): self.can_cancel = any(CONF_DELAY in action for action in self.sequence) self._lock = threading.Lock() - self._delay_listener = None + self._unsub_delay_listener = None @property def is_running(self) -> bool: @@ -72,7 +72,7 @@ class Script(): # Call ourselves in the future to continue work def script_delay(now): """Called after delay is done.""" - self._delay_listener = None + self._unsub_delay_listener = None self.run(variables) delay = action[CONF_DELAY] @@ -83,7 +83,7 @@ class Script(): cv.positive_timedelta)( template.render(self.hass, delay)) - self._delay_listener = track_point_in_utc_time( + self._unsub_delay_listener = track_point_in_utc_time( self.hass, script_delay, date_util.utcnow() + delay) self._cur = cur + 1 @@ -139,10 +139,9 @@ class Script(): def _remove_listener(self): """Remove point in time listener, if any.""" - if self._delay_listener: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, - self._delay_listener) - self._delay_listener = None + if self._unsub_delay_listener: + self._unsub_delay_listener() + self._unsub_delay_listener = None def _log(self, msg): """Logger helper.""" diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4c5f14bf0f1..3678585141d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -149,7 +149,7 @@ class TestMQTT(unittest.TestCase): def test_subscribe_topic(self): """Test the subscription of a topic.""" - mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + unsub = mqtt.subscribe(self.hass, 'test-topic', self.record_calls) fire_mqtt_message(self.hass, 'test-topic', 'test-payload') @@ -158,6 +158,13 @@ class TestMQTT(unittest.TestCase): self.assertEqual('test-topic', self.calls[0][0]) self.assertEqual('test-payload', self.calls[0][1]) + unsub() + + fire_mqtt_message(self.hass, 'test-topic', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_subscribe_topic_not_match(self): """Test if subscribed topic is not a match.""" mqtt.subscribe(self.hass, 'test-topic', self.record_calls) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 5d9f8d28e20..704a501eefc 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -65,13 +65,21 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(runs)) + unsub = track_point_in_time( + self.hass, lambda x: runs.append(1), birthday_paulus) + unsub() + + self._send_time_changed(after_birthday) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + def test_track_time_change(self): """Test tracking time change.""" wildcard_runs = [] specific_runs = [] - track_time_change(self.hass, lambda x: wildcard_runs.append(1)) - track_utc_time_change( + unsub = track_time_change(self.hass, lambda x: wildcard_runs.append(1)) + unsub_utc = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), second=[0, 30]) self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) @@ -89,6 +97,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(specific_runs)) self.assertEqual(3, len(wildcard_runs)) + unsub() + unsub_utc() + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + self.assertEqual(3, len(wildcard_runs)) + def test_track_state_change(self): """Test track_state_change.""" # 2 lists to track how often our callbacks get called @@ -186,11 +202,12 @@ class TestEventHelpers(unittest.TestCase): # Track sunrise runs = [] - track_sunrise(self.hass, lambda: runs.append(1)) + unsub = track_sunrise(self.hass, lambda: runs.append(1)) offset_runs = [] offset = timedelta(minutes=30) - track_sunrise(self.hass, lambda: offset_runs.append(1), offset) + unsub2 = track_sunrise(self.hass, lambda: offset_runs.append(1), + offset) # run tests self._send_time_changed(next_rising - offset) @@ -208,6 +225,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(runs)) self.assertEqual(1, len(offset_runs)) + unsub() + unsub2() + + self._send_time_changed(next_rising + offset) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + self.assertEqual(1, len(offset_runs)) + def test_track_sunset(self): """Test track the sunset.""" latitude = 32.87336 @@ -232,11 +257,11 @@ class TestEventHelpers(unittest.TestCase): # Track sunset runs = [] - track_sunset(self.hass, lambda: runs.append(1)) + unsub = track_sunset(self.hass, lambda: runs.append(1)) offset_runs = [] offset = timedelta(minutes=30) - track_sunset(self.hass, lambda: offset_runs.append(1), offset) + unsub2 = track_sunset(self.hass, lambda: offset_runs.append(1), offset) # Run tests self._send_time_changed(next_setting - offset) @@ -254,6 +279,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(runs)) self.assertEqual(1, len(offset_runs)) + unsub() + unsub2() + + self._send_time_changed(next_setting + offset) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + self.assertEqual(1, len(offset_runs)) + def _send_time_changed(self, now): """Send a time changed event.""" self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) @@ -262,7 +295,7 @@ class TestEventHelpers(unittest.TestCase): """Test periodic tasks per minute.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), minute='/5') self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) @@ -277,11 +310,17 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2014, 5, 24, 12, 5, 0)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + def test_periodic_task_hour(self): """Test periodic tasks per hour.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), hour='/2') self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) @@ -304,11 +343,17 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(3, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(3, len(specific_runs)) + def test_periodic_task_day(self): """Test periodic tasks per day.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), day='/2') self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) @@ -323,11 +368,17 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + def test_periodic_task_year(self): """Test periodic tasks per year.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), year='/2') self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) @@ -342,6 +393,12 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + def test_periodic_task_wrong_input(self): """Test periodic tasks with wrong input.""" specific_runs = [] diff --git a/tests/test_core.py b/tests/test_core.py index aa3cdd2aecc..0a67d933119 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -175,6 +175,29 @@ class TestEventBus(unittest.TestCase): # Try deleting listener while category doesn't exist either self.bus.remove_listener('test', listener) + def test_unsubscribe_listener(self): + """Test unsubscribe listener from returned function.""" + self.bus._pool.add_worker() + calls = [] + + def listener(event): + """Mock listener.""" + calls.append(event) + + unsub = self.bus.listen('test', listener) + + self.bus.fire('test') + self.bus._pool.block_till_done() + + assert len(calls) == 1 + + unsub() + + self.bus.fire('event') + self.bus._pool.block_till_done() + + assert len(calls) == 1 + def test_listen_once_event(self): """Test listen_once_event method.""" runs = [] From 3fa1963345aed7949a3e999a944acba450187666 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 25 Aug 2016 23:25:57 -0700 Subject: [PATCH 012/208] Convert automation to entities with services --- .../components/automation/__init__.py | 202 +++++++++++++++--- homeassistant/components/automation/event.py | 3 +- homeassistant/components/automation/mqtt.py | 4 +- .../components/automation/numeric_state.py | 5 +- homeassistant/components/automation/state.py | 20 +- homeassistant/components/automation/sun.py | 6 +- .../components/automation/template.py | 3 +- homeassistant/components/automation/time.py | 6 +- homeassistant/components/automation/zone.py | 6 +- tests/components/automation/test_event.py | 7 + tests/components/automation/test_init.py | 75 ++++++- tests/components/automation/test_mqtt.py | 6 + .../automation/test_numeric_state.py | 8 + tests/components/automation/test_state.py | 6 + tests/components/automation/test_sun.py | 12 ++ tests/components/automation/test_template.py | 33 ++- tests/components/automation/test_time.py | 6 + tests/components/automation/test_zone.py | 18 ++ 18 files changed, 365 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index d99043f0c75..fe443515e8a 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -4,19 +4,26 @@ Allow to setup simple automation rules via the config file. For more details about this component, please refer to the documentation at https://home-assistant.io/components/automation/ """ +from functools import partial import logging import voluptuous as vol from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_TOGGLE) from homeassistant.components import logbook from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import get_platform +from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv DOMAIN = 'automation' +ENTITY_ID_FORMAT = DOMAIN + '.{}' DEPENDENCIES = ['group'] @@ -36,6 +43,10 @@ DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND METHOD_TRIGGER = 'trigger' METHOD_IF_ACTION = 'if_action' +ATTR_LAST_TRIGGERED = 'last_triggered' +ATTR_VARIABLES = 'variables' +SERVICE_TRIGGER = 'trigger' + _LOGGER = logging.getLogger(__name__) @@ -88,41 +99,170 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, vol.Required(CONF_CONDITION_TYPE, default=DEFAULT_CONDITION_TYPE): vol.All(vol.Lower, vol.Any(CONDITION_TYPE_AND, CONDITION_TYPE_OR)), - CONF_CONDITION: _CONDITION_SCHEMA, + vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, }) +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +TRIGGER_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_VARIABLES, default={}): dict, +}) + + +def is_on(hass, entity_id=None): + """ + Return true if specified automation entity_id is on. + + Check all automation if no entity_id specified. + """ + entity_ids = [entity_id] if entity_id else hass.states.entity_ids(DOMAIN) + return any(hass.states.is_state(entity_id, STATE_ON) + for entity_id in entity_ids) + + +def turn_on(hass, entity_id=None): + """Turn on specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +def turn_off(hass, entity_id=None): + """Turn off specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + + +def toggle(hass, entity_id=None): + """Toggle specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + + +def trigger(hass, entity_id=None): + """Trigger specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TRIGGER, data) + def setup(hass, config): """Setup the automation.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + success = False for config_key in extract_domain_configs(config, DOMAIN): conf = config[config_key] for list_no, config_block in enumerate(conf): - name = config_block.get(CONF_ALIAS, "{}, {}".format(config_key, - list_no)) - success = (_setup_automation(hass, config_block, name, config) or - success) + name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, + list_no) - return success + action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + if CONF_CONDITION in config_block: + cond_func = _process_if(hass, config, config_block) -def _setup_automation(hass, config_block, name, config): - """Setup one instance of automation.""" - action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + if cond_func is None: + continue + else: + def cond_func(variables): + """Condition will always pass.""" + return True - if CONF_CONDITION in config_block: - action = _process_if(hass, config, config_block, action) + attach_triggers = partial(_process_trigger, hass, config, + config_block.get(CONF_TRIGGER, []), name) + entity = AutomationEntity(name, attach_triggers, cond_func, action) + component.add_entities((entity,)) + success = True - if action is None: - return False + if not success: + return False + + def trigger_service_handler(service_call): + """Handle automation triggers.""" + for entity in component.extract_from_service(service_call): + entity.trigger(service_call.data.get(ATTR_VARIABLES)) + + def service_handler(service_call): + """Handle automation service calls.""" + for entity in component.extract_from_service(service_call): + getattr(entity, service_call.service)() + + hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler, + schema=TRIGGER_SERVICE_SCHEMA) + + for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE): + hass.services.register(DOMAIN, service, service_handler, + schema=SERVICE_SCHEMA) - _process_trigger(hass, config, config_block.get(CONF_TRIGGER, []), name, - action) return True +class AutomationEntity(ToggleEntity): + """Entity to show status of entity.""" + + def __init__(self, name, attach_triggers, cond_func, action): + """Initialize an automation entity.""" + self._name = name + self._attach_triggers = attach_triggers + self._detach_triggers = attach_triggers(self.trigger) + self._cond_func = cond_func + self._action = action + self._enabled = True + self._last_triggered = None + + @property + def name(self): + """Name of the automation.""" + return self._name + + @property + def should_poll(self): + """No polling needed for automation entities.""" + return False + + @property + def state_attributes(self): + """Return the entity state attributes.""" + return { + ATTR_LAST_TRIGGERED: self._last_triggered + } + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._enabled + + def turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + if self._enabled: + return + + self._detach_triggers = self._attach_triggers(self.trigger) + self._enabled = True + self.update_ha_state() + + def turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if not self._enabled: + return + + self._detach_triggers() + self._detach_triggers = None + self._enabled = False + self.update_ha_state() + + def trigger(self, variables): + """Trigger automation.""" + if self._cond_func(variables): + self._action(variables) + self._last_triggered = utcnow() + self.update_ha_state() + + def _get_action(hass, config, name): """Return an action based on a configuration.""" script_obj = script.Script(hass, config, name) @@ -136,7 +276,7 @@ def _get_action(hass, config, name): return action -def _process_if(hass, config, p_config, action): +def _process_if(hass, config, p_config): """Process if checks.""" cond_type = p_config.get(CONF_CONDITION_TYPE, DEFAULT_CONDITION_TYPE).lower() @@ -182,29 +322,43 @@ def _process_if(hass, config, p_config, action): if cond_type == CONDITION_TYPE_AND: def if_action(variables=None): """AND all conditions.""" - if all(check(hass, variables) for check in checks): - action(variables) + return all(check(hass, variables) for check in checks) else: def if_action(variables=None): """OR all conditions.""" - if any(check(hass, variables) for check in checks): - action(variables) + return any(check(hass, variables) for check in checks) return if_action def _process_trigger(hass, config, trigger_configs, name, action): """Setup the triggers.""" + removes = [] + for conf in trigger_configs: platform = _resolve_platform(METHOD_TRIGGER, hass, config, conf.get(CONF_PLATFORM)) if platform is None: continue - if platform.trigger(hass, conf, action): - _LOGGER.info("Initialized rule %s", name) - else: + remove = platform.trigger(hass, conf, action) + + if not remove: _LOGGER.error("Error setting up rule %s", name) + continue + + _LOGGER.info("Initialized rule %s", name) + removes.append(remove) + + if not removes: + return None + + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers def _resolve_platform(method, hass, config, platform): diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 6b3160996f3..795dd94a71f 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -39,5 +39,4 @@ def trigger(hass, config, action): }, }) - hass.bus.listen(event_type, handle_event) - return True + return hass.bus.listen(event_type, handle_event) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index e4a6b221e04..5cd60ff0cea 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -39,6 +39,4 @@ def trigger(hass, config, action): } }) - mqtt.subscribe(hass, topic, mqtt_automation_listener) - - return True + return mqtt.subscribe(hass, topic, mqtt_automation_listener) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3a148b0880f..608063b4708 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -63,7 +63,4 @@ def trigger(hass, config, action): action(variables) - track_state_change( - hass, entity_id, state_automation_listener) - - return True + return track_state_change(hass, entity_id, state_automation_listener) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index d0044bc8c4b..c36466311a9 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -38,9 +38,13 @@ def trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL time_delta = config.get(CONF_FOR) + remove_state_for_cancel = None + remove_state_for_listener = None def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" + nonlocal remove_state_for_cancel, remove_state_for_listener + def call_action(): """Call action with right context.""" action({ @@ -75,7 +79,17 @@ def trigger(hass, config, action): remove_state_for_cancel = track_state_change( hass, entity, state_for_cancel_listener) - track_state_change( - hass, entity_id, state_automation_listener, from_state, to_state) + unsub = track_state_change(hass, entity_id, state_automation_listener, + from_state, to_state) - return True + def remove(): + """Remove state listeners.""" + unsub() + + if remove_state_for_cancel is not None: + remove_state_for_cancel() + + if remove_state_for_listener is not None: + remove_state_for_listener() + + return remove diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 7666847575e..991f9b3b385 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -42,8 +42,6 @@ def trigger(hass, config, action): # Do something to call action if event == SUN_EVENT_SUNRISE: - track_sunrise(hass, call_action, offset) + return track_sunrise(hass, call_action, offset) else: - track_sunset(hass, call_action, offset) - - return True + return track_sunset(hass, call_action, offset) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 1cfbf45a24d..0891590a539 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -49,5 +49,4 @@ def trigger(hass, config, action): elif not template_result: already_triggered = False - track_state_change(hass, MATCH_ALL, state_changed_listener) - return True + return track_state_change(hass, MATCH_ALL, state_changed_listener) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index ca80536ea96..0732e2b212c 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -47,7 +47,5 @@ def trigger(hass, config, action): }, }) - track_time_change(hass, time_automation_listener, - hour=hours, minute=minutes, second=seconds) - - return True + return track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index 5578bf052c4..ec948684805 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -58,7 +58,5 @@ def trigger(hass, config, action): }, }) - track_state_change( - hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL) - - return True + return track_state_change(hass, entity_id, zone_automation_listener, + MATCH_ALL, MATCH_ALL) diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index ef5d380075b..80b1f507651 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -44,6 +44,13 @@ class TestAutomationEvent(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_with_data(self): """Test the firing of events with data.""" assert _setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index e90ffe8d765..744bd0becfb 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,9 +1,11 @@ """The tests for the automation component.""" import unittest +from unittest.mock import patch from homeassistant.bootstrap import _setup_component import homeassistant.components.automation as automation from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant @@ -45,6 +47,7 @@ class TestAutomation(unittest.TestCase): """Test service data.""" assert _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { + 'alias': 'hello', 'trigger': { 'platform': 'event', 'event_type': 'test_event', @@ -59,10 +62,17 @@ class TestAutomation(unittest.TestCase): } }) - self.hass.bus.fire('test_event') - self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) - self.assertEqual('event - test_event', self.calls[0].data['some']) + time = dt_util.utcnow() + + with patch('homeassistant.components.automation.utcnow', + return_value=time): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + assert 'event - test_event' == self.calls[0].data['some'] + state = self.hass.states.get('automation.hello') + assert state is not None + assert state.attributes.get('last_triggered') == time def test_service_specify_entity_id(self): """Test service data.""" @@ -347,3 +357,60 @@ class TestAutomation(unittest.TestCase): assert len(self.calls) == 2 assert self.calls[0].data['position'] == 0 assert self.calls[1].data['position'] == 1 + + def test_services(self): + """ """ + entity_id = 'automation.hello' + + assert self.hass.states.get(entity_id) is None + assert not automation.is_on(self.hass, entity_id) + + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + assert self.hass.states.get(entity_id) is not None + assert automation.is_on(self.hass, entity_id) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + + automation.turn_off(self.hass, entity_id) + self.hass.pool.block_till_done() + + assert not automation.is_on(self.hass, entity_id) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + + automation.toggle(self.hass, entity_id) + self.hass.pool.block_till_done() + + assert automation.is_on(self.hass, entity_id) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 + + automation.trigger(self.hass, entity_id) + self.hass.pool.block_till_done() + assert len(self.calls) == 3 + + automation.turn_off(self.hass, entity_id) + self.hass.pool.block_till_done() + automation.trigger(self.hass, entity_id) + self.hass.pool.block_till_done() + assert len(self.calls) == 4 + + automation.turn_on(self.hass, entity_id) + self.hass.pool.block_till_done() + assert automation.is_on(self.hass, entity_id) diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index 29d55b424f2..9bd22d0675c 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -50,6 +50,12 @@ class TestAutomationMQTT(unittest.TestCase): self.assertEqual('mqtt - test-topic - test_payload', self.calls[0].data['some']) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + fire_mqtt_message(self.hass, 'test-topic', 'test_payload') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_topic_and_payload_match(self): """Test if message is fired on topic and payload match.""" assert _setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index f7d1447632f..9ee8514052c 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -45,6 +45,14 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + # Set above 12 so the automation will fire again + self.hass.states.set('test.entity', 12) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_over_to_below(self): """"Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 4a6971124b6..0b715cb365c 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -59,6 +59,12 @@ class TestAutomationState(unittest.TestCase): 'state - test.entity - hello - world - None', self.calls[0].data['some']) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + self.hass.states.set('test.entity', 'planet') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_with_from_filter(self): """Test for firing on entity change with filter.""" assert _setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index 745e7c060ca..d3bbd254e1b 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -54,6 +54,18 @@ class TestAutomationSun(unittest.TestCase): } }) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + fire_time_changed(self.hass, trigger_time) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + with patch('homeassistant.util.dt.utcnow', + return_value=now): + automation.turn_on(self.hass) + self.hass.pool.block_till_done() + fire_time_changed(self.hass, trigger_time) self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index a643b731492..a33da951cc8 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -45,6 +45,13 @@ class TestAutomationTemplate(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + self.hass.states.set('test.entity', 'planet') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_change_str(self): """Test for firing on change.""" assert _setup_component(self.hass, automation.DOMAIN, { @@ -149,6 +156,9 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'hello') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) @@ -209,9 +219,12 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() - self.assertEqual(0, len(self.calls)) + assert len(self.calls) == 0 def test_if_fires_on_change_with_template_advanced(self): """Test for firing on change with template advanced.""" @@ -237,6 +250,9 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) @@ -287,29 +303,32 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() - self.assertEqual(0, len(self.calls)) + assert len(self.calls) == 0 self.hass.states.set('test.entity', 'home') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'work') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'not_home') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'home') self.hass.pool.block_till_done() - self.assertEqual(2, len(self.calls)) + assert len(self.calls) == 2 def test_if_action(self): """Test for firing if action.""" diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index b36ce8c92b5..3c195f2eb38 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -43,7 +43,13 @@ class TestAutomationTime(unittest.TestCase): }) fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0)) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0)) self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index 24980b466bf..9d4161547ef 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -74,6 +74,24 @@ class TestAutomationZone(unittest.TestCase): 'zone - test.entity - hello - hello - test', self.calls[0].data['some']) + # Set out of zone again so we can trigger call + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + def test_if_not_fires_for_enter_on_zone_leave(self): """Test for not firing on zone leave.""" self.hass.states.set('test.entity', 'hello', { From 5a25c7427638594bc730569d14c29b5d3ad5c91a Mon Sep 17 00:00:00 2001 From: NMA Date: Fri, 26 Aug 2016 19:52:08 +0530 Subject: [PATCH 013/208] Refactored zone creation based on code review feedback, enhanced configuration --- .../components/device_tracker/owntracks.py | 33 +++++++++++++------ homeassistant/components/zone.py | 33 ++++++++++--------- .../device_tracker/test_owntracks.py | 21 ++++++++++-- 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 3d95e0e0268..caf0f38ad7f 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -23,23 +23,28 @@ BEACON_DEV_ID = 'beacon' LOCATION_TOPIC = 'owntracks/+/+' EVENT_TOPIC = 'owntracks/+/+/event' -WAYPOINT_TOPIC = 'owntracks/{}/+/waypoint' +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' _LOGGER = logging.getLogger(__name__) LOCK = threading.Lock() CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' VALIDATE_LOCATION = 'location' VALIDATE_TRANSITION = 'transition' +WAYPOINT_LAT_KEY = 'lat' +WAYPOINT_LON_KEY = 'lon' + def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import_user = config.get(CONF_WAYPOINT_IMPORT_USER) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT, True) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) def validate_payload(payload, data_type): """Validate OwnTracks payload.""" @@ -198,10 +203,12 @@ def setup_scanner(hass, config, see): _LOGGER.info("Got %d waypoints from %s", len(wayps), topic) for wayp in wayps: name = wayp['desc'] - lat = wayp['lat'] - lon = wayp['lon'] + lat = wayp[WAYPOINT_LAT_KEY] + lon = wayp[WAYPOINT_LON_KEY] rad = wayp['rad'] - zone_comp.add_zone(hass, name, lat, lon, rad) + zone = zone_comp.Zone(hass, name, lat, lon, rad, + zone_comp.ICON_IMPORT, False, True) + zone_comp.add_zone(hass, name, zone) def see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" @@ -216,9 +223,15 @@ def setup_scanner(hass, config, see): mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1) - if waypoint_import_user is not None: - mqtt.subscribe(hass, WAYPOINT_TOPIC.format(waypoint_import_user), - owntracks_waypoint_update, 1) + if waypoint_import: + if waypoint_whitelist is None: + mqtt.subscribe(hass, WAYPOINT_TOPIC.format('+', '+'), + owntracks_waypoint_update, 1) + else: + for whitelist_user in waypoint_whitelist: + mqtt.subscribe(hass, WAYPOINT_TOPIC.format(whitelist_user, + '+'), + owntracks_waypoint_update, 1) return True @@ -231,7 +244,7 @@ def _parse_see_args(topic, data): kwargs = { 'dev_id': dev_id, 'host_name': host_name, - 'gps': (data['lat'], data['lon']) + 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]) } if 'acc' in data: kwargs['gps_accuracy'] = data['acc'] diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index d097b0b76ef..a9ad05434cd 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -92,17 +92,16 @@ def setup(hass, config): 'Each zone needs a latitude and longitude.') continue - zone = Zone(hass, name, latitude, longitude, radius, icon, - passive, False) - zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, - entities) - zone.update_ha_state() + zone = Zone(hass, name, latitude, longitude, radius, + icon, passive, False) + add_zone(hass, name, zone, entities) entities.add(zone.entity_id) if ENTITY_ID_HOME not in entities: - zone = Zone(hass, hass.config.location_name, hass.config.latitude, - hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, - False, False) + zone = Zone(hass, hass.config.location_name, + hass.config.latitude, hass.config.longitude, + DEFAULT_RADIUS, ICON_HOME, False, False) + add_zone(hass, hass.config.location_name, zone, entities) zone.entity_id = ENTITY_ID_HOME zone.update_ha_state() @@ -110,20 +109,24 @@ def setup(hass, config): # Add a zone to the existing set -def add_zone(hass, name, latitude, longitude, radius): +def add_zone(hass, name, zone, entities=None): """Add a zone from other components.""" _LOGGER.info("Adding new zone %s", name) - entities = set() + if entities is None: + _entities = set() + else: + _entities = entities - if hass.states.get('zone.' + name) is None: - zone = Zone(hass, name, latitude, longitude, radius, ICON_IMPORT, - False, True) + zone_exists = hass.states.get('zone.' + str(name)) + if zone_exists is None: zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, - entities) + _entities) zone.update_ha_state() - entities.add(zone.entity_id) + _entities.add(zone.entity_id) + return zone else: _LOGGER.info("Zone already exists") + return zone_exists class Zone(Entity): diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 4abad8b6ca1..9a2e1e6d643 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -17,7 +17,10 @@ DEVICE = 'phone' LOCATION_TOPIC = "owntracks/{}/{}".format(USER, DEVICE) EVENT_TOPIC = "owntracks/{}/{}/event".format(USER, DEVICE) -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'.format(USER, DEVICE) +WAYPOINT_TOPIC = owntracks.WAYPOINT_TOPIC.format(USER, DEVICE) +USER_BLACKLIST = 'ram' +WAYPOINT_TOPIC_BLOCKED = owntracks.WAYPOINT_TOPIC.format(USER_BLACKLIST, + DEVICE) DEVICE_TRACKER_STATE = "device_tracker.{}_{}".format(USER, DEVICE) @@ -25,7 +28,8 @@ IBEACON_DEVICE = 'keys' REGION_TRACKER_STATE = "device_tracker.beacon_{}".format(IBEACON_DEVICE) CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_WAYPOINT_IMPORT_USER = 'waypoint_import_user' +CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT +CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST LOCATION_MESSAGE = { 'batt': 92, @@ -168,7 +172,8 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT_USER: USER + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] }})) self.hass.states.set( @@ -565,3 +570,13 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assertTrue(wayp is not None) wayp = self.hass.states.get('zone.exp_wayp2') self.assertTrue(wayp is not None) + + def test_waypoint_import_blacklist(self): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get('zone.exp_wayp1') + self.assertTrue(wayp is None) + wayp = self.hass.states.get('zone.exp_wayp2') + self.assertTrue(wayp is None) From 2430acf3ad486e92e64bb6d65299d8130c6b6fc7 Mon Sep 17 00:00:00 2001 From: NMA Date: Fri, 26 Aug 2016 22:00:48 +0530 Subject: [PATCH 014/208] Added unit test to enhance waypoint_whitelist coverage --- .../device_tracker/test_owntracks.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 9a2e1e6d643..cd520daf0a4 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -217,6 +217,10 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): except FileNotFoundError: pass + def mock_see(**kwargs): + """Fake see method for owntracks.""" + return + def send_message(self, topic, message): """Test the sending of a message.""" fire_mqtt_message( @@ -580,3 +584,19 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assertTrue(wayp is None) wayp = self.hass.states.get('zone.exp_wayp2') self.assertTrue(wayp is None) + + def test_waypoint_import_no_whitelist(self): + """Test import of list of waypoints with no whitelist set.""" + test_config = { + CONF_PLATFORM: 'owntracks', + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True + } + owntracks.setup_scanner(self.hass, test_config, self.mock_see) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get('zone.exp_wayp1') + self.assertTrue(wayp is not None) + wayp = self.hass.states.get('zone.exp_wayp2') + self.assertTrue(wayp is not None) From 586208b3eda20f527b83c2be07fe9ca8506ca63e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 27 Aug 2016 07:43:42 +0100 Subject: [PATCH 015/208] Fix JSON encoder issue in recorder --- homeassistant/components/recorder/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 6e3e2db064d..671623ec564 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -92,7 +92,8 @@ class States(Base): # type: ignore else: dbstate.domain = state.domain dbstate.state = state.state - dbstate.attributes = json.dumps(dict(state.attributes)) + dbstate.attributes = json.dumps(dict(state.attributes), + cls=JSONEncoder) dbstate.last_changed = state.last_changed dbstate.last_updated = state.last_updated From 7f27cc5468af351956789605ad3d98c30a2be0bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 27 Aug 2016 07:45:46 +0100 Subject: [PATCH 016/208] Fix tests docstring --- tests/components/automation/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 744bd0becfb..77727ca56b5 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -359,7 +359,7 @@ class TestAutomation(unittest.TestCase): assert self.calls[1].data['position'] == 1 def test_services(self): - """ """ + """Test the automation services for turning entities on/off.""" entity_id = 'automation.hello' assert self.hass.states.get(entity_id) is None From 70fe7f747a54f87e60248e2f9f3b242880dda241 Mon Sep 17 00:00:00 2001 From: NMA Date: Sun, 28 Aug 2016 13:18:30 +0530 Subject: [PATCH 017/208] * Improved zone naming in waypoint import * Added more test coverage for owntracks and zone --- .../components/device_tracker/owntracks.py | 22 ++++-- homeassistant/components/zone.py | 7 +- .../device_tracker/test_owntracks.py | 67 ++++++++++++++++--- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index caf0f38ad7f..c895537e5ff 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -203,12 +203,13 @@ def setup_scanner(hass, config, see): _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'] - zone = zone_comp.Zone(hass, name, lat, lon, rad, + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, zone_comp.ICON_IMPORT, False, True) - zone_comp.add_zone(hass, name, zone) + zone_comp.add_zone(hass, pretty_name, zone) def see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" @@ -236,11 +237,22 @@ def setup_scanner(hass, config, see): return True +def parse_topic(topic, pretty=False): + """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple.""" + 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) + + def _parse_see_args(topic, data): """Parse the OwnTracks location parameters, into the format see expects.""" - parts = topic.split('/') - dev_id = slugify('{}_{}'.format(parts[1], parts[2])) - host_name = parts[1] + (host_name, dev_id) = parse_topic(topic, False) kwargs = { 'dev_id': dev_id, 'host_name': host_name, diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index a9ad05434cd..a7841578e2b 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -116,11 +116,10 @@ def add_zone(hass, name, zone, entities=None): _entities = set() else: _entities = entities - - zone_exists = hass.states.get('zone.' + str(name)) + zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, + _entities) + zone_exists = hass.states.get(zone.entity_id) if zone_exists is None: - zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, - _entities) zone.update_ha_state() _entities.add(zone.entity_id) return zone diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index cd520daf0a4..57125d6e6ea 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -136,6 +136,26 @@ WAYPOINTS_EXPORTED_MESSAGE = { ] } +WAYPOINTS_UPDATED_MESSAGE = { + "_type": "waypoints", + "_creator": "test", + "waypoints": [ + { + "_type": "waypoint", + "tst": 4, + "lat": 9, + "lon": 47, + "rad": 50, + "desc": "exp_wayp1" + }, + ] +} + +WAYPOINT_ENTITY_NAMES = ['zone.greg_phone__exp_wayp1', + 'zone.greg_phone__exp_wayp2', + 'zone.ram_phone__exp_wayp1', + 'zone.ram_phone__exp_wayp2'] + REGION_ENTER_ZERO_MESSAGE = { 'lon': 1.0, 'event': 'enter', @@ -160,6 +180,9 @@ REGION_LEAVE_ZERO_MESSAGE = { 'lat': 20.0, '_type': 'transition'} +BAD_JSON_PREFIX = '--$this is bad json#--' +BAD_JSON_SUFFIX = '** and it ends here ^^' + class TestDeviceTrackerOwnTracks(unittest.TestCase): """Test the OwnTrack sensor.""" @@ -221,10 +244,14 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): """Fake see method for owntracks.""" return - def send_message(self, topic, message): + def send_message(self, topic, message, corrupt=False): """Test the sending of a message.""" - fire_mqtt_message( - self.hass, topic, json.dumps(message)) + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + fire_mqtt_message(self.hass, topic, mod_message) self.hass.pool.block_till_done() def assert_location_state(self, location): @@ -570,9 +597,9 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() self.send_message(WAYPOINT_TOPIC, waypoints_message) # Check if it made it into states - wayp = self.hass.states.get('zone.exp_wayp1') + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) self.assertTrue(wayp is not None) - wayp = self.hass.states.get('zone.exp_wayp2') + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) self.assertTrue(wayp is not None) def test_waypoint_import_blacklist(self): @@ -580,9 +607,9 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states - wayp = self.hass.states.get('zone.exp_wayp1') + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is None) - wayp = self.hass.states.get('zone.exp_wayp2') + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) self.assertTrue(wayp is None) def test_waypoint_import_no_whitelist(self): @@ -596,7 +623,29 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states - wayp = self.hass.states.get('zone.exp_wayp1') + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is not None) - wayp = self.hass.states.get('zone.exp_wayp2') + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) self.assertTrue(wayp is not None) + + def test_waypoint_import_bad_json(self): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + self.assertTrue(wayp is None) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + self.assertTrue(wayp is None) + + def test_waypoint_import_existing(self): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + self.assertTrue(wayp == new_wayp) From 4864a67dcde44f6879f9d439c73ab76dad959599 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 30 Aug 2016 14:23:00 -0700 Subject: [PATCH 018/208] Back to 0.28.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a720c989907..ce46c62850b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = '0.27.1' +__version__ = '0.28.0.dev0' REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' From 12e2c38436d0ebe4ae6302bf311578cac831bacd Mon Sep 17 00:00:00 2001 From: NMA Date: Wed, 31 Aug 2016 08:16:01 +0530 Subject: [PATCH 019/208] Code review feedback from @pavoni --- homeassistant/components/device_tracker/owntracks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index c895537e5ff..abc503a370a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -35,6 +35,7 @@ CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' VALIDATE_LOCATION = 'location' VALIDATE_TRANSITION = 'transition' +VALIDATE_WAYPOINTS = 'waypoints' WAYPOINT_LAT_KEY = 'lat' WAYPOINT_LON_KEY = 'lon' @@ -59,7 +60,7 @@ def setup_scanner(hass, config, see): 'because of missing or malformatted data: %s', data_type, data) return None - if data_type == VALIDATE_TRANSITION or data_type == 'waypoints': + 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: @@ -195,7 +196,7 @@ def setup_scanner(hass, config, see): """List of waypoints published by a user.""" # Docs on available data: # http://owntracks.org/booklet/tech/json/#_typewaypoints - data = validate_payload(payload, 'waypoints') + data = validate_payload(payload, VALIDATE_WAYPOINTS) if not data: return From eadd07dc7da7d3649e5819f464ebc7e7c977ae3c Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 31 Aug 2016 03:52:19 -0400 Subject: [PATCH 020/208] Added bitfield of features for flux_led since we are supporting effects --- homeassistant/components/light/flux_led.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index b3aa7e59901..0d48e4c794b 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, + SUPPORT_EFFECT, SUPPORT_RGB_COLOR, Light) import homeassistant.helpers.config_validation as cv @@ -33,7 +34,8 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional('automatic_add', default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) -SUPPORT_FLUX_LED = SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR +SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | + SUPPORT_RGB_COLOR) def setup_platform(hass, config, add_devices_callback, discovery_info=None): From dfee4433126401b0501a427536f46aa42183f27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 31 Aug 2016 18:09:22 +0200 Subject: [PATCH 021/208] Host should be optional for apcupsd component (#3072) --- homeassistant/components/apcupsd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py index 867208305b0..72db3e06dee 100644 --- a/homeassistant/components/apcupsd.py +++ b/homeassistant/components/apcupsd.py @@ -32,7 +32,7 @@ VALUE_ONLINE = 'ONLINE' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }), }, extra=vol.ALLOW_EXTRA) From 705b3571f45cb36ce7b9899e6e1393370f3f90ee Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 31 Aug 2016 18:12:34 +0200 Subject: [PATCH 022/208] Use voluptuous for file (#3049) --- homeassistant/components/notify/file.py | 26 ++++++++++++++----------- tests/components/notify/test_file.py | 7 ++++--- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py index 3d04bf13334..3e2f20707ad 100644 --- a/homeassistant/components/notify/file.py +++ b/homeassistant/components/notify/file.py @@ -7,24 +7,28 @@ https://home-assistant.io/components/notify.file/ import logging import os +import voluptuous as vol + import homeassistant.util.dt as dt_util from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_FILENAME +import homeassistant.helpers.config_validation as cv + +CONF_TIMESTAMP = 'timestamp' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILENAME): cv.string, + vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, +}) _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """Get the file notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['filename', - 'timestamp']}, - _LOGGER): - return None - - filename = config['filename'] - timestamp = config['timestamp'] + filename = config[CONF_FILENAME] + timestamp = config[CONF_TIMESTAMP] return FileNotificationService(hass, filename, timestamp) @@ -48,7 +52,7 @@ class FileNotificationService(BaseNotificationService): '-' * 80) file.write(title) - if self.add_timestamp == 1: + if self.add_timestamp: text = '{} {}\n'.format(dt_util.utcnow().isoformat(), message) file.write(text) else: diff --git a/tests/components/notify/test_file.py b/tests/components/notify/test_file.py index 87d275b05f9..2564b0bd65a 100644 --- a/tests/components/notify/test_file.py +++ b/tests/components/notify/test_file.py @@ -8,6 +8,7 @@ import homeassistant.components.notify as notify from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT) import homeassistant.util.dt as dt_util +from homeassistant.bootstrap import _setup_component from tests.common import get_test_home_assistant @@ -25,11 +26,11 @@ class TestNotifyFile(unittest.TestCase): def test_bad_config(self): """Test set up the platform with bad/missing config.""" - self.assertFalse(notify.setup(self.hass, { + self.assertFalse(_setup_component(self.hass, notify.DOMAIN, { 'notify': { 'name': 'test', 'platform': 'file', - } + }, })) @patch('homeassistant.util.dt.utcnow') @@ -45,7 +46,7 @@ class TestNotifyFile(unittest.TestCase): 'name': 'test', 'platform': 'file', 'filename': filename, - 'timestamp': 0 + 'timestamp': False, } })) title = '{} notifications (Log started: {})\n{}\n'.format( From e5b6592870a3b7e3ad5464fdb1fe61ece80bd2a8 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Wed, 31 Aug 2016 21:50:03 +0200 Subject: [PATCH 023/208] =?UTF-8?q?Zwave=20climate=20Bugfix:=20if=20some?= =?UTF-8?q?=20setpoints=20have=20different=20units,=20we=20should=20fetch?= =?UTF-8?q?=20the=20o=E2=80=A6=20(#3078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bugfix: if some setpoints have different units, we should fetch the one that are active. * Move order of population for first time detection * Default to config if None unit_of_measurement --- homeassistant/components/climate/zwave.py | 42 +++++++++-------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index c8425ab4e8c..b29c6a92b63 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ( ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) from homeassistant.components import zwave -from homeassistant.const import (TEMP_FAHRENHEIT, TEMP_CELSIUS) _LOGGER = logging.getLogger(__name__) @@ -59,11 +58,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.debug("No discovery_info=%s or no NETWORK=%s", discovery_info, zwave.NETWORK) return - + temp_unit = hass.config.units.temperature_unit node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] value = node.values[discovery_info[ATTR_VALUE_ID]] value.set_change_verified(False) - add_devices([ZWaveClimate(value)]) + add_devices([ZWaveClimate(value, temp_unit)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", discovery_info, zwave.NETWORK) @@ -73,7 +72,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): """Represents a ZWave Climate device.""" # pylint: disable=too-many-public-methods, too-many-instance-attributes - def __init__(self, value): + def __init__(self, value, temp_unit): """Initialize the zwave climate device.""" from openzwave.network import ZWaveNetwork from pydispatch import dispatcher @@ -87,7 +86,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self._fan_list = None self._current_swing_mode = None self._swing_list = None - self._unit = None + self._unit = temp_unit + _LOGGER.debug("temp_unit is %s", self._unit) self._zxt_120 = None self.update_properties() # register listener @@ -115,18 +115,6 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): def update_properties(self): """Callback on data change for the registered node/value pair.""" - # Set point - for value in self._node.get_values( - class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): - self._unit = value.units - if self.current_operation is not None: - if SET_TEMP_TO_INDEX.get(self._current_operation) \ - != value.index: - continue - if self._zxt_120: - continue - self._target_temperature = int(value.data) - # Operation Mode for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): @@ -158,6 +146,17 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): _LOGGER.debug("self._swing_list=%s", self._swing_list) _LOGGER.debug("self._current_swing_mode=%s", self._current_swing_mode) + # Set point + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): + if self.current_operation is not None: + if SET_TEMP_TO_INDEX.get(self._current_operation) \ + != value.index: + continue + self._unit = value.units + if self._zxt_120: + continue + self._target_temperature = int(value.data) @property def should_poll(self): @@ -187,14 +186,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): @property def unit_of_measurement(self): """Return the unit of measurement.""" - unit = self._unit - if unit == 'C': - return TEMP_CELSIUS - elif unit == 'F': - return TEMP_FAHRENHEIT - else: - _LOGGER.exception("unit_of_measurement=%s is not valid", - unit) + return self._unit @property def current_temperature(self): From 5f664acb4fe1f6f7c1870df97155cb4cba08f6b4 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Wed, 31 Aug 2016 22:30:44 +0200 Subject: [PATCH 024/208] unit fix (#3083) --- homeassistant/components/climate/zwave.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index b29c6a92b63..63dddaf466b 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ( ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) from homeassistant.components import zwave +from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT) _LOGGER = logging.getLogger(__name__) @@ -186,7 +187,13 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return self._unit + unit = self._unit + if unit == 'C': + return TEMP_CELSIUS + elif unit == 'F': + return TEMP_FAHRENHEIT + else: + return self._unit @property def current_temperature(self): From 4b12ea04d6f10d8205daecf2ec1cd21527c77420 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Thu, 1 Sep 2016 07:13:33 +0200 Subject: [PATCH 025/208] humidity slider (#3088) --- homeassistant/components/climate/ecobee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index da4b29dfe92..2417a8562ce 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -181,7 +181,7 @@ class Thermostat(ClimateDevice): else: operation = status return { - "humidity": self.thermostat['runtime']['actualHumidity'], + "actual_humidity": self.thermostat['runtime']['actualHumidity'], "fan": self.fan, "mode": self.mode, "operation": operation, From 571cbdf40c7232e108ac7cadb0e5a0d01601aebd Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Thu, 1 Sep 2016 09:31:52 +0200 Subject: [PATCH 026/208] If device was off target temp was null. Default to Heating setpoint (#3091) --- homeassistant/components/climate/zwave.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 63dddaf466b..b77a97e5d93 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -150,7 +150,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): # Set point for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): - if self.current_operation is not None: + if self.current_operation is not None and \ + self.current_operation != 'Off': if SET_TEMP_TO_INDEX.get(self._current_operation) \ != value.index: continue From c792dd41260487548b765a13e5ac4b0d0852b9ba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 1 Sep 2016 09:12:42 +0100 Subject: [PATCH 027/208] Fix linting --- homeassistant/components/automation/__init__.py | 1 + homeassistant/components/automation/state.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fe443515e8a..6f5396afa15 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -150,6 +150,7 @@ def trigger(hass, entity_id=None): def setup(hass, config): """Setup the automation.""" + # pylint: disable=too-many-locals component = EntityComponent(_LOGGER, DOMAIN, hass) success = False diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index c36466311a9..8e0eb5231a5 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -85,7 +85,7 @@ def trigger(hass, config, action): def remove(): """Remove state listeners.""" unsub() - + # pylint: disable=not-callable if remove_state_for_cancel is not None: remove_state_for_cancel() From e045a6f0c36bc375178002f2d18f650a766fdfde Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 1 Sep 2016 10:37:58 +0200 Subject: [PATCH 028/208] Upgrade pyuserinput to 0.1.11 (#3068) --- homeassistant/components/keyboard.py | 5 +++-- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keyboard.py b/homeassistant/components/keyboard.py index 1a33b7dc082..65f94c730bc 100644 --- a/homeassistant/components/keyboard.py +++ b/homeassistant/components/keyboard.py @@ -11,8 +11,9 @@ from homeassistant.const import ( SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP) -DOMAIN = "keyboard" -REQUIREMENTS = ['pyuserinput==0.1.9'] +REQUIREMENTS = ['pyuserinput==0.1.11'] + +DOMAIN = 'keyboard' TAP_KEY_SCHEMA = vol.Schema({}) diff --git a/requirements_all.txt b/requirements_all.txt index 934dec82bed..ecd9c146073 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ python-twitch==1.3.0 python-wink==0.7.13 # homeassistant.components.keyboard -pyuserinput==0.1.9 +pyuserinput==0.1.11 # homeassistant.components.vera pyvera==0.2.15 From 88e600827eb0f12addead7c9d4e9dfeafbab8340 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 1 Sep 2016 10:38:50 +0200 Subject: [PATCH 029/208] Upgrade pyowm to 2.4.0 (#3067) --- .../components/sensor/openweathermap.py | 58 ++++++++++--------- requirements_all.txt | 2 +- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index efaa8d450b4..e7936cc0535 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -9,14 +9,24 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import (CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_PLATFORM, CONF_MONITORED_CONDITIONS) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_MONITORED_CONDITIONS) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.3.2'] +REQUIREMENTS = ['pyowm==2.4.0'] + _LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = 'forecast' + +DEFAULT_NAME = 'OWM' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + SENSOR_TYPES = { 'weather': ['Condition', None], 'temperature': ['Temperature', None], @@ -28,17 +38,14 @@ SENSOR_TYPES = { 'snow': ['Snow', 'mm'] } -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'openweathermap', - vol.Required(CONF_API_KEY): vol.Coerce(str), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): - [vol.In(SENSOR_TYPES.keys())], - vol.Optional('forecast', default=False): cv.boolean + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_FORECAST, default=False): cv.boolean }) -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the OpenWeatherMap sensor.""" @@ -49,32 +56,29 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from pyowm import OWM SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit - forecast = config.get('forecast') - owm = OWM(config.get(CONF_API_KEY, None)) + + name = config.get(CONF_NAME) + forecast = config.get(CONF_FORECAST) + + owm = OWM(config.get(CONF_API_KEY)) if not owm: _LOGGER.error( "Connection error " - "Please check your settings for OpenWeatherMap.") + "Please check your settings for OpenWeatherMap") return False data = WeatherData(owm, forecast, hass.config.latitude, hass.config.longitude) dev = [] - try: - for variable in config['monitored_conditions']: - if variable not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) - else: - dev.append(OpenWeatherMapSensor(data, variable, - SENSOR_TYPES[variable][1])) - except KeyError: - pass + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append(OpenWeatherMapSensor( + name, data, variable, SENSOR_TYPES[variable][1])) if forecast: SENSOR_TYPES['forecast'] = ['Forecast', None] - dev.append(OpenWeatherMapSensor(data, 'forecast', - SENSOR_TYPES['temperature'][1])) + dev.append(OpenWeatherMapSensor( + name, data, 'forecast', SENSOR_TYPES['temperature'][1])) add_devices(dev) @@ -83,9 +87,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class OpenWeatherMapSensor(Entity): """Implementation of an OpenWeatherMap sensor.""" - def __init__(self, weather_data, sensor_type, temp_unit): + def __init__(self, name, weather_data, sensor_type, temp_unit): """Initialize the sensor.""" - self.client_name = 'Weather' + self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] self.owa_client = weather_data self.temp_unit = temp_unit diff --git a/requirements_all.txt b/requirements_all.txt index ecd9c146073..4dd67781bd8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -343,7 +343,7 @@ pynetio==0.1.6 pynx584==0.2 # homeassistant.components.sensor.openweathermap -pyowm==2.3.2 +pyowm==2.4.0 # homeassistant.components.switch.acer_projector pyserial<=3.1 From 5036bb0bc695067d167cc8d63986ab2639759cd6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 1 Sep 2016 15:35:00 +0200 Subject: [PATCH 030/208] improve isfile validation check (#3101) --- homeassistant/components/notify/file.py | 3 +-- homeassistant/components/notify/smtp.py | 4 ++-- homeassistant/helpers/config_validation.py | 13 +++++++++++-- tests/components/camera/test_local_file.py | 2 +- tests/helpers/test_config_validation.py | 20 ++++++++++++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py index 3e2f20707ad..ec79cab59ea 100644 --- a/homeassistant/components/notify/file.py +++ b/homeassistant/components/notify/file.py @@ -54,7 +54,6 @@ class FileNotificationService(BaseNotificationService): if self.add_timestamp: text = '{} {}\n'.format(dt_util.utcnow().isoformat(), message) - file.write(text) else: text = '{}\n'.format(message) - file.write(text) + file.write(text) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 9ac73a49e3d..60fccc0c510 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -28,10 +28,10 @@ CONF_DEBUG = 'debug' CONF_SERVER = 'server' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_RECIPIENT): vol.Email, vol.Optional(CONF_SERVER, default='localhost'): cv.string, vol.Optional(CONF_PORT, default=25): cv.port, - vol.Optional(CONF_SENDER): cv.string, + vol.Optional(CONF_SENDER): vol.Email, vol.Optional(CONF_STARTTLS, default=False): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index d9c761832dc..c9a6917bb01 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 +import os from urllib.parse import urlparse from typing import Any, Union, TypeVar, Callable, Sequence, List, Dict @@ -65,9 +66,17 @@ def boolean(value: Any) -> bool: return bool(value) -def isfile(value): +def isfile(value: Any) -> str: """Validate that the value is an existing file.""" - return vol.IsFile('not a file')(value) + if value is None: + raise vol.Invalid('None is not file') + file_in = str(value) + + if not os.path.isfile(file_in): + raise vol.Invalid('not a file') + if not os.access(file_in, os.R_OK): + raise vol.Invalid('file not readable') + return file_in def ensure_list(value: Union[T, Sequence[T]]) -> List[T]: diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index c30f59968e8..546152b0d8a 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -59,7 +59,7 @@ class TestLocalCamera(unittest.TestCase): fp.flush() with mock.patch('os.access', return_value=False): - assert setup_component(self.hass, 'camera', { + assert not setup_component(self.hass, 'camera', { 'camera': { 'name': 'config_test', 'platform': 'local_file', diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 14d80d9104d..637d5ead0b7 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1,4 +1,6 @@ from datetime import timedelta +import os +import tempfile import pytest import voluptuous as vol @@ -59,6 +61,24 @@ def test_port(): schema(value) +def test_isfile(): + """Validate that the value is an existing file.""" + schema = vol.Schema(cv.isfile) + + with tempfile.NamedTemporaryFile() as fp: + pass + + for value in ('invalid', None, -1, 0, 80000, fp.name): + with pytest.raises(vol.Invalid): + schema(value) + + with tempfile.TemporaryDirectory() as tmp_path: + tmp_file = os.path.join(tmp_path, "test.txt") + with open(tmp_file, "w") as tmp_handl: + tmp_handl.write("test file") + schema(tmp_file) + + def test_url(): """Test URL.""" schema = vol.Schema(cv.url) From 0bcfb65a30051e6846cd085e643c4ee4a4daa388 Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Thu, 1 Sep 2016 14:35:46 +0100 Subject: [PATCH 031/208] Refactor notification titles to allow for them to be None, this also includes a change in Telegram to only include the title if it's present, and to use a Markdown parse mode for messages (#3100) --- homeassistant/components/notify/__init__.py | 2 +- homeassistant/components/notify/aws_sns.py | 5 +++-- homeassistant/components/notify/file.py | 4 ++-- homeassistant/components/notify/gntp.py | 5 +++-- homeassistant/components/notify/html5.py | 6 +++--- homeassistant/components/notify/instapush.py | 4 ++-- homeassistant/components/notify/joaoapps_join.py | 4 ++-- homeassistant/components/notify/nma.py | 4 ++-- homeassistant/components/notify/pushbullet.py | 4 ++-- homeassistant/components/notify/pushetta.py | 4 ++-- homeassistant/components/notify/pushover.py | 5 +++-- homeassistant/components/notify/rest.py | 6 ++++-- homeassistant/components/notify/sendgrid.py | 4 ++-- homeassistant/components/notify/smtp.py | 5 +++-- homeassistant/components/notify/syslog.py | 4 ++-- homeassistant/components/notify/telegram.py | 12 +++++++++++- homeassistant/components/notify/xmpp.py | 4 ++-- tests/components/notify/test_smtp.py | 2 +- 18 files changed, 50 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index a28a50d766f..d7ebfbbcd1f 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -41,7 +41,7 @@ PLATFORM_SCHEMA = vol.Schema({ NOTIFY_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.template, - vol.Optional(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, + vol.Optional(ATTR_TITLE): cv.string, vol.Optional(ATTR_TARGET): cv.string, vol.Optional(ATTR_DATA): dict, }) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index dec72b18633..31cac90105c 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TARGET, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, BaseNotificationService) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] @@ -76,5 +76,6 @@ class AWSSNS(BaseNotificationService): for k, v in kwargs.items() if v} for target in targets: self.client.publish(TargetArn=target, Message=message, - Subject=kwargs.get(ATTR_TITLE), + Subject=kwargs.get(ATTR_TITLE, + ATTR_TITLE_DEFAULT), MessageAttributes=message_attributes) diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py index ec79cab59ea..82ec2420df8 100644 --- a/homeassistant/components/notify/file.py +++ b/homeassistant/components/notify/file.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.util.dt as dt_util from homeassistant.components.notify import ( - ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_FILENAME import homeassistant.helpers.config_validation as cv @@ -47,7 +47,7 @@ class FileNotificationService(BaseNotificationService): with open(self.filepath, 'a') as file: if os.stat(self.filepath).st_size == 0: title = '{} notifications (Log started: {})\n{}\n'.format( - kwargs.get(ATTR_TITLE), + kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), dt_util.utcnow().isoformat(), '-' * 80) file.write(title) diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py index 5b5d377e1ea..64033f03125 100644 --- a/homeassistant/components/notify/gntp.py +++ b/homeassistant/components/notify/gntp.py @@ -8,7 +8,7 @@ import logging import os from homeassistant.components.notify import ( - ATTR_TITLE, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService) REQUIREMENTS = ['gntp==1.0.3'] @@ -59,5 +59,6 @@ class GNTPNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - self.gntp.notify(noteType="Notification", title=kwargs.get(ATTR_TITLE), + self.gntp.notify(noteType="Notification", + title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), description=message) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 54727a60d3f..103ccc7885b 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -18,8 +18,8 @@ from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, URL_ROOT) from homeassistant.util import ensure_unique_string from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_DATA, BaseNotificationService, - PLATFORM_SCHEMA) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, + BaseNotificationService, PLATFORM_SCHEMA) from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv @@ -332,7 +332,7 @@ class HTML5NotificationService(BaseNotificationService): 'icon': '/static/icons/favicon-192x192.png', ATTR_TAG: tag, 'timestamp': (timestamp*1000), # Javascript ms since epoch - ATTR_TITLE: kwargs.get(ATTR_TITLE) + ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) } data = kwargs.get(ATTR_DATA) diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 028afb32468..7dbbbfced35 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -10,7 +10,7 @@ import logging import requests from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) from homeassistant.const import CONF_API_KEY from homeassistant.helpers import validate_config @@ -70,7 +70,7 @@ class InstapushNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = {"event": self._event, "trackers": {self._tracker: title + " : " + message}} diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py index 67ecd493a06..ca82b6bb934 100644 --- a/homeassistant/components/notify/joaoapps_join.py +++ b/homeassistant/components/notify/joaoapps_join.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/notify.join/ import logging import voluptuous as vol from homeassistant.components.notify import ( - ATTR_DATA, ATTR_TITLE, BaseNotificationService) + ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService) from homeassistant.const import CONF_PLATFORM, CONF_NAME, CONF_API_KEY import homeassistant.helpers.config_validation as cv @@ -52,7 +52,7 @@ class JoinNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" from pyjoin import send_notification - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) or {} send_notification(device_id=self._device_id, text=message, diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py index f37f5ca8bd0..ef75abb2fe4 100644 --- a/homeassistant/components/notify/nma.py +++ b/homeassistant/components/notify/nma.py @@ -10,7 +10,7 @@ import xml.etree.ElementTree as ET import requests from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) from homeassistant.const import CONF_API_KEY from homeassistant.helpers import validate_config @@ -49,7 +49,7 @@ class NmaNotificationService(BaseNotificationService): data = { "apikey": self._api_key, "application": 'home-assistant', - "event": kwargs.get(ATTR_TITLE), + "event": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), "description": message, "priority": 0, } diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 20a6daebf05..7c924223ae1 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/notify.pushbullet/ import logging from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, BaseNotificationService) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService) from homeassistant.const import CONF_API_KEY _LOGGER = logging.getLogger(__name__) @@ -73,7 +73,7 @@ class PushBulletNotificationService(BaseNotificationService): call which doesn't require a push object. """ targets = kwargs.get(ATTR_TARGET) - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) refreshed = False if not targets: diff --git a/homeassistant/components/notify/pushetta.py b/homeassistant/components/notify/pushetta.py index 234c8978452..441379b2285 100644 --- a/homeassistant/components/notify/pushetta.py +++ b/homeassistant/components/notify/pushetta.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/notify.pushetta/ import logging from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) from homeassistant.const import CONF_API_KEY from homeassistant.helpers import validate_config @@ -52,6 +52,6 @@ class PushettaNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) self.pushetta.pushMessage(self._channel_name, "{} {}".format(title, message)) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index 5ded1ebe778..de82bb4e819 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TARGET, ATTR_DATA, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, ATTR_DATA, + BaseNotificationService) from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv @@ -56,7 +57,7 @@ class PushoverNotificationService(BaseNotificationService): # Make a copy and use empty dict if necessary data = dict(kwargs.get(ATTR_DATA) or {}) - data['title'] = kwargs.get(ATTR_TITLE) + data['title'] = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) target = kwargs.get(ATTR_TARGET) if target is not None: diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 5cc556a1957..0a82b8d5d72 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -10,7 +10,8 @@ import requests import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, BaseNotificationService, PLATFORM_SCHEMA) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + PLATFORM_SCHEMA) from homeassistant.const import (CONF_RESOURCE, CONF_METHOD, CONF_NAME) import homeassistant.helpers.config_validation as cv @@ -71,7 +72,8 @@ class RestNotificationService(BaseNotificationService): } if self._title_param_name is not None: - data[self._title_param_name] = kwargs.get(ATTR_TITLE) + data[self._title_param_name] = kwargs.get(ATTR_TITLE, + ATTR_TITLE_DEFAULT) if self._target_param_name is not None: data[self._target_param_name] = kwargs.get(ATTR_TARGET) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 894b35a85d4..b0805338844 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/notify.sendgrid/ import logging from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) from homeassistant.helpers import validate_config REQUIREMENTS = ['sendgrid==3.2.10'] @@ -44,7 +44,7 @@ class SendgridNotificationService(BaseNotificationService): def send_message(self, message='', **kwargs): """Send an email to a user via SendGrid.""" - subject = kwargs.get(ATTR_TITLE) + subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = { "personalizations": [ diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 60fccc0c510..694058a11ce 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -14,7 +14,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, + BaseNotificationService) from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PORT) _LOGGER = logging.getLogger(__name__) @@ -120,7 +121,7 @@ class MailNotificationService(BaseNotificationService): Will send plain text normally, or will build a multipart HTML message with inline image attachments if images config is defined. """ - subject = kwargs.get(ATTR_TITLE) + subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) if data: diff --git a/homeassistant/components/notify/syslog.py b/homeassistant/components/notify/syslog.py index 381a92394c3..8b36f0ea858 100644 --- a/homeassistant/components/notify/syslog.py +++ b/homeassistant/components/notify/syslog.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/notify.syslog/ import logging from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) @@ -80,7 +80,7 @@ class SyslogNotificationService(BaseNotificationService): """Send a message to a user.""" import syslog - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) syslog.openlog(title, self._option, self._facility) syslog.syslog(self._priority, message) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 8da916eb1f3..35d6a5a6977 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -106,10 +106,20 @@ class TelegramNotificationService(BaseNotificationService): elif data is not None and ATTR_DOCUMENT in data: return self.send_document(data.get(ATTR_DOCUMENT)) + text = '' + + if title: + text = '{} {}'.format(title, message) + else: + text = message + + parse_mode = telegram.parsemode.ParseMode.MARKDOWN + # send message try: self.bot.sendMessage(chat_id=self._chat_id, - text=title + " " + message) + text=text, + parse_mode=parse_mode) except telegram.error.TelegramError: _LOGGER.exception("Error sending message.") return diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 68c0ce2979f..e5b7792776e 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/notify.xmpp/ import logging from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) from homeassistant.helpers import validate_config REQUIREMENTS = ['sleekxmpp==1.3.1', @@ -45,7 +45,7 @@ class XmppNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = "{}: {}".format(title, message) if title else message send_message(self._sender + '/home-assistant', self._password, diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py index 7fa61fbdc24..bbaca71ee13 100644 --- a/tests/components/notify/test_smtp.py +++ b/tests/components/notify/test_smtp.py @@ -37,7 +37,7 @@ class TestNotifySmtp(unittest.TestCase): expected = ('Content-Type: text/plain; charset="us-ascii"\n' 'MIME-Version: 1.0\n' 'Content-Transfer-Encoding: 7bit\n' - 'Subject: \n' + 'Subject: Home Assistant\n' 'To: testrecip@test.com\n' 'From: test@test.com\n' 'X-Mailer: HomeAssistant\n' From 60f540315a023cd3637af577cc62acc81bb9e23d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 1 Sep 2016 14:38:06 +0100 Subject: [PATCH 032/208] Fix broken test --- homeassistant/helpers/event.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index ff81b693704..512b173a249 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -107,7 +107,8 @@ def track_sunrise(hass, action, offset=None): def sunrise_automation_listener(now): """Called when it's time for action.""" nonlocal remove - track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + remove = track_point_in_utc_time(hass, sunrise_automation_listener, + next_rise()) action() remove = track_point_in_utc_time(hass, sunrise_automation_listener, From 831d96995d7754f4a8f35de12a6e308a3718290e Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 1 Sep 2016 18:58:32 +0200 Subject: [PATCH 033/208] rfxtrx sensor clean up --- homeassistant/components/sensor/rfxtrx.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index f9f7270c8e3..60afd80997d 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -9,6 +9,7 @@ import voluptuous as vol import homeassistant.components.rfxtrx as rfxtrx import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PLATFORM from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.components.rfxtrx import ( @@ -20,7 +21,7 @@ DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = vol.Schema({ - vol.Required("platform"): rfxtrx.DOMAIN, + vol.Required(CONF_PLATFORM): rfxtrx.DOMAIN, vol.Optional(CONF_DEVICES, default={}): vol.All(dict, rfxtrx.valid_sensor), vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) @@ -31,7 +32,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): # pylint: disable=too-many-locals from RFXtrx import SensorEvent sensors = [] - for packet_id, entity_info in config['devices'].items(): + for packet_id, entity_info in config[CONF_DEVICES].items(): event = rfxtrx.get_rfx_object(packet_id) device_id = "sensor_" + slugify(event.device.id_string.lower()) if device_id in rfxtrx.RFX_DEVICES: @@ -41,7 +42,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sub_sensors = {} data_types = entity_info[ATTR_DATA_TYPE] if len(data_types) == 0: - data_types = ["Unknown"] + data_types = [''] for data_type in DATA_TYPES: if data_type in event.values: data_types = [data_type] @@ -52,7 +53,6 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sensors.append(new_sensor) sub_sensors[_data_type] = new_sensor rfxtrx.RFX_DEVICES[device_id] = sub_sensors - add_devices_callback(sensors) def sensor_update(event): @@ -75,7 +75,6 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sensors[key].entity_id, } ) - return # Add entity if not exist and the automatic_add is True @@ -86,7 +85,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): _LOGGER.info("Automatic add rfxtrx.sensor: %s", pkt_id) - data_type = "Unknown" + data_type = '' for _data_type in DATA_TYPES: if _data_type in event.values: data_type = _data_type @@ -119,9 +118,9 @@ class RfxtrxSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self.event and self.data_type in self.event.values: - return self.event.values[self.data_type] - return None + if not self.event: + return None + return self.event.values.get(self.data_type) @property def name(self): @@ -131,8 +130,9 @@ class RfxtrxSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if self.event: - return self.event.values + if not self.event: + return None + return self.event.values @property def unit_of_measurement(self): From 748d7f4ecb63d9a57be497bcfe2768eb0ac5e0ca Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 1 Sep 2016 21:57:47 +0200 Subject: [PATCH 034/208] Bitcoin sensor use warning instead of error (#3103) --- homeassistant/components/sensor/bitcoin.py | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 0d859314bb4..51b5f9bba3b 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -17,6 +17,16 @@ from homeassistant.util import Throttle REQUIREMENTS = ['blockchain==1.3.3'] +_LOGGER = logging.getLogger(__name__) + +CONF_CURRENCY = 'currency' + +DEFAULT_CURRENCY = 'USD' + +ICON = 'mdi:currency-btc' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + OPTION_TYPES = { 'exchangerate': ['Exchange rate (1 BTC)', None], 'trade_volume_btc': ['Trade volume', 'BTC'], @@ -41,20 +51,12 @@ OPTION_TYPES = { 'market_price_usd': ['Market price', 'USD'] } -ICON = 'mdi:currency-btc' -CONF_CURRENCY = 'currency' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DISPLAY_OPTIONS, default=[]): - [vol.In(OPTION_TYPES)], - vol.Optional(CONF_CURRENCY, default='USD'): cv.string, + vol.All(cv.ensure_list, [vol.In(OPTION_TYPES)]), + vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, }) -_LOGGER = logging.getLogger(__name__) - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Bitcoin sensors.""" @@ -63,8 +65,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): currency = config.get(CONF_CURRENCY) if currency not in exchangerates.get_ticker(): - _LOGGER.error('Currency "%s" is not available. Using "USD"', currency) - currency = 'USD' + _LOGGER.warning('Currency "%s" is not available. Using "USD"', + currency) + currency = DEFAULT_CURRENCY data = BitcoinData() dev = [] From 24d412938e8506d09cffe382b9a5c8cb599af9d8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 1 Sep 2016 22:04:00 +0200 Subject: [PATCH 035/208] Use voluptuous for HDMI CEC & CONF_DEVICES constants (#3107) --- .../components/climate/eq3btsmart.py | 5 +-- .../components/device_tracker/mqtt.py | 3 +- homeassistant/components/hdmi_cec.py | 33 +++++++++---------- homeassistant/const.py | 1 + 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 01114972811..ee7fb7050f5 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -7,14 +7,12 @@ https://home-assistant.io/components/climate.eq3btsmart/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, CONF_DEVICES from homeassistant.util.temperature import convert REQUIREMENTS = ['bluepy_devices==0.2.0'] CONF_MAC = 'mac' -CONF_DEVICES = 'devices' -CONF_ID = 'id' _LOGGER = logging.getLogger(__name__) @@ -28,7 +26,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(EQ3BTSmartThermostat(mac, name)) add_devices(devices) - return True # pylint: disable=too-many-instance-attributes, import-error, abstract-method diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index 0998e227857..2318eb44dd1 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -9,13 +9,12 @@ import logging import voluptuous as vol import homeassistant.components.mqtt as mqtt +from homeassistant.const import CONF_DEVICES from homeassistant.components.mqtt import CONF_QOS import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['mqtt'] -CONF_DEVICES = 'devices' - _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 89cbe789c58..4fab7f84bd3 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -1,25 +1,28 @@ """ CEC component. -Requires libcec + Python bindings. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hdmi_cec/ """ - import logging + import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_START + +from homeassistant.const import (EVENT_HOMEASSISTANT_START, CONF_DEVICES) import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) _CEC = None -DOMAIN = 'hdmi_cec' -SERVICE_SELECT_DEVICE = 'select_device' -SERVICE_POWER_ON = 'power_on' -SERVICE_STANDBY = 'standby' -CONF_DEVICES = 'devices' +_LOGGER = logging.getLogger(__name__) + ATTR_DEVICE = 'device' + +DOMAIN = 'hdmi_cec' + MAX_DEPTH = 4 +SERVICE_POWER_ON = 'power_on' +SERVICE_SELECT_DEVICE = 'select_device' +SERVICE_STANDBY = 'standby' # pylint: disable=unnecessary-lambda DEVICE_SCHEMA = vol.Schema({ @@ -27,7 +30,6 @@ DEVICE_SCHEMA = vol.Schema({ cv.string) }) - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_DEVICES): DEVICE_SCHEMA @@ -56,17 +58,14 @@ def setup(hass, config): """Setup CEC capability.""" global _CEC - # cec is only available if libcec is properly installed - # and the Python bindings are accessible. try: import cec except ImportError: _LOGGER.error("libcec must be installed") return False - # Parse configuration into a dict of device name - # to physical address represented as a list of - # four elements. + # Parse configuration into a dict of device name to physical address + # represented as a list of four elements. flat = {} for pair in parse_mapping(config[DOMAIN].get(CONF_DEVICES, {})): flat[pair[0]] = pad_physical_address(pair[1]) @@ -78,7 +77,7 @@ def setup(hass, config): cfg.bMonitorOnly = 1 cfg.clientVersion = cec.LIBCEC_VERSION_CURRENT - # Set up CEC adapter. + # Setup CEC adapter. _CEC = cec.ICECAdapter.Create(cfg) def _power_on(call): diff --git a/homeassistant/const.py b/homeassistant/const.py index ce46c62850b..5b23fb5395d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -31,6 +31,7 @@ CONF_CODE = 'code' CONF_CONDITION = 'condition' CONF_CUSTOMIZE = 'customize' CONF_DEVICE = 'device' +CONF_DEVICES = 'devices' CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' CONF_DISPLAY_OPTIONS = 'display_options' CONF_ELEVATION = 'elevation' From d2dfe04ec90e6cad6d7ac5602f708f41656d3081 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 1 Sep 2016 22:08:03 +0200 Subject: [PATCH 036/208] Update voluptuous for nest (#3109) * Update configuration check * Extend platform --- .../components/binary_sensor/nest.py | 16 ++++++++-------- homeassistant/components/climate/nest.py | 7 +++---- homeassistant/components/nest.py | 19 ++++++++++--------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 9f963b730b5..4dfe4d58b99 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -6,12 +6,12 @@ https://home-assistant.io/components/binary_sensor.nest/ """ import voluptuous as vol -import homeassistant.components.nest as nest -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.sensor.nest import NestSensor -from homeassistant.const import ( - CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS -) +from homeassistant.const import (CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS) +import homeassistant.components.nest as nest +import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['nest'] BINARY_TYPES = ['fan', @@ -25,11 +25,11 @@ BINARY_TYPES = ['fan', 'hvac_emer_heat_state', 'online'] -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): nest.DOMAIN, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Required(CONF_MONITORED_CONDITIONS): [vol.In(BINARY_TYPES)], + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(BINARY_TYPES)]), }) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 39746bff601..585ff804526 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -8,13 +8,12 @@ import voluptuous as vol import homeassistant.components.nest as nest from homeassistant.components.climate import ( - STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) -from homeassistant.const import TEMP_CELSIUS, CONF_PLATFORM, CONF_SCAN_INTERVAL + STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA) +from homeassistant.const import TEMP_CELSIUS, CONF_SCAN_INTERVAL DEPENDENCIES = ['nest'] -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): nest.DOMAIN, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1)), }) diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 430b9baa956..d875ab0e2c0 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -1,18 +1,21 @@ """ -Support for Nest thermostats and protect smoke alarms. +Support for Nest devices. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.nest/ +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/nest/ """ import logging import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_STRUCTURE +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME, CONF_STRUCTURE) + +_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['python-nest==2.9.2'] + DOMAIN = 'nest' NEST = None @@ -21,14 +24,12 @@ STRUCTURES_TO_INCLUDE = None CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string) }) }, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) - def devices(): """Generator returning list of devices and their location.""" From 83f1272662176335e963784fed8e005dc65363e1 Mon Sep 17 00:00:00 2001 From: Open Home Automation Date: Thu, 1 Sep 2016 22:18:58 +0200 Subject: [PATCH 037/208] Fix for BLE device tracker (#3019) * Bug fix tracked devices * Added scan_duration configuration parameter --- .../device_tracker/bluetooth_le_tracker.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 6576f46bad7..ce8a535ff57 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -2,16 +2,19 @@ import logging from datetime import timedelta +import voluptuous as vol from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, + PLATFORM_SCHEMA, load_config, ) import homeassistant.util as util import homeassistant.util.dt as dt_util +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -19,6 +22,11 @@ REQUIREMENTS = ['gattlib==0.20150805'] BLE_PREFIX = 'BLE_' MIN_SEEN_NEW = 5 +CONF_SCAN_DURATION = "scan_duration" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SCAN_DURATION, default=10): cv.positive_int +}) def setup_scanner(hass, config, see): @@ -51,12 +59,13 @@ def setup_scanner(hass, config, see): """Discover Bluetooth LE devices.""" _LOGGER.debug("Discovering Bluetooth LE devices") service = DiscoveryService() - devices = service.discover(10) + devices = service.discover(duration) _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) return devices yaml_path = hass.config.path(YAML_DEVICES) + duration = config.get(CONF_SCAN_DURATION) devs_to_track = [] devs_donot_track = [] @@ -65,11 +74,13 @@ def setup_scanner(hass, config, see): # to 0 for device in load_config(yaml_path, hass, 0): # check if device is a valid bluetooth device - if device.mac and device.mac[:3].upper() == BLE_PREFIX: + if device.mac and device.mac[:4].upper() == BLE_PREFIX: if device.track: - devs_to_track.append(device.mac[3:]) + _LOGGER.debug("Adding %s to BLE tracker", device.mac) + devs_to_track.append(device.mac[4:]) else: - devs_donot_track.append(device.mac[3:]) + _LOGGER.debug("Adding %s to BLE do not track", device.mac) + devs_donot_track.append(device.mac[4:]) # if track new devices is true discover new devices # on every scan. @@ -96,7 +107,7 @@ def setup_scanner(hass, config, see): if track_new: for address in devs: if address not in devs_to_track and \ - address not in devs_donot_track: + address not in devs_donot_track: _LOGGER.info("Discovered Bluetooth LE device %s", address) see_device(address, devs[address], new_device=True) From dcfc1ef361fdc8521b0531c24a2d8a2a73220cbd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 1 Sep 2016 22:20:55 +0200 Subject: [PATCH 038/208] fix homematic climate implementation (#3114) --- homeassistant/components/climate/homematic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index be81bb9326e..e51ad5e67a5 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -101,7 +101,7 @@ class HMThermostat(homematic.HMDevice, ClimateDevice): for mode, state in HM_STATE_MAP.items(): if state == operation_mode: code = getattr(self._hmdevice, mode, 0) - self._hmdevice.STATE = code + self._hmdevice.MODE = code @property def min_temp(self): From dadcf922908748a4f77460146d713871cb89b813 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 2 Sep 2016 00:02:35 +0200 Subject: [PATCH 039/208] Allow 'None' MAC to be loaded from known_devices (#3102) --- homeassistant/components/device_tracker/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index b260eccd7d1..a4f65ab4ea4 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -388,7 +388,8 @@ def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): try: return [ Device(hass, consider_home, device.get('track', False), - str(dev_id).lower(), str(device.get('mac')).upper(), + str(dev_id).lower(), None if device.get('mac') is None + else str(device.get('mac')).upper(), device.get('name'), device.get('picture'), device.get('gravatar'), device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) From 6b6d34ba51a509cb979d421a0af656587c686c5f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 2 Sep 2016 06:27:28 +0200 Subject: [PATCH 040/208] Use voluptuous for xmpp (#3127) --- homeassistant/components/notify/xmpp.py | 27 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index e5b7792776e..35157a9bd46 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -6,30 +6,41 @@ https://home-assistant.io/components/notify.xmpp/ """ import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_PASSWORD REQUIREMENTS = ['sleekxmpp==1.3.1', 'dnspython3==1.12.0', 'pyasn1==0.1.9', 'pyasn1-modules==0.0.8'] + +CONF_SENDER = 'sender' +CONF_RECIPIENT = 'recipient' +CONF_TLS = 'tls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENDER): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, + vol.Optional(CONF_TLS, default=True): cv.boolean, +}) + + _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """Get the Jabber (XMPP) notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['sender', 'password', 'recipient']}, - _LOGGER): - return None - return XmppNotificationService( config.get('sender'), config.get('password'), config.get('recipient'), - config.get('tls', True)) + config.get('tls')) # pylint: disable=too-few-public-methods From afdd734b44635734c1c8e5e40878afa0f275bedf Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 2 Sep 2016 06:27:38 +0200 Subject: [PATCH 041/208] Use voluptuous for twitter (#3126) --- homeassistant/components/notify/twitter.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9284c4fac93..bafdc2403be 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -6,9 +6,12 @@ https://home-assistant.io/components/notify.twitter/ """ import logging -from homeassistant.components.notify import DOMAIN, BaseNotificationService +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import (PLATFORM_SCHEMA, + BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['TwitterAPI==2.4.2'] @@ -17,16 +20,16 @@ CONF_CONSUMER_KEY = "consumer_key" CONF_CONSUMER_SECRET = "consumer_secret" CONF_ACCESS_TOKEN_SECRET = "access_token_secret" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CONSUMER_KEY): cv.string, + vol.Required(CONF_CONSUMER_SECRET): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_ACCESS_TOKEN_SECRET): cv.string, +}) + def get_service(hass, config): """Get the Twitter notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_CONSUMER_KEY, CONF_CONSUMER_SECRET, - CONF_ACCESS_TOKEN, - CONF_ACCESS_TOKEN_SECRET]}, - _LOGGER): - return None - return TwitterNotificationService(config[CONF_CONSUMER_KEY], config[CONF_CONSUMER_SECRET], config[CONF_ACCESS_TOKEN], From 78f0e681ed9c898eb714de66af9a4e99ba467f8b Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 2 Sep 2016 06:28:03 +0200 Subject: [PATCH 042/208] Use voluptuous for Fritzbox and DDWRT (#3122) --- .../components/device_tracker/ddwrt.py | 26 +++++----- .../components/device_tracker/fritz.py | 47 +++++++++---------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index 02f49fe7475..4dc6229566c 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -10,10 +10,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -24,15 +25,16 @@ _LOGGER = logging.getLogger(__name__) _DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a DD-WRT scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = DdWrtDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -107,7 +109,7 @@ class DdWrtDeviceScanner(object): return False with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info('Checking ARP') url = 'http://{}/Status_Wireless.live.asp'.format(self.host) data = self.get_ddwrt_data(url) @@ -143,18 +145,18 @@ class DdWrtDeviceScanner(object): auth=(self.username, self.password), timeout=4) except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") + _LOGGER.exception('Connection to the router timed out') return if response.status_code == 200: return _parse_ddwrt_response(response.text) elif response.status_code == 401: # Authentication error _LOGGER.exception( - "Failed to authenticate, " - "please check your username and password") + 'Failed to authenticate, ' + 'please check your username and password') return else: - _LOGGER.error("Invalid response from ddwrt: %s", response) + _LOGGER.error('Invalid response from ddwrt: %s', response) def _parse_ddwrt_response(data_str): diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 8def71cce73..202919871ad 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -7,9 +7,11 @@ https://home-assistant.io/components/device_tracker.fritz/ import logging from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle REQUIREMENTS = ['https://github.com/deisi/fritzconnection/archive/' @@ -21,14 +23,17 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, + vol.Optional(CONF_PASSWORD, default='admin'): cv.string, + vol.Optional(CONF_USERNAME, default=''): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and return FritzBoxScanner.""" - if not validate_config(config, - {DOMAIN: []}, - _LOGGER): - return None - scanner = FritzBoxScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -40,22 +45,14 @@ class FritzBoxScanner(object): def __init__(self, config): """Initialize the scanner.""" self.last_results = [] - self.host = '169.254.1.1' # This IP is valid for all FRITZ!Box router. - self.username = 'admin' - self.password = '' + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] self.success_init = True # pylint: disable=import-error import fritzconnection as fc - # Check for user specific configuration - if CONF_HOST in config.keys(): - self.host = config[CONF_HOST] - if CONF_USERNAME in config.keys(): - self.username = config[CONF_USERNAME] - if CONF_PASSWORD in config.keys(): - self.password = config[CONF_PASSWORD] - # Establish a connection to the FRITZ!Box. try: self.fritz_box = fc.FritzHosts(address=self.host, @@ -70,25 +67,25 @@ class FritzBoxScanner(object): self.success_init = False if self.success_init: - _LOGGER.info("Successfully connected to %s", + _LOGGER.info('Successfully connected to %s', self.fritz_box.modelname) self._update_info() else: - _LOGGER.error("Failed to establish connection to FRITZ!Box " - "with IP: %s", self.host) + _LOGGER.error('Failed to establish connection to FRITZ!Box ' + 'with IP: %s', self.host) def scan_devices(self): """Scan for new devices and return a list of found device ids.""" self._update_info() active_hosts = [] for known_host in self.last_results: - if known_host["status"] == "1": - active_hosts.append(known_host["mac"]) + if known_host['status'] == '1': + active_hosts.append(known_host['mac']) return active_hosts def get_device_name(self, mac): """Return the name of the given device or None if is not known.""" - ret = self.fritz_box.get_specific_host_entry(mac)["NewHostName"] + ret = self.fritz_box.get_specific_host_entry(mac)['NewHostName'] if ret == {}: return None return ret @@ -99,6 +96,6 @@ class FritzBoxScanner(object): if not self.success_init: return False - _LOGGER.info("Scanning") + _LOGGER.info('Scanning') self.last_results = self.fritz_box.get_hosts_info() return True From 586e47d08d9b602d141306c8a878396b14be5682 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 2 Sep 2016 06:28:28 +0200 Subject: [PATCH 043/208] Use Voluptuous for BT Home Hub (#3121) --- .../components/device_tracker/bt_home_hub_5.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py index c447fae1635..3b4115ff355 100644 --- a/homeassistant/components/device_tracker/bt_home_hub_5.py +++ b/homeassistant/components/device_tracker/bt_home_hub_5.py @@ -13,9 +13,10 @@ import json from urllib.parse import unquote import requests +import voluptuous as vol -from homeassistant.helpers import validate_config -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST from homeassistant.util import Throttle @@ -26,14 +27,14 @@ _LOGGER = logging.getLogger(__name__) _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Return a BT Home Hub 5 scanner if successful.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST]}, - _LOGGER): - return None scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -44,7 +45,7 @@ class BTHomeHub5DeviceScanner(object): def __init__(self, config): """Initialise the scanner.""" - _LOGGER.info("Initialising BT Home Hub 5") + _LOGGER.info('Initialising BT Home Hub 5') self.host = config.get(CONF_HOST, '192.168.1.254') self.lock = threading.Lock() @@ -85,7 +86,7 @@ class BTHomeHub5DeviceScanner(object): return False with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info('Scanning') data = _get_homehub_data(self.url) From 9e38255c261a5e84fdafaad43c93580c8047230d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 2 Sep 2016 06:28:33 +0200 Subject: [PATCH 044/208] Use voluptuous for syslog (#3120) --- homeassistant/components/notify/syslog.py | 107 ++++++++++++---------- 1 file changed, 60 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/notify/syslog.py b/homeassistant/components/notify/syslog.py index 8b36f0ea858..792ed2ad631 100644 --- a/homeassistant/components/notify/syslog.py +++ b/homeassistant/components/notify/syslog.py @@ -6,63 +6,76 @@ https://home-assistant.io/components/notify.syslog/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) + + +CONF_FACILITY = 'facility' +CONF_OPTION = 'option' +CONF_PRIORITY = 'priority' + +SYSLOG_FACILITY = { + 'kernel': 'LOG_KERN', + 'user': 'LOG_USER', + 'mail': 'LOG_MAIL', + 'daemon': 'LOG_DAEMON', + 'auth': 'LOG_KERN', + 'LPR': 'LOG_LPR', + 'news': 'LOG_NEWS', + 'uucp': 'LOG_UUCP', + 'cron': 'LOG_CRON', + 'syslog': 'LOG_SYSLOG', + 'local0': 'LOG_LOCAL0', + 'local1': 'LOG_LOCAL1', + 'local2': 'LOG_LOCAL2', + 'local3': 'LOG_LOCAL3', + 'local4': 'LOG_LOCAL4', + 'local5': 'LOG_LOCAL5', + 'local6': 'LOG_LOCAL6', + 'local7': 'LOG_LOCAL7', +} + +SYSLOG_OPTION = { + 'pid': 'LOG_PID', + 'cons': 'LOG_CONS', + 'ndelay': 'LOG_NDELAY', + 'nowait': 'LOG_NOWAIT', + 'perror': 'LOG_PERROR', +} + +SYSLOG_PRIORITY = { + 5: 'LOG_EMERG', + 4: 'LOG_ALERT', + 3: 'LOG_CRIT', + 2: 'LOG_ERR', + 1: 'LOG_WARNING', + 0: 'LOG_NOTICE', + -1: 'LOG_INFO', + -2: 'LOG_DEBUG', +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FACILITY, default='syslog'): + vol.In(SYSLOG_FACILITY.keys()), + vol.Optional(CONF_OPTION, default='pid'): vol.In(SYSLOG_OPTION.keys()), + vol.Optional(CONF_PRIORITY, default=-1): vol.In(SYSLOG_PRIORITY.keys()), +}) + _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """Get the syslog notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['facility', 'option', 'priority']}, - _LOGGER): - return None - import syslog - _facility = { - 'kernel': syslog.LOG_KERN, - 'user': syslog.LOG_USER, - 'mail': syslog.LOG_MAIL, - 'daemon': syslog.LOG_DAEMON, - 'auth': syslog.LOG_KERN, - 'LPR': syslog.LOG_LPR, - 'news': syslog.LOG_NEWS, - 'uucp': syslog.LOG_UUCP, - 'cron': syslog.LOG_CRON, - 'syslog': syslog.LOG_SYSLOG, - 'local0': syslog.LOG_LOCAL0, - 'local1': syslog.LOG_LOCAL1, - 'local2': syslog.LOG_LOCAL2, - 'local3': syslog.LOG_LOCAL3, - 'local4': syslog.LOG_LOCAL4, - 'local5': syslog.LOG_LOCAL5, - 'local6': syslog.LOG_LOCAL6, - 'local7': syslog.LOG_LOCAL7, - }.get(config['facility'], 40) + facility = getattr(syslog, SYSLOG_FACILITY[config.get(CONF_FACILITY)]) + option = getattr(syslog, SYSLOG_OPTION[config.get(CONF_OPTION)]) + priority = getattr(syslog, SYSLOG_PRIORITY[config.get(CONF_PRIORITY)]) - _option = { - 'pid': syslog.LOG_PID, - 'cons': syslog.LOG_CONS, - 'ndelay': syslog.LOG_NDELAY, - 'nowait': syslog.LOG_NOWAIT, - 'perror': syslog.LOG_PERROR - }.get(config['option'], 10) - - _priority = { - 5: syslog.LOG_EMERG, - 4: syslog.LOG_ALERT, - 3: syslog.LOG_CRIT, - 2: syslog.LOG_ERR, - 1: syslog.LOG_WARNING, - 0: syslog.LOG_NOTICE, - -1: syslog.LOG_INFO, - -2: syslog.LOG_DEBUG - }.get(config['priority'], -1) - - return SyslogNotificationService(_facility, _option, _priority) + return SyslogNotificationService(facility, option, priority) # pylint: disable=too-few-public-methods From a571271c396cd43fb3f6ac8109ec1f699b0da498 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 2 Sep 2016 06:28:46 +0200 Subject: [PATCH 045/208] Use voluptuous for Aruba (#3119) --- .../components/device_tracker/aruba.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index a62306b5619..6383bc962a4 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -9,9 +9,11 @@ import re import threading from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago @@ -25,15 +27,16 @@ _DEVICES_REGEX = re.compile( r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a Aruba scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = ArubaDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -90,7 +93,7 @@ class ArubaDeviceScanner(object): def get_aruba_data(self): """Retrieve data from Aruba Access Point and return parsed result.""" import pexpect - connect = "ssh {}@{}" + connect = 'ssh {}@{}' ssh = pexpect.spawn(connect.format(self.username, self.host)) query = ssh.expect(['password:', pexpect.TIMEOUT, pexpect.EOF, 'continue connecting (yes/no)?', @@ -98,22 +101,22 @@ class ArubaDeviceScanner(object): 'Connection refused', 'Connection timed out'], timeout=120) if query == 1: - _LOGGER.error("Timeout") + _LOGGER.error('Timeout') return elif query == 2: - _LOGGER.error("Unexpected response from router") + _LOGGER.error('Unexpected response from router') return elif query == 3: ssh.sendline('yes') ssh.expect('password:') elif query == 4: - _LOGGER.error("Host key Changed") + _LOGGER.error('Host key Changed') return elif query == 5: - _LOGGER.error("Connection refused by server") + _LOGGER.error('Connection refused by server') return elif query == 6: - _LOGGER.error("Connection timed out") + _LOGGER.error('Connection timed out') return ssh.sendline(self.password) ssh.expect('#') From db7abc1cfeb749f3814b0811bd230ef91582dd77 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:28:52 +0200 Subject: [PATCH 046/208] Use constants, update configuration check, and ordering (Pilight) (#3118) * Use contants, update configuration check, and ordering * Fix pylint issue --- homeassistant/components/pilight.py | 52 +++++++++++++++-------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 07771acee00..764b972d393 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -4,7 +4,6 @@ Component to create an interface to a Pilight daemon (https://pilight.org/). For more details about this component, please refer to the documentation at https://home-assistant.io/components/pilight/ """ -# pylint: disable=import-error import logging import socket @@ -12,46 +11,49 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ensure_list -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT, + CONF_WHITELIST) REQUIREMENTS = ['pilight==0.0.2'] -DOMAIN = "pilight" +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTOCOL = 'protocol' + +DEFAULT_HOST = '127.0.0.1' +DEFAULT_PORT = 5000 +DOMAIN = 'pilight' + EVENT = 'pilight_received' -SERVICE_NAME = 'send' - -CONF_WHITELIST = 'whitelist' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_HOST, default='127.0.0.1'): cv.string, - vol.Required(CONF_PORT, default=5000): vol.Coerce(int), - vol.Optional(CONF_WHITELIST): {cv.string: [cv.string]} - }), -}, extra=vol.ALLOW_EXTRA) # The pilight code schema depends on the protocol # Thus only require to have the protocol information -ATTR_PROTOCOL = 'protocol' RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): cv.string}, extra=vol.ALLOW_EXTRA) +SERVICE_NAME = 'send' -_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]} + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Setup the pilight component.""" from pilight import pilight + host = config[DOMAIN][CONF_HOST] + port = config[DOMAIN][CONF_PORT] + try: - pilight_client = pilight.Client(host=config[DOMAIN][CONF_HOST], - port=config[DOMAIN][CONF_PORT]) + pilight_client = pilight.Client(host=host, port=port) except (socket.error, socket.timeout) as err: - _LOGGER.error( - "Unable to connect to %s on port %s: %s", - config[CONF_HOST], config[CONF_PORT], err) + _LOGGER.error("Unable to connect to %s on port %s: %s", + host, port, err) return False # Start / stop pilight-daemon connection with HA start/stop @@ -74,7 +76,7 @@ def setup(hass, config): # Patch data because of bug: # https://github.com/pilight/pilight/issues/296 # Protocol has to be in a list otherwise segfault in pilight-daemon - message_data["protocol"] = ensure_list(message_data["protocol"]) + message_data['protocol'] = ensure_list(message_data['protocol']) try: pilight_client.send_code(message_data) @@ -86,7 +88,7 @@ def setup(hass, config): # Publish received codes on the HA event bus # A whitelist of codes to be published in the event bus - whitelist = config[DOMAIN].get('whitelist', False) + whitelist = config[DOMAIN].get(CONF_WHITELIST) def handle_received_code(data): """Called when RF codes are received.""" From d8ad4e1584165efeac4b68648d9311de106102aa Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:29:35 +0200 Subject: [PATCH 047/208] Migrate to voluptuous (#3113) --- .../components/sensor/fritzbox_callmonitor.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index 7525e5fcc81..82f6ae839fb 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -1,35 +1,49 @@ """ A sensor to monitor incoming and outgoing phone calls on a Fritz!Box router. -To activate the call monitor on your Fritz!Box, dial #96*5* from any phone -connected to it. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fritzbox_callmonitor/ """ import logging import socket import threading import datetime import time + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_NAME) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Phone' DEFAULT_HOST = '169.254.1.1' # IP valid for all Fritz!Box routers DEFAULT_PORT = 1012 -# sensor values -VALUE_DEFAULT = 'idle' # initial value + +VALUE_DEFAULT = 'idle' VALUE_RING = 'ringing' VALUE_CALL = 'dialing' VALUE_CONNECT = 'talking' VALUE_DISCONNECT = 'idle' + INTERVAL_RECONNECT = 60 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Fritz!Box call monitor sensor platform.""" - host = config.get('host', DEFAULT_HOST) - port = config.get('port', DEFAULT_PORT) + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) - sensor = FritzBoxCallSensor(name=config.get('name', DEFAULT_NAME)) + sensor = FritzBoxCallSensor(name=name) add_devices([sensor]) From ed7a2270356ce3385e729ad1fc2faa618d8463e3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:30:20 +0200 Subject: [PATCH 048/208] Fix typo (#3108) --- homeassistant/components/http.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index b9a81858d39..dba1ab0f86e 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -24,21 +24,21 @@ from homeassistant.core import split_entity_id import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv -DOMAIN = "http" -REQUIREMENTS = ("cherrypy==7.1.0", "static3==0.7.0", "Werkzeug==0.11.10") +DOMAIN = 'http' +REQUIREMENTS = ('cherrypy==7.1.0', 'static3==0.7.0', 'Werkzeug==0.11.10') -CONF_API_PASSWORD = "api_password" -CONF_SERVER_HOST = "server_host" -CONF_SERVER_PORT = "server_port" -CONF_DEVELOPMENT = "development" +CONF_API_PASSWORD = 'api_password' +CONF_SERVER_HOST = 'server_host' +CONF_SERVER_PORT = 'server_port' +CONF_DEVELOPMENT = 'development' CONF_SSL_CERTIFICATE = 'ssl_certificate' CONF_SSL_KEY = 'ssl_key' CONF_CORS_ORIGINS = 'cors_allowed_origins' DATA_API_PASSWORD = 'api_password' -# TLS configuation follows the best-practice guidelines -# specified here: https://wiki.mozilla.org/Security/Server_Side_TLS +# TLS configuation follows the best-practice guidelines specified here: +# https://wiki.mozilla.org/Security/Server_Side_TLS # Intermediate guidelines are followed. SSL_VERSION = ssl.PROTOCOL_SSLv23 SSL_OPTS = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 @@ -478,7 +478,7 @@ class HomeAssistantView(object): authenticated = True if self.requires_auth and not authenticated: - _LOGGER.warning('Login attempt or request with an invalid' + _LOGGER.warning('Login attempt or request with an invalid ' 'password from %s', request.remote_addr) raise Unauthorized() From 29f2dd2ce953ad22f32fc3fcd60d73ae91f4641b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:30:25 +0200 Subject: [PATCH 049/208] Migrate to voluptuous (#3106) --- homeassistant/components/foursquare.py | 61 +++++++++++++++----------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/foursquare.py b/homeassistant/components/foursquare.py index 6fcd2312bab..b08ba89ca77 100644 --- a/homeassistant/components/foursquare.py +++ b/homeassistant/components/foursquare.py @@ -7,42 +7,51 @@ https://home-assistant.io/components/foursquare/ import logging import os import json -import requests +import requests import voluptuous as vol +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView -DOMAIN = "foursquare" - -SERVICE_CHECKIN = "checkin" - -EVENT_PUSH = "foursquare.push" -EVENT_CHECKIN = "foursquare.checkin" - -CHECKIN_SERVICE_SCHEMA = vol.Schema({ - vol.Required("venueId"): cv.string, - vol.Optional("eventId"): cv.string, - vol.Optional("shout"): cv.string, - vol.Optional("mentions"): cv.string, - vol.Optional("broadcast"): cv.string, - vol.Optional("ll"): cv.string, - vol.Optional("llAcc"): cv.string, - vol.Optional("alt"): cv.string, - vol.Optional("altAcc"): cv.string, -}) - _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ["http"] +CONF_PUSH_SECRET = 'push_secret' + +DEPENDENCIES = ['http'] +DOMAIN = 'foursquare' + +EVENT_CHECKIN = 'foursquare.checkin' +EVENT_PUSH = 'foursquare.push' + +SERVICE_CHECKIN = 'checkin' + +CHECKIN_SERVICE_SCHEMA = vol.Schema({ + vol.Optional('alt'): cv.string, + vol.Optional('altAcc'): cv.string, + vol.Optional('broadcast'): cv.string, + vol.Optional('eventId'): cv.string, + vol.Optional('ll'): cv.string, + vol.Optional('llAcc'): cv.string, + vol.Optional('mentions'): cv.string, + vol.Optional('shout'): cv.string, + vol.Required('venueId'): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_PUSH_SECRET): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Setup the Foursquare component.""" descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), "services.yaml")) + os.path.join(os.path.dirname(__file__), 'services.yaml')) config = config[DOMAIN] @@ -51,7 +60,7 @@ def setup(hass, config): url = ("https://api.foursquare.com/v2/checkins/add" "?oauth_token={}" "&v=20160802" - "&m=swarm").format(config["access_token"]) + "&m=swarm").format(config[CONF_ACCESS_TOKEN]) response = requests.post(url, data=call.data, timeout=10) if response.status_code not in (200, 201): @@ -62,12 +71,12 @@ def setup(hass, config): hass.bus.fire(EVENT_CHECKIN, response.text) # Register our service with Home Assistant. - hass.services.register(DOMAIN, "checkin", checkin_user, + hass.services.register(DOMAIN, 'checkin', checkin_user, descriptions[DOMAIN][SERVICE_CHECKIN], schema=CHECKIN_SERVICE_SCHEMA) - hass.wsgi.register_view(FoursquarePushReceiver(hass, - config["push_secret"])) + hass.wsgi.register_view(FoursquarePushReceiver( + hass, config[CONF_PUSH_SECRET])) return True From 9226cef61e1ba04499d0a1f78433f0c3891db5c5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:30:49 +0200 Subject: [PATCH 050/208] Update voluptuous (#3104) --- homeassistant/components/feedreader.py | 43 +++++++++++++++----------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/feedreader.py b/homeassistant/components/feedreader.py index 4cc0223ce9b..ce3d46b4751 100644 --- a/homeassistant/components/feedreader.py +++ b/homeassistant/components/feedreader.py @@ -1,5 +1,5 @@ """ -Support for RSS/Atom feed. +Support for RSS/Atom feeds. For more details about this component, please refer to the documentation at https://home-assistant.io/components/feedreader/ @@ -9,22 +9,39 @@ from logging import getLogger from os.path import exists from threading import Lock import pickle + import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.helpers.event import track_utc_time_change +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['feedparser==5.2.1'] + _LOGGER = getLogger(__name__) -DOMAIN = "feedreader" -EVENT_FEEDREADER = "feedreader" -# pylint: disable=no-value-for-parameter + +CONF_URLS = 'urls' + +DOMAIN = 'feedreader' + +EVENT_FEEDREADER = 'feedreader' + +MAX_ENTRIES = 20 + CONFIG_SCHEMA = vol.Schema({ DOMAIN: { - 'urls': [vol.Url()], + vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), } }, extra=vol.ALLOW_EXTRA) -MAX_ENTRIES = 20 + + +def setup(hass, config): + """Setup the feedreader component.""" + urls = config.get(DOMAIN)[CONF_URLS] + data_file = hass.config.path("{}.pickle".format(DOMAIN)) + storage = StoredData(data_file) + feeds = [FeedManager(url, hass, storage) for url in urls] + return len(feeds) > 0 # pylint: disable=too-few-public-methods @@ -83,9 +100,8 @@ class FeedManager(object): def _update_and_fire_entry(self, entry): """Update last_entry_timestamp and fire entry.""" - # We are lucky, `published_parsed` data available, - # let's make use of it to publish only new available - # entries since the last run + # We are lucky, `published_parsed` data available, let's make use of + # it to publish only new available entries since the last run if 'published_parsed' in entry.keys(): self._has_published_parsed = True self._last_entry_timestamp = max(entry.published_parsed, @@ -163,12 +179,3 @@ class StoredData(object): _LOGGER.error('Error saving pickled data to %s', self._data_file) self._cache_outdated = True - - -def setup(hass, config): - """Setup the feedreader component.""" - urls = config.get(DOMAIN)['urls'] - data_file = hass.config.path("{}.pickle".format(DOMAIN)) - storage = StoredData(data_file) - feeds = [FeedManager(url, hass, storage) for url in urls] - return len(feeds) > 0 From a50205aedba19a2f39dbe1bc9922ad321e9ae255 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 2 Sep 2016 06:31:25 +0200 Subject: [PATCH 051/208] Climate and cover bugfix (#3097) * Avoid None comparison for zwave cover. * Just rely on unit from config for unit_of_measurement * Explicit return None * Mqtt (#11) * Explicit return None * Missing service and wrong service name defined * Mqtt state was inverted, and never triggering --- homeassistant/components/climate/zwave.py | 10 +--------- homeassistant/components/cover/__init__.py | 1 + homeassistant/components/cover/mqtt.py | 4 ++-- homeassistant/components/cover/zwave.py | 2 ++ homeassistant/const.py | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index b77a97e5d93..11704b06fdf 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ( ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) from homeassistant.components import zwave -from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT) _LOGGER = logging.getLogger(__name__) @@ -155,7 +154,6 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): if SET_TEMP_TO_INDEX.get(self._current_operation) \ != value.index: continue - self._unit = value.units if self._zxt_120: continue self._target_temperature = int(value.data) @@ -188,13 +186,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): @property def unit_of_measurement(self): """Return the unit of measurement.""" - unit = self._unit - if unit == 'C': - return TEMP_CELSIUS - elif unit == 'F': - return TEMP_FAHRENHEIT - else: - return self._unit + return self._unit @property def current_temperature(self): diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 3f08c7ff229..876a8b46cfa 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -61,6 +61,7 @@ SERVICE_TO_METHOD = { SERVICE_STOP_COVER: {'method': 'stop_cover'}, SERVICE_OPEN_COVER_TILT: {'method': 'open_cover_tilt'}, SERVICE_CLOSE_COVER_TILT: {'method': 'close_cover_tilt'}, + SERVICE_STOP_COVER_TILT: {'method': 'stop_cover_tilt'}, SERVICE_SET_COVER_TILT_POSITION: { 'method': 'set_cover_tilt_position', 'schema': COVER_SET_COVER_TILT_POSITION_SCHEMA}, diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index dd6b10e244d..a632fb46ba3 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -96,10 +96,10 @@ class MqttCover(CoverDevice): payload = template.render_with_possible_json_value( hass, value_template, payload) if payload == self._state_open: - self._state = False + self._state = True self.update_ha_state() elif payload == self._state_closed: - self._state = True + self._state = False self.update_ha_state() elif payload.isnumeric() and 0 <= int(payload) <= 100: self._state = int(payload) diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 83d55001fe2..d7ebfb834e8 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -96,6 +96,8 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" + if self.current_cover_position is None: + return None if self.current_cover_position > 0: return False else: diff --git a/homeassistant/const.py b/homeassistant/const.py index 5b23fb5395d..5bb11679076 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -245,7 +245,7 @@ SERVICE_OPEN_COVER = 'open_cover' SERVICE_OPEN_COVER_TILT = 'open_cover_tilt' SERVICE_SET_COVER_POSITION = 'set_cover_position' SERVICE_SET_COVER_TILT_POSITION = 'set_cover_tilt_position' -SERVICE_STOP_COVER = 'stop' +SERVICE_STOP_COVER = 'stop_cover' SERVICE_STOP_COVER_TILT = 'stop_cover_tilt' SERVICE_MOVE_UP = 'move_up' From 177d8ef4ef176f05b92ac3fdc48c464e3c0d0903 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:31:32 +0200 Subject: [PATCH 052/208] Migrate to voluptuous (#3096) --- .../components/binary_sensor/bloomsky.py | 40 ++++++------ homeassistant/components/bloomsky.py | 34 ++++++----- homeassistant/components/sensor/bloomsky.py | 61 ++++++++++--------- 3 files changed, 75 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index f9e192c7984..6e958dcd0ad 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -6,32 +6,38 @@ https://home-assistant.io/components/binary_sensor.bloomsky/ """ import logging -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.loader import get_component +import voluptuous as vol -DEPENDENCIES = ["bloomsky"] +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bloomsky'] # These are the available sensors mapped to binary_sensor class SENSOR_TYPES = { - "Rain": "moisture", - "Night": None, + 'Rain': 'moisture', + 'Night': None, } +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available BloomSky weather binary sensors.""" - logger = logging.getLogger(__name__) bloomsky = get_component('bloomsky') - sensors = config.get('monitored_conditions', SENSOR_TYPES) + sensors = config.get(CONF_MONITORED_CONDITIONS) for device in bloomsky.BLOOMSKY.devices.values(): for variable in sensors: - if variable in SENSOR_TYPES: - add_devices([BloomSkySensor(bloomsky.BLOOMSKY, - device, - variable)]) - else: - logger.error("Cannot find definition for device: %s", variable) + add_devices([BloomSkySensor(bloomsky.BLOOMSKY, device, variable)]) class BloomSkySensor(BinarySensorDevice): @@ -40,10 +46,10 @@ class BloomSkySensor(BinarySensorDevice): def __init__(self, bs, device, sensor_name): """Initialize a BloomSky binary sensor.""" self._bloomsky = bs - self._device_id = device["DeviceID"] + self._device_id = device['DeviceID'] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) - self._unique_id = "bloomsky_binary_sensor {}".format(self._name) + self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._unique_id = 'bloomsky_binary_sensor {}'.format(self._name) self.update() @property @@ -71,4 +77,4 @@ class BloomSkySensor(BinarySensorDevice): self._bloomsky.refresh_devices() self._state = \ - self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py index b881dcb9526..e610082951b 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -8,30 +8,34 @@ import logging from datetime import timedelta import requests +import voluptuous as vol from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config, discovery +from homeassistant.helpers import discovery from homeassistant.util import Throttle - -DOMAIN = "bloomsky" -BLOOMSKY = None +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +BLOOMSKY = None +BLOOMSKY_TYPE = ['camera', 'binary_sensor', 'sensor'] + +DOMAIN = 'bloomsky' + # The BloomSky only updates every 5-8 minutes as per the API spec so there's # no point in polling the API more frequently MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + # pylint: disable=unused-argument,too-few-public-methods def setup(hass, config): """Setup BloomSky component.""" - if not validate_config( - config, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): - return False - api_key = config[DOMAIN][CONF_API_KEY] global BLOOMSKY @@ -40,7 +44,7 @@ def setup(hass, config): except RuntimeError: return False - for component in 'camera', 'binary_sensor', 'sensor': + for component in BLOOMSKY_TYPE: discovery.load_platform(hass, component, DOMAIN, {}, config) return True @@ -50,19 +54,19 @@ class BloomSky(object): """Handle all communication with the BloomSky API.""" # API documentation at http://weatherlution.com/bloomsky-api/ - API_URL = "https://api.bloomsky.com/api/skydata" + API_URL = 'https://api.bloomsky.com/api/skydata' def __init__(self, api_key): """Initialize the BookSky.""" self._api_key = api_key self.devices = {} - _LOGGER.debug("Initial bloomsky device load...") + _LOGGER.debug("Initial BloomSky device load...") self.refresh_devices() @Throttle(MIN_TIME_BETWEEN_UPDATES) def refresh_devices(self): """Use the API to retreive a list of devices.""" - _LOGGER.debug("Fetching bloomsky update") + _LOGGER.debug("Fetching BloomSky update") response = requests.get(self.API_URL, headers={"Authorization": self._api_key}, timeout=10) @@ -73,5 +77,5 @@ class BloomSky(object): return # Create dictionary keyed off of the device unique id self.devices.update({ - device["DeviceID"]: device for device in response.json() + device['DeviceID']: device for device in response.json() }) diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index a9d2c0c6631..b8f5e7e8470 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -6,58 +6,63 @@ https://home-assistant.io/components/sensor.bloomsky/ """ import logging -from homeassistant.const import TEMP_FAHRENHEIT +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["bloomsky"] +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bloomsky'] # These are the available sensors -SENSOR_TYPES = ["Temperature", - "Humidity", - "Pressure", - "Luminance", - "UVIndex", - "Voltage"] +SENSOR_TYPES = ['Temperature', + 'Humidity', + 'Pressure', + 'Luminance', + 'UVIndex', + 'Voltage'] # Sensor units - these do not currently align with the API documentation -SENSOR_UNITS = {"Temperature": TEMP_FAHRENHEIT, - "Humidity": "%", - "Pressure": "inHg", - "Luminance": "cd/m²", - "Voltage": "mV"} +SENSOR_UNITS = {'Temperature': TEMP_FAHRENHEIT, + 'Humidity': '%', + 'Pressure': 'inHg', + 'Luminance': 'cd/m²', + 'Voltage': 'mV'} # Which sensors to format numerically -FORMAT_NUMBERS = ["Temperature", "Pressure", "Voltage"] +FORMAT_NUMBERS = ['Temperature', 'Pressure', 'Voltage'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available BloomSky weather sensors.""" - logger = logging.getLogger(__name__) bloomsky = get_component('bloomsky') - sensors = config.get('monitored_conditions', SENSOR_TYPES) + sensors = config.get(CONF_MONITORED_CONDITIONS) for device in bloomsky.BLOOMSKY.devices.values(): for variable in sensors: - if variable in SENSOR_TYPES: - add_devices([BloomSkySensor(bloomsky.BLOOMSKY, - device, - variable)]) - else: - logger.error("Cannot find definition for device: %s", variable) + add_devices([BloomSkySensor(bloomsky.BLOOMSKY, device, variable)]) class BloomSkySensor(Entity): """Representation of a single sensor in a BloomSky device.""" def __init__(self, bs, device, sensor_name): - """Initialize a bloomsky sensor.""" + """Initialize a BloomSky sensor.""" self._bloomsky = bs - self._device_id = device["DeviceID"] + self._device_id = device['DeviceID'] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) - self._unique_id = "bloomsky_sensor {}".format(self._name) + self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._unique_id = 'bloomsky_sensor {}'.format(self._name) self.update() @property @@ -85,9 +90,9 @@ class BloomSkySensor(Entity): self._bloomsky.refresh_devices() state = \ - self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] if self._sensor_name in FORMAT_NUMBERS: - self._state = "{0:.2f}".format(state) + self._state = '{0:.2f}'.format(state) else: self._state = state From 06df31bb5bae794de77c14c279f25378ca9a2e24 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:31:49 +0200 Subject: [PATCH 053/208] Migrate to voluptuous (#3084) --- homeassistant/components/downloader.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index b752743d2d4..57b6bd4dc6d 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -12,10 +12,11 @@ import threading import requests import voluptuous as vol -from homeassistant.helpers import validate_config import homeassistant.helpers.config_validation as cv from homeassistant.util import sanitize_filename +_LOGGER = logging.getLogger(__name__) + ATTR_SUBDIR = 'subdir' ATTR_URL = 'url' @@ -30,15 +31,16 @@ SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ vol.Optional(ATTR_SUBDIR): cv.string, }) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOWNLOAD_DIR): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + # pylint: disable=too-many-branches def setup(hass, config): """Listen for download events to download files.""" - logger = logging.getLogger(__name__) - - if not validate_config(config, {DOMAIN: [CONF_DOWNLOAD_DIR]}, logger): - return False - download_path = config[DOMAIN][CONF_DOWNLOAD_DIR] # If path is relative, we assume relative to HASS config dir @@ -46,8 +48,7 @@ def setup(hass, config): download_path = hass.config.path(download_path) if not os.path.isdir(download_path): - - logger.error( + _LOGGER.error( "Download path %s does not exist. File Downloader not active.", download_path) @@ -113,16 +114,16 @@ def setup(hass, config): final_path = "{}_{}.{}".format(path, tries, ext) - logger.info("%s -> %s", url, final_path) + _LOGGER.info("%s -> %s", url, final_path) with open(final_path, 'wb') as fil: for chunk in req.iter_content(1024): fil.write(chunk) - logger.info("Downloading of %s done", url) + _LOGGER.info("Downloading of %s done", url) except requests.exceptions.ConnectionError: - logger.exception("ConnectionError occured for %s", url) + _LOGGER.exception("ConnectionError occured for %s", url) # Remove file if we started downloading but failed if final_path and os.path.isfile(final_path): From 0c310c166a69dfd90c858d238185a2017c7f64f8 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 2 Sep 2016 06:32:12 +0200 Subject: [PATCH 054/208] Fixed Homematic cover (#3116) --- homeassistant/components/cover/homematic.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index cab6b51e645..fd68ac3d265 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -11,7 +11,7 @@ properly configured. import logging from homeassistant.const import STATE_UNKNOWN from homeassistant.components.cover import CoverDevice,\ - ATTR_CURRENT_POSITION + ATTR_POSITION import homeassistant.components.homematic as homematic _LOGGER = logging.getLogger(__name__) @@ -41,16 +41,16 @@ class HMCover(homematic.HMDevice, CoverDevice): None is unknown, 0 is closed, 100 is fully open. """ if self.available: - return int((1 - self._hm_get_state()) * 100) + return int(self._hm_get_state() * 100) return None def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if self.available: - if ATTR_CURRENT_POSITION in kwargs: - position = float(kwargs[ATTR_CURRENT_POSITION]) + if ATTR_POSITION in kwargs: + position = float(kwargs[ATTR_POSITION]) position = min(100, max(0, position)) - level = (100 - position) / 100.0 + level = position / 100.0 self._hmdevice.set_level(level, self._channel) @property From 27ee4c555a44ee25014d8bc969e1ae36aaca8754 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:34:07 +0200 Subject: [PATCH 055/208] Migrate to voluptuous (#3069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/sensor/supervisord.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/supervisord.py b/homeassistant/components/sensor/supervisord.py index cebdfb83f14..22c1285a547 100644 --- a/homeassistant/components/sensor/supervisord.py +++ b/homeassistant/components/sensor/supervisord.py @@ -7,28 +7,41 @@ https://home-assistant.io/components/sensor.supervisord/ import logging import xmlrpc.client +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_URL from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_URL = 'http://localhost:9001/RPC2' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Supervisord platform.""" + url = config.get(CONF_URL) try: - supervisor_server = xmlrpc.client.ServerProxy( - config.get('url', 'http://localhost:9001/RPC2')) + supervisor_server = xmlrpc.client.ServerProxy(url) except ConnectionRefusedError: _LOGGER.error('Could not connect to Supervisord') - return + return False + processes = supervisor_server.supervisor.getAllProcessInfo() + add_devices( [SupervisorProcessSensor(info, supervisor_server) for info in processes]) class SupervisorProcessSensor(Entity): - """Represent a supervisor-monitored process.""" + """Representation of a supervisor-monitored process.""" # pylint: disable=abstract-method def __init__(self, info, server): From b4df9b30d8ccd752ce69e9bf018d693073d1bfc6 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 06:34:42 +0200 Subject: [PATCH 056/208] Migrate to voluptuous (#3066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/ifttt.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py index a30ef184d7e..123d1a9d382 100644 --- a/homeassistant/components/ifttt.py +++ b/homeassistant/components/ifttt.py @@ -9,21 +9,22 @@ import logging import requests import voluptuous as vol -from homeassistant.helpers import validate_config import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['pyfttt==0.3'] + _LOGGER = logging.getLogger(__name__) -DOMAIN = "ifttt" - -SERVICE_TRIGGER = 'trigger' - ATTR_EVENT = 'event' ATTR_VALUE1 = 'value1' ATTR_VALUE2 = 'value2' ATTR_VALUE3 = 'value3' -REQUIREMENTS = ['pyfttt==0.3'] +CONF_KEY = 'key' + +DOMAIN = 'ifttt' + +SERVICE_TRIGGER = 'trigger' SERVICE_TRIGGER_SCHEMA = vol.Schema({ vol.Required(ATTR_EVENT): cv.string, @@ -32,6 +33,12 @@ SERVICE_TRIGGER_SCHEMA = vol.Schema({ vol.Optional(ATTR_VALUE3): cv.string, }) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + def trigger(hass, event, value1=None, value2=None, value3=None): """Trigger a Maker IFTTT recipe.""" @@ -46,10 +53,7 @@ def trigger(hass, event, value1=None, value2=None, value3=None): def setup(hass, config): """Setup the IFTTT service component.""" - if not validate_config(config, {DOMAIN: ['key']}, _LOGGER): - return False - - key = config[DOMAIN]['key'] + key = config[DOMAIN][CONF_KEY] def trigger_service(call): """Handle IFTTT trigger service calls.""" From 451f0cb3f1befd2f800de224f84d8d450adbbbe7 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Fri, 2 Sep 2016 00:36:14 -0400 Subject: [PATCH 057/208] snapcast update (#3012) * snapcast update * snapcast update * validate config * use conf constants --- .../components/media_player/snapcast.py | 31 ++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 998490fb9b9..6baba63afe6 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -6,30 +6,35 @@ https://home-assistant.io/components/media_player.snapcast/ """ import logging import socket +import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, - MediaPlayerDevice) + PLATFORM_SCHEMA, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN) + STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, + CONF_HOST, CONF_PORT) SUPPORT_SNAPCAST = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_SELECT_SOURCE DOMAIN = 'snapcast' -REQUIREMENTS = ['snapcast==1.2.1'] +REQUIREMENTS = ['snapcast==1.2.2'] _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Snapcast platform.""" import snapcast.control - host = config.get('host') - port = config.get('port', snapcast.control.CONTROL_PORT) - if not host: - _LOGGER.error('No snapserver host specified') - return + host = config.get(CONF_HOST) + port = config.get(CONF_PORT, snapcast.control.CONTROL_PORT) try: server = snapcast.control.Snapserver(host, port) except socket.gaierror: @@ -75,18 +80,18 @@ class SnapcastDevice(MediaPlayerDevice): return { 'idle': STATE_IDLE, 'playing': STATE_PLAYING, - 'unkown': STATE_UNKNOWN, + 'unknown': STATE_UNKNOWN, }.get(self._client.stream.status, STATE_UNKNOWN) @property def source(self): """Return the current input source.""" - return self._client.stream.identifier + return self._client.stream.name @property def source_list(self): """List of available input sources.""" - return self._client.available_streams() + return list(self._client.streams_by_name().keys()) def mute_volume(self, mute): """Send the mute command.""" @@ -98,4 +103,6 @@ class SnapcastDevice(MediaPlayerDevice): def select_source(self, source): """Set input source.""" - self._client.stream = source + streams = self._client.streams_by_name() + if source in streams: + self._client.stream = streams[source].identifier diff --git a/requirements_all.txt b/requirements_all.txt index 4dd67781bd8..9bde6532ba7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ slacker==0.9.24 sleekxmpp==1.3.1 # homeassistant.components.media_player.snapcast -snapcast==1.2.1 +snapcast==1.2.2 # homeassistant.components.climate.honeywell # homeassistant.components.thermostat.honeywell From 24d3cbdfe9f16fbf903259c2fb1870907cd5f9f5 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Fri, 2 Sep 2016 00:37:09 -0400 Subject: [PATCH 058/208] orvibo updates (#3006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/switch/orvibo.py | 50 ++++++++++++++++------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/switch/orvibo.py b/homeassistant/components/switch/orvibo.py index b2b8ed41abe..274b2cd40ca 100644 --- a/homeassistant/components/switch/orvibo.py +++ b/homeassistant/components/switch/orvibo.py @@ -6,40 +6,62 @@ https://home-assistant.io/components/switch.orvibo/ """ import logging -from homeassistant.components.switch import SwitchDevice +import voluptuous as vol + +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv -DEFAULT_NAME = "Orvibo S20 Switch" REQUIREMENTS = ['orvibo==1.1.1'] _LOGGER = logging.getLogger(__name__) +CONF_SWITCHES = 'switches' +CONF_HOST = 'host' +CONF_NAME = 'name' +CONF_MAC = 'mac' +CONF_DISCOVERY = 'discovery' +DEFAULT_NAME = 'Orvibo S20 Switch' +DEFAULT_DISCOVERY = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, + vol.Required(CONF_SWITCHES, default=[]): + vol.All(cv.ensure_list, [{ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MAC): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string + }]) +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Find and return S20 switches.""" - from orvibo.s20 import S20, S20Exception + from orvibo.s20 import discover, S20, S20Exception + switch_data = {} switches = [] - switch_conf = config.get('switches', [config]) + switch_conf = config.get(CONF_SWITCHES, [config]) + + if config.get(CONF_DISCOVERY): + _LOGGER.info("Discovering S20 switches ...") + switch_data.update(discover()) for switch in switch_conf: - if switch.get('host') is None: - _LOGGER.error("Missing required variable: host") - continue - host = switch.get('host') - mac = switch.get('mac') + switch_data[switch.get(CONF_HOST)] = switch + + for host, data in switch_data.items(): try: - switches.append(S20Switch(switch.get('name', DEFAULT_NAME), - S20(host, mac=mac))) + switches.append(S20Switch(data.get(CONF_NAME, DEFAULT_NAME), + S20(host, mac=data.get(CONF_MAC)))) _LOGGER.info("Initialized S20 at %s", host) except S20Exception: - _LOGGER.exception("S20 at %s couldn't be initialized", - host) + _LOGGER.error("S20 at %s couldn't be initialized", host) add_devices_callback(switches) class S20Switch(SwitchDevice): - """Representsation of an S20 switch.""" + """Representation of an S20 switch.""" def __init__(self, name, s20): """Initialize the S20 device.""" From b8b1fadc6dd37cc60e2b7e38a3b8d890e61f8f0f Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 1 Sep 2016 21:59:32 -0700 Subject: [PATCH 059/208] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../components/frontend/www_static/core.js.gz | Bin 32161 -> 32161 bytes .../frontend/www_static/frontend.html | 4 ++-- .../frontend/www_static/frontend.html.gz | Bin 124422 -> 124571 bytes .../www_static/home-assistant-polymer | 2 +- .../frontend/www_static/mdi.html.gz | Bin 174430 -> 174430 bytes .../panels/ha-panel-dev-event.html.gz | Bin 2639 -> 2639 bytes .../panels/ha-panel-dev-info.html.gz | Bin 1308 -> 1308 bytes .../panels/ha-panel-dev-service.html.gz | Bin 2824 -> 2824 bytes .../panels/ha-panel-dev-state.html.gz | Bin 2772 -> 2772 bytes .../panels/ha-panel-dev-template.html.gz | Bin 7290 -> 7290 bytes .../panels/ha-panel-history.html.gz | Bin 6842 -> 6842 bytes .../www_static/panels/ha-panel-iframe.html.gz | Bin 403 -> 403 bytes .../panels/ha-panel-logbook.html.gz | Bin 7344 -> 7344 bytes .../www_static/panels/ha-panel-map.html.gz | Bin 43920 -> 43920 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2285 -> 2282 bytes .../www_static/webcomponents-lite.min.js.gz | Bin 12355 -> 12355 bytes 18 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 5fce36f45b1..f9862e7148a 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -2,7 +2,7 @@ FINGERPRINTS = { "core.js": "1fd10c1fcdf56a61f60cf861d5a0368c", - "frontend.html": "88c97d278de3320278da6c32fe9e7d61", + "frontend.html": "eb9bda51654858cd32036fb0880e5a17", "mdi.html": "710b84acc99b32514f52291aba9cd8e8", "panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b", "panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169", diff --git a/homeassistant/components/frontend/www_static/core.js.gz b/homeassistant/components/frontend/www_static/core.js.gz index 847c937ec81bc9f7e4f5cb609fabfb184c8b4125..be968b067e39460d3f5a57752cd5f488d9cf0542 100644 GIT binary patch delta 18 acmZ4Zn{nZ9Mt1pb4i0hllN;G*)dB!SHU_c) delta 18 acmZ4Zn{nZ9Mt1pb4vy6!hc>d$ss#W^?FWni diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 128248ce62e..97057d312b0 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,5 +1,5 @@ \ No newline at end of file +var r=t.propertyDataFromStyles(n._styles,this),i=!this.__notStyleScopeCacheable;i&&(r.key.customStyle=this.customStyle,e=n._styleCache.retrieve(this.is,r.key,this._styles));var a=Boolean(e);a?this._styleProperties=e._styleProperties:this._computeStyleProperties(r.properties),this._computeOwnStyleProperties(),a||(e=o.retrieve(this.is,this._ownStyleProperties,this._styles));var l=Boolean(e)&&!a,h=this._applyStyleProperties(e);a||(h=h&&s?h.cloneNode(!0):h,e={style:h,_scopeSelector:this._scopeSelector,_styleProperties:this._styleProperties},i&&(r.key.customStyle={},this.mixin(r.key.customStyle,this.customStyle),n._styleCache.store(this.is,e,r.key,this._styles)),l||o.store(this.is,Object.create(e),this._ownStyleProperties,this._styles))},_computeStyleProperties:function(e){var n=this._findStyleHost();n._styleProperties||n._computeStyleProperties();var r=Object.create(n._styleProperties),s=t.hostAndRootPropertiesForScope(this);this.mixin(r,s.hostProps),e=e||t.propertyDataFromStyles(n._styles,this).properties,this.mixin(r,e),this.mixin(r,s.rootProps),t.mixinCustomStyle(r,this.customStyle),t.reify(r),this._styleProperties=r},_computeOwnStyleProperties:function(){for(var e,t={},n=0;n0&&l.push(t);return[{removed:a,added:l}]}},Polymer.Collection.get=function(e){return Polymer._collections.get(e)||new Polymer.Collection(e)},Polymer.Collection.applySplices=function(e,t){var n=Polymer._collections.get(e);return n?n._applySplices(t):null},Polymer({is:"dom-repeat",extends:"template",_template:null,properties:{items:{type:Array},as:{type:String,value:"item"},indexAs:{type:String,value:"index"},sort:{type:Function,observer:"_sortChanged"},filter:{type:Function,observer:"_filterChanged"},observe:{type:String,observer:"_observeChanged"},delay:Number,renderedItemCount:{type:Number,notify:!0,readOnly:!0},initialCount:{type:Number,observer:"_initializeChunking"},targetFramerate:{type:Number,value:20},_targetFrameTime:{type:Number,computed:"_computeFrameTime(targetFramerate)"}},behaviors:[Polymer.Templatizer],observers:["_itemsChanged(items.*)"],created:function(){this._instances=[],this._pool=[],this._limit=1/0;var e=this;this._boundRenderChunk=function(){e._renderChunk()}},detached:function(){this.__isDetached=!0;for(var e=0;e=0;t--){var n=this._instances[t];n.isPlaceholder&&t=this._limit&&(n=this._downgradeInstance(t,n.__key__)),e[n.__key__]=t,n.isPlaceholder||n.__setProperty(this.indexAs,t,!0)}this._pool.length=0,this._setRenderedItemCount(this._instances.length),this.fire("dom-change"),this._tryRenderChunk()},_applyFullRefresh:function(){var e,t=this.collection;if(this._sortFn)e=t?t.getKeys():[];else{e=[];var n=this.items;if(n)for(var r=0;r=r;a--)this._detachAndRemoveInstance(a)},_numericSort:function(e,t){return e-t},_applySplicesUserSort:function(e){for(var t,n,r=this.collection,s={},i=0;i=0;i--){var h=a[i];void 0!==h&&this._detachAndRemoveInstance(h)}var c=this;if(l.length){this._filterFn&&(l=l.filter(function(e){return c._filterFn(r.getItem(e))})),l.sort(function(e,t){return c._sortFn(r.getItem(e),r.getItem(t))});var u=0;for(i=0;i>1,a=this._instances[o].__key__,l=this._sortFn(n.getItem(a),r);if(l<0)e=o+1;else{if(!(l>0)){i=o;break}s=o-1}}return i<0&&(i=s+1),this._insertPlaceholder(i,t),i},_applySplicesArrayOrder:function(e){for(var t,n=0;n=0?(e=this.as+"."+e.substring(n+1),i._notifyPath(e,t,!0)):i.__setProperty(this.as,t,!0))}},itemForElement:function(e){var t=this.modelForElement(e);return t&&t[this.as]},keyForElement:function(e){var t=this.modelForElement(e);return t&&t.__key__},indexForElement:function(e){var t=this.modelForElement(e);return t&&t[this.indexAs]}}),Polymer({is:"array-selector",_template:null,properties:{items:{type:Array,observer:"clearSelection"},multi:{type:Boolean,value:!1,observer:"clearSelection"},selected:{type:Object,notify:!0},selectedItem:{type:Object,notify:!0},toggle:{type:Boolean,value:!1}},clearSelection:function(){if(Array.isArray(this.selected))for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index bde1fe17a8dddad0788b4040de413876f4a6add9..09792deb421b31164a20d3b7a4b94ba69fef0695 100644 GIT binary patch delta 79802 zcmV(yKReXt)hHk+pS)}EJU8{~B za8}Dg20{iVWr0w6+(;Zoz+S95MpQ^SGG52QJ)s`T#c-e~i;be7;DK(W_XUiN`O|y>Kg!CGR^cJWJv< z^FdHx+N-%g@hVD%l^30F+#`COV{GAz+!~dQy?*t zt8!@4T^~1l*_8LoWdf8=mPTIM7%=b_+KAa|_9->gQ@Du2nwclaz@4GX#Se)IUOXe0 ziM-%NYyySfEax&VfV`j0b8C2)$jMSFFGoUxQG)sUXVYHN%YUd5EknuMFPsw}SoGeHZ(LH0p&sjtqqx`&xKcSXPFUJ{qW5jg8^a%scfxk;C zIg18!uh%K!8;Z7@eC8#Y#H1IY6lm{Ult6JtUsowfs1 zNO?h1Ed6Q}W}=n@uaYZ%KJkvpS^M!HY} z*0b32_;aX}&0rcy%LRazBs^KE(wHj=yGj{C29ma{;T+Ng)@N#vOJ{0P}%DNf)Hep67dh zt72tSKWrEwe@F9g9XaA|GhyedqU}6kEY2fM^c}F3JR|3H{G7s};y#xc$I-t)kIw&G zfuzQ>rDRPbEDRxPdp= zcGc@una=vK856BTmB_1pOwSAB2r-6WkWO8$bJw6g#S3wm7b|ff zkvI_p4`QM77q9}G2Hwt%p?K-Bm-tQmowfn6q=Bxun|vuySsCmO#z@9<@+`kN!}AgF z5=mHq6qFM%+8mtWL*c^v|Nh@|BCNN!@be}8^mP`4D9Uw0*o);~16NQhb)B!ur2i44 zn12Dq+$4)K>0n`#3Df&-maq(;R2^G~Zvog{Z zM^r>b5A;Ehny+Tsvwt>G(6kyjEvCMhVnV(ws+SmOZNNMSds%&$ zzlvLw&LyexI>2hqXeSbd2jEe*1oG}0xBviBp}WYTC`9|u0j%H*<#UKYv`%|7yj62J-?#`ro(--;91Qi{z&NxZbUWD&Y?g z5AnZo8(xGzJU++Ʋ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�vL|r<7AYK;l}O(RYw`!s>9T= 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 d(0Dm;-lv|GQe}KJuYLK(K@K9xvQWu7_z!t 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 delta 16 XcmX>va$ba8zMF$%b;zNO>>gYIFarfs diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index f2414ac39071ea1d135aff6c01e67cda414cc83a..4ede258f804c3c0a8c39a79dbc1bd1cbe3fd2636 100644 GIT binary patch delta 16 XcmbQkHHV8`zMF$Xoc-iRc2QOUBF_WI delta 16 XcmbQkHHV8`zMF$%b;zNO?4qmyD24=~ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz index 95cdd62d016a4626ae1fcbfd302c49869f387696..6e8bcb42b1a611e33ccdab7160a2b5732b7231f7 100644 GIT binary patch delta 16 XcmeAW>kwm?@8;kTXFs`-osk;=A*BPU delta 16 XcmeAW>kwm?@8;lG9dc+RJ0mv$CtL)B diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index 679cc353108e3b243417b5ab49e273dece2f4a24..2194d2ec7d3b7a858cc4bcf7b3d1633bad21e56f 100644 GIT binary patch delta 16 Xcmca2dPS67zMF$Xoc-iR_Oo07EC>Y( delta 16 Xcmca2dPS67zMF$%b;zNO>}R^+PCC!PfK diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index 124f8f5ace25a780a04951c8ebd77373627f2493..dab7a8d6902b9a7f8c12147d0d7f99ee04e417ac 100644 GIT binary patch delta 16 XcmdmBxxtcMzMF$Xoc-iR_LVXKD~bg8 delta 16 XcmdmBxxtcMzMF$%b;zNO>?>seF+l~= diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 97afe1f0e84702a80767e0e51d6a721f3b758e46..af4e0a2bdf5a948def7339606d2e9213a11487c5 100644 GIT binary patch delta 18 acmbPmooT{#CU*I54i0hllN;GPR|5b&(gtS$ delta 18 acmbPmooT{#CU*I54vy6!hc>czt_A=@i3dRd diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index d7c78d557d4..62ffe2e933c 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","cf23e37da78b0dfa560d4a1895b39f76"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-1fd10c1fcdf56a61f60cf861d5a0368c.js","800ebb1bbb48274790f2ee1a2e53a24c"],["/static/frontend-88c97d278de3320278da6c32fe9e7d61.html","be147f0848a4730291bac9cdb76e2d65"],["/static/mdi-710b84acc99b32514f52291aba9cd8e8.html","149c8eaf6bb78a9b642c7bcedab86900"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e["delete"](a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))})["catch"](function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;a@S*%RC~IN$1?hiMr{`QsX7BplrtqE=OQ}J_@DGGRh$%ERjSD z9&?_gDhwkYa=}E0g4M$qD1wS8Nz+tjDTq8_L}gUREEdsmJw>JKQ5XSJnsOa7NQl;2 zat$dElJT6;B;Tk>ArEsU(}XEWBmsQOm56|+36BKHkE>~+xhy1%gotIFrCjkOkAP?u zF$tW5Qi+Cy^R1?g$tX*Rj3L8~rV$IN;JMN&W|?MBX)@fvC`w2cvLr`2ags!ljF_OI z1)Z=A$W{+a(l`@oM7g8_frDIXDOhB;n5F3xz!_L9ORI`-lIJRlvpfkCP$3C39zw1O zBTCxj+KW-3xD zRT~Wn2`Rc!M2dwf79pdIWKon0rdS^8tk+-xD&$z!4UDM9h>=uFrQ?JrR3qX#OQ=E( zVUo$IcubQx%Hm9_kfBH|NR|KGxj+G3*hiSHO5UucaT)3qgEzL+==7Poumx3(9 z%C#%4uyuQ^`K5gs(VYE`zrnJap`3{3ISJ88jG;k11%?F-;|Fp=DNW;}asvE=>j|cN zjKPzlR_Jp4_zYSkf<|2|5E_1gX?YFJg_y0a;bFuws<7~8VT`pi z;}{8ON*)qODCfJ=|Z&Qq{h$t*jm~t9{$yFw( zbAIl39Po}WVCuA2qYWvHF4<`0oVYENI%r^4Ac1rY^WtD6Xl0E=>^PoZ%qq-L>_k5B z1|=G#u(up}5UFQ+HvVm878m1lpDXnX60)Vw>(`Zn->3IIhUH~TPJC)<@XN*=5xR5 zHOfPcgK9XGJCd+tvZP*lx3{hgT4Y)|KbdY{UpPa@!f*nWtQCCu`1;kxF|P4WoT>BJ z7w&n!+%vamKj2=z&e4@6q73Q*K81E|qXO%;#OsZHMkf2f8E=cpWZT-c^#UGi>G*nY zU3cKyZejVlbEew!<{$GEO6w7sZeG^DqF&Hq|6*K3$~)eFuIEf0+l1<(M9q&)Gy%38 z&Gr2!v-$K4D;Kobpq`O)uWMzG>iSj})kanSj;1|`pE+;m9R_`xTi}tZ;XXh!xa8#u@EG&JgS^WEbTh(WGid8h+@ot@{0RS9 z*$CkN4ML}I*99-zQ>R7yN7ydUipoJ?x}SrS&dz=iw)T6m{ECS5!C)8oN8q&Qm`ili zKAyd&H)p1!8=U+fxF0#}@ki0-PeEzVe)n*mIsKu=Imc;#1S?qP|@e zP@y5t-+%q=bk(itT^RJIq&1G1EJurzd%p&E@e~<(6El72h(40K8d^;;Gmw}1wruhY z4|kR2nCSs?xt2csW5RlAa*E8=36aC0Q(@aMvoW&2M`VTJ@SyO!XU=b~`{J3~bNwwI zCU0$3C~w!NlZ&gm`3?=T{q2HRg|@Qnu@Ljc`|Dq@P1u8^Bg$=+kq@fOZq+!tBj4Cl za04x}m-jPdU)8HpRsVi&1{Mw& z^k%g%&^4Zbe`=k9tto@fs?)(w@1O6(fef}jrf-M4h&>*%%T+snB)KR3nIF-`ioU*0 zXl-uzyE*+x<20|Y-tAmnxJX-tDJ``g;&*s|(%pYJ7>-a}f^yc^B=%-xeUoAz_Il+0 zM#Ot6A$M<4Fp=#Q#o<m5#3E zs>R|ks5UjS3c4}Zo8XF#88#WdcB(hi4!^j6&K-!~zB`_D1kUdKaOX|_2e7+O-#HWj E0Kx@&w*UYD literal 2285 zcmV4B=~S^4oo}I-CLxvLW8v5-3mXP&*v}1 z2b5+&Z}{W|71j&09FMnCWmA(UHqtche!+NrB+l1pCKb zrxluIqb{A7FJ8ZK{3U|Iw!5_G{(X3NKRM5v+7OnLH?&#j(t-85+HQ3Ep=)Z#|9(S4 z+Va2s*roFWLW}LlxiI&hFB)}s`H$BxehqBo)C;m=aM}&DqjihYMhnTnzqA`o2kD3BiH995{eVqunLGS5&HDGyc7bixx6pVw2?x|u{Va-|vQh+`UR zttDv8Fbs1jI7^G2nhcAmP%=xolA#Qd-U=mRge-+vgvEI^9kw^6#EB4zO!5pAq(zLO zRxy_dfJ&trQ-HmuoXa>*Lz!Ss8_i-KF#(0rD&e{2U(#g2;3Q7NJmP6Va*{NSV;OV7 zq9P`%d5+;;4^Ois7g@|eGC{y$A+;1dwp+}z>lwtmzFE6BD;X0en!(S;`T(lcA4vN8dksW1jj50%7OArlBgmI|4x zSgB0yG^Am~$c-XaJW>>4obfP^<3e!7i%92#1`ALrr?P2r!ZbyUWLhenq>wUAi0eFM ziZn!NE`tvp$<4zMg`Gxhyipvs_^m@zB0NDkHA3 zz)YoVXSF~UXPOMnAITHj8Cy&Knll2R# zaSr%rjkX%!4rAf$;ihxjr>sJkXg^NuE?N%DdNM{txi4dR@R-_sPO9(E7 zc^ol$D6O=0d#d5azD!ubf1}@ET`x#Z%!(q6$Vr@&L69M*0!HzJMaUS-lCyFG`a|nO zO7}$Asu@RUw}pa9fn3P6p!_+4s9eDUJ0gNsy zUP4O}4N=%HKs-p)GryYtdt+Abr`J9x^;aTfOP_Z?)e3)`KlB)umn}KL+|uCNS3eV9 z&4#vv=!w`Cf;E_BP11vom=N6w^TuQMVfbQhJRbtnyzVK__3nMyB9*Xd2eF&r>9AKxDpq+4_okK}Y?IaS18!eE+$fGk0tgs>_NrKQ++= z)N-_U51-8L(=)1E*inOe6<&LND|=Sgho-D|s)lzm?NR(Jp%MgQs_Arn4KI{U5lWIqbocXv3GFv}eBuIIo=HP~%+FwBLi3tV&Bm^n`Nt6}0*j z(lJzIi1W7}-#C4BYX=tw!zpQtBPGk(;^aQ8p0&!gzdA_}we#->&=JGk4(n z&vcmlY^y@`d3!o}f7`Slu_d$QoeY9{u8wcN09VHxvw(vQI$EY8fSOp z8+!_Fu_N|U?@F`$Wh<>;Fi-yXw347NR{?}z({Js+~`O}Bg^xo5+fpU|a> zzP(N8Y;O36IsHWAv}|sFJ-E7XiMC2pS!zAS@AUqpzyEM39I?DX?9IsbCdEGN z^~l4Gi1($0JiJArM7CEHr&~RL%j5KGtRZMOesm`ztENNuWOzXBi9ze0Eu2q&8^Z9Q zUT*77A6ws3k=ufEOL^SfI#1nY^}UN<&nQ+XDZ&~2W!F&6;NrrgQdAQ!1M#v|NZCp% z9o@)HN5x@O?`mWn^kc3!qZK^24fHu(kZ From 0907eea44206543b64b3f6982d1643c430fb876c Mon Sep 17 00:00:00 2001 From: turbokongen Date: Fri, 2 Sep 2016 07:17:41 +0200 Subject: [PATCH 060/208] move units to temperature for climate zwave. wrong state was sent to mqtt cove --- homeassistant/components/climate/zwave.py | 9 ++++++++- homeassistant/components/cover/mqtt.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 11704b06fdf..530e3ea028f 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ( ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) from homeassistant.components import zwave +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT _LOGGER = logging.getLogger(__name__) @@ -128,6 +129,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): class_id=COMMAND_CLASS_SENSOR_MULTILEVEL).values(): if value.label == 'Temperature': self._current_temperature = int(value.data) + self._unit = value.units # Fan Mode for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): @@ -186,7 +188,12 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return self._unit + if self._unit == 'C': + return TEMP_CELSIUS + elif self._unit == 'F': + return TEMP_FAHRENHEIT + else: + return self._unit @property def current_temperature(self): diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index a632fb46ba3..b47bcf124e1 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -96,13 +96,17 @@ class MqttCover(CoverDevice): payload = template.render_with_possible_json_value( hass, value_template, payload) if payload == self._state_open: - self._state = True + self._state = False + _LOGGER.warning("state=%s", int(self._state)) self.update_ha_state() elif payload == self._state_closed: - self._state = False + self._state = True self.update_ha_state() elif payload.isnumeric() and 0 <= int(payload) <= 100: - self._state = int(payload) + if int(payload) > 0: + self._state = False + else: + self._state = True self._position = int(payload) self.update_ha_state() else: @@ -129,11 +133,7 @@ class MqttCover(CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + return self._state @property def current_cover_position(self): From 000832a82cfe01742829ca4cea62f055ff29aafb Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 2 Sep 2016 11:14:18 +0200 Subject: [PATCH 061/208] Use voluptuous for instapush (#3132) --- homeassistant/components/notify/instapush.py | 35 ++++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 7dbbbfced35..3a8f2d9ee0a 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -8,11 +8,25 @@ import json 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, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config + + +CONF_APP_SECRET = 'app_secret' +CONF_EVENT = 'event' +CONF_TRACKER = 'tracker' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_APP_SECRET): cv.string, + vol.Required(CONF_EVENT): cv.string, + vol.Required(CONF_TRACKER): cv.string, +}) + _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://api.instapush.im/v1/' @@ -20,16 +34,8 @@ _RESOURCE = 'https://api.instapush.im/v1/' def get_service(hass, config): """Get the instapush notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY, - 'app_secret', - 'event', - 'tracker']}, - _LOGGER): - return None - headers = {'x-instapush-appid': config[CONF_API_KEY], - 'x-instapush-appsecret': config['app_secret']} + 'x-instapush-appsecret': config[CONF_APP_SECRET]} try: response = requests.get(_RESOURCE + 'events/list', @@ -42,15 +48,16 @@ def get_service(hass, config): _LOGGER.error(response['msg']) return None - if len([app for app in response if app['title'] == config['event']]) == 0: + if len([app for app in response + if app['title'] == config[CONF_EVENT]]) == 0: _LOGGER.error( "No app match your given value. " "Please create an app at https://instapush.im") return None return InstapushNotificationService( - config[CONF_API_KEY], config['app_secret'], config['event'], - config['tracker']) + config[CONF_API_KEY], config[CONF_APP_SECRET], config[CONF_EVENT], + config[CONF_TRACKER]) # pylint: disable=too-few-public-methods From 6a84b826633beeb807c5a8b8d300095c5241f0d5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 12:26:23 +0200 Subject: [PATCH 062/208] Use voluptuous for Octoprint (#3111) * Migrate to voluptuous * Fix pylint issues --- .../components/binary_sensor/octoprint.py | 59 +++++++++++------- homeassistant/components/octoprint.py | 36 ++++++----- homeassistant/components/sensor/octoprint.py | 62 ++++++++++++------- 3 files changed, 93 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 25c922ca20c..6763eaafa55 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -5,45 +5,56 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.octoprint/ """ import logging + import requests +import voluptuous as vol -from homeassistant.const import CONF_NAME, STATE_ON, STATE_OFF -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF, CONF_MONITORED_CONDITIONS) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["octoprint"] + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['octoprint'] + +DEFAULT_NAME = 'OctoPrint' SENSOR_TYPES = { # API Endpoint, Group, Key, unit - "Printing": ["printer", "state", "printing", None], - "Printing Error": ["printer", "state", "error", None] + 'Printing': ['printer', 'state', 'printing', None], + 'Printing Error': ['printer', 'state', 'error', None] } -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available OctoPrint binary sensors.""" octoprint = get_component('octoprint') - name = config.get(CONF_NAME, "OctoPrint") - monitored_conditions = config.get("monitored_conditions", + name = config.get(CONF_NAME) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES.keys()) devices = [] for octo_type in monitored_conditions: - if octo_type in SENSOR_TYPES: - new_sensor = OctoPrintBinarySensor(octoprint.OCTOPRINT, - octo_type, - SENSOR_TYPES[octo_type][2], - name, - SENSOR_TYPES[octo_type][3], - SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1], - "flags") - devices.append(new_sensor) - else: - _LOGGER.error("Unknown OctoPrint sensor type: %s", octo_type) + new_sensor = OctoPrintBinarySensor(octoprint.OCTOPRINT, + octo_type, + SENSOR_TYPES[octo_type][2], + name, + SENSOR_TYPES[octo_type][3], + SENSOR_TYPES[octo_type][0], + SENSOR_TYPES[octo_type][1], + 'flags') + devices.append(new_sensor) add_devices(devices) @@ -52,14 +63,14 @@ class OctoPrintBinarySensor(BinarySensorDevice): """Representation an OctoPrint binary sensor.""" # pylint: disable=too-many-arguments - def __init__(self, api, condition, sensor_type, sensor_name, - unit, endpoint, group, tool=None): + def __init__(self, api, condition, sensor_type, sensor_name, unit, + endpoint, group, tool=None): """Initialize a new OctoPrint sensor.""" self.sensor_name = sensor_name if tool is None: - self._name = sensor_name + ' ' + condition + self._name = '{} {}'.format(sensor_name, condition) else: - self._name = sensor_name + ' ' + condition + self._name = '{} {}'.format(sensor_name, condition) self.sensor_type = sensor_type self.api = api self._state = False diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index bd90e67d0df..871f81759e0 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -5,37 +5,41 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/octoprint/ """ import logging - import time + import requests +import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_HOST -from homeassistant.helpers import validate_config, discovery - -DOMAIN = "octoprint" -OCTOPRINT = None +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DISCOVER_SENSORS = 'octoprint.sensors' DISCOVER_BINARY_SENSORS = 'octoprint.binary_sensor' +DISCOVER_SENSORS = 'octoprint.sensors' +DOMAIN = 'octoprint' + +OCTOPRINT = None + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Set up OctoPrint API.""" - if not validate_config(config, {DOMAIN: [CONF_API_KEY], - DOMAIN: [CONF_HOST]}, - _LOGGER): - return False - - base_url = config[DOMAIN][CONF_HOST] + "/api/" + base_url = 'http://{}/api/'.format(config[DOMAIN][CONF_HOST]) api_key = config[DOMAIN][CONF_API_KEY] global OCTOPRINT try: OCTOPRINT = OctoPrintAPI(base_url, api_key) - OCTOPRINT.get("printer") - OCTOPRINT.get("job") + OCTOPRINT.get('printer') + OCTOPRINT.get('job') except requests.exceptions.RequestException as conn_err: _LOGGER.error("Error setting up OctoPrint API: %r", conn_err) return False @@ -55,7 +59,7 @@ class OctoPrintAPI(object): def __init__(self, api_url, key): """Initialize OctoPrint API and set headers needed later.""" self.api_url = api_url - self.headers = {'content-type': 'application/json', + self.headers = {'content-type': CONTENT_TYPE_JSON, 'X-Api-Key': key} self.printer_last_reading = [{}, None] self.job_last_reading = [{}, None] diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index f7e7fa30817..3b4635c829a 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -5,31 +5,44 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.octoprint/ """ import logging -import requests -from homeassistant.const import TEMP_CELSIUS, CONF_NAME +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["octoprint"] + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['octoprint'] + +DEFAULT_NAME = 'OctoPrint' SENSOR_TYPES = { # API Endpoint, Group, Key, unit - "Temperatures": ["printer", "temperature", "*", TEMP_CELSIUS], - "Current State": ["printer", "state", "text", None], - "Job Percentage": ["job", "progress", "completion", "%"], + 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], + 'Current State': ['printer', 'state', 'text', None], + 'Job Percentage': ['job', 'progress', 'completion', '%'], } -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available OctoPrint sensors.""" octoprint = get_component('octoprint') - name = config.get(CONF_NAME, "OctoPrint") - monitored_conditions = config.get("monitored_conditions", - SENSOR_TYPES.keys()) + name = config.get(CONF_NAME) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) devices = [] types = ["actual", "target"] @@ -46,19 +59,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): SENSOR_TYPES[octo_type][1], tool) devices.append(new_sensor) - elif octo_type in SENSOR_TYPES: - new_sensor = OctoPrintSensor(octoprint.OCTOPRINT, - octo_type, - SENSOR_TYPES[octo_type][2], - name, - SENSOR_TYPES[octo_type][3], - SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1]) - devices.append(new_sensor) else: _LOGGER.error("Unknown OctoPrint sensor type: %s", octo_type) - add_devices(devices) + new_sensor = OctoPrintSensor(octoprint.OCTOPRINT, + octo_type, + SENSOR_TYPES[octo_type][2], + name, + SENSOR_TYPES[octo_type][3], + SENSOR_TYPES[octo_type][0], + SENSOR_TYPES[octo_type][1]) + devices.append(new_sensor) + + add_devices(devices) # pylint: disable=too-many-instance-attributes @@ -66,14 +79,15 @@ class OctoPrintSensor(Entity): """Representation of an OctoPrint sensor.""" # pylint: disable=too-many-arguments - def __init__(self, api, condition, sensor_type, sensor_name, - unit, endpoint, group, tool=None): + def __init__(self, api, condition, sensor_type, sensor_name, unit, + endpoint, group, tool=None): """Initialize a new OctoPrint sensor.""" self.sensor_name = sensor_name if tool is None: - self._name = sensor_name + ' ' + condition + self._name = '{} {}'.format(sensor_name, condition) else: - self._name = sensor_name + ' ' + condition + ' ' + tool + ' temp' + self._name = '{} {} {} {}'.format( + sensor_name, condition, tool, ' temp') self.sensor_type = sensor_type self.api = api self._state = None From 95cc6721614487209314080fc783a0afdf12e1df Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 14:25:13 +0200 Subject: [PATCH 063/208] Add missing docstrings (fix PEP257 issues) (#3098) * Add missing docstrings (fix PEP257 issues) * Finish sentence --- tests/components/test_emulated_hue.py | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/components/test_emulated_hue.py b/tests/components/test_emulated_hue.py index c9efa6e9fda..9433aacc20b 100755 --- a/tests/components/test_emulated_hue.py +++ b/tests/components/test_emulated_hue.py @@ -1,3 +1,4 @@ +"""The tests for the emulated Hue component.""" import time import json import threading @@ -11,8 +12,7 @@ import homeassistant.components as core_components from homeassistant.components import emulated_hue, http, light, mqtt from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.components.emulated_hue import ( - HUE_API_STATE_ON, HUE_API_STATE_BRI -) + HUE_API_STATE_ON, HUE_API_STATE_BRI) from tests.common import get_test_instance_port, get_test_home_assistant @@ -27,6 +27,7 @@ mqtt_broker = None def setUpModule(): + """Setup things to be run when tests are started.""" global mqtt_broker mqtt_broker = MQTTBroker('127.0.0.1', MQTT_BROKER_PORT) @@ -34,12 +35,14 @@ def setUpModule(): def tearDownModule(): + """Stop everything that was started.""" global mqtt_broker mqtt_broker.stop() def setup_hass_instance(emulated_hue_config): + """Setup the Home Assistant instance to test.""" hass = get_test_home_assistant() # We need to do this to get access to homeassistant/turn_(on,off) @@ -55,15 +58,19 @@ def setup_hass_instance(emulated_hue_config): def start_hass_instance(hass): + """Start the Home Assistant instance to test.""" hass.start() time.sleep(0.05) class TestEmulatedHue(unittest.TestCase): + """Test the emulated Hue component.""" + hass = None @classmethod def setUpClass(cls): + """Setup the class.""" cls.hass = setup_hass_instance({ emulated_hue.DOMAIN: { emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT @@ -73,9 +80,11 @@ class TestEmulatedHue(unittest.TestCase): @classmethod def tearDownClass(cls): + """Stop the class.""" cls.hass.stop() def test_description_xml(self): + """Test the description.""" import xml.etree.ElementTree as ET result = requests.get( @@ -91,6 +100,7 @@ class TestEmulatedHue(unittest.TestCase): self.fail('description.xml is not valid XML!') def test_create_username(self): + """Test the creation of an username.""" request_json = {'devicetype': 'my_device'} result = requests.post( @@ -107,6 +117,7 @@ class TestEmulatedHue(unittest.TestCase): self.assertTrue('username' in success_json['success']) def test_valid_username_request(self): + """Test request with a valid username.""" request_json = {'invalid_key': 'my_device'} result = requests.post( @@ -117,8 +128,11 @@ class TestEmulatedHue(unittest.TestCase): class TestEmulatedHueExposedByDefault(unittest.TestCase): + """Test class for emulated hue component.""" + @classmethod def setUpClass(cls): + """Setup the class.""" cls.hass = setup_hass_instance({ emulated_hue.DOMAIN: { emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, @@ -177,9 +191,11 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): @classmethod def tearDownClass(cls): + """Stop the class.""" cls.hass.stop() def test_discover_lights(self): + """Test the discovery of lights.""" result = requests.get( BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5) @@ -194,6 +210,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertTrue('light.kitchen_light' not in result_json) def test_get_light_state(self): + """Test the getting of light state.""" # Turn office light on and set to 127 brightness self.hass.services.call( light.DOMAIN, const.SERVICE_TURN_ON, @@ -229,6 +246,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertEqual(kitchen_result.status_code, 404) def test_put_light_state(self): + """Test the seeting of light states.""" self.perform_put_test_on_office_light() # Turn the bedroom light on first @@ -264,6 +282,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertEqual(kitchen_result.status_code, 404) def test_put_with_form_urlencoded_content_type(self): + """Test the form with urlencoded content.""" # Needed for Alexa self.perform_put_test_on_office_light( 'application/x-www-form-urlencoded') @@ -278,6 +297,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertEqual(result.status_code, 400) def test_entity_not_found(self): + """Test for entity which are not found.""" result = requests.get( BRIDGE_URL_BASE.format( '/api/username/lights/{}'.format("not.existant_entity")), @@ -293,6 +313,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertEqual(result.status_code, 404) def test_allowed_methods(self): + """Test the allowed methods.""" result = requests.get( BRIDGE_URL_BASE.format( '/api/username/lights/{}/state'.format("light.office_light"))) @@ -313,6 +334,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertEqual(result.status_code, 405) def test_proper_put_state_request(self): + """Test the request to set the state.""" # Test proper on value parsing result = requests.put( BRIDGE_URL_BASE.format( @@ -334,6 +356,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): def perform_put_test_on_office_light(self, content_type='application/json'): + """Test the setting of a light.""" # Turn the office light off first self.hass.services.call( light.DOMAIN, const.SERVICE_TURN_OFF, @@ -361,6 +384,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): self.assertEqual(office_light.attributes[light.ATTR_BRIGHTNESS], 56) def perform_get_light_state(self, entity_id, expected_status): + """Test the gettting of a light state.""" result = requests.get( BRIDGE_URL_BASE.format( '/api/username/lights/{}'.format(entity_id)), timeout=5) @@ -377,6 +401,7 @@ class TestEmulatedHueExposedByDefault(unittest.TestCase): def perform_put_light_state(self, entity_id, is_on, brightness=None, content_type='application/json'): + """Test the setting of a light state.""" url = BRIDGE_URL_BASE.format( '/api/username/lights/{}/state'.format(entity_id)) @@ -432,6 +457,7 @@ class MQTTBroker(object): self._thread.join() def _run_loop(self): + """Run the loop.""" asyncio.set_event_loop(self._loop) self._loop.run_until_complete(self._broker_coroutine()) @@ -442,4 +468,5 @@ class MQTTBroker(object): @asyncio.coroutine def _broker_coroutine(self): + """The Broker coroutine.""" yield from self._broker.start() From dedc4a129c6486a230c31327e3b6b1e67fe37ace Mon Sep 17 00:00:00 2001 From: Tomi Tuhkanen Date: Fri, 2 Sep 2016 16:07:40 +0300 Subject: [PATCH 064/208] Updated braviatv's braviarc version to 0.3.4 (#2997) * Updated braviarc version to 0.3.4 * Updated braviarc version to requirements_all.txt --- homeassistant/components/media_player/braviatv.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 3e9e8fdbd44..3cd470dba8d 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -17,8 +17,8 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) REQUIREMENTS = [ - 'https://github.com/aparraga/braviarc/archive/0.3.3.zip' - '#braviarc==0.3.3'] + 'https://github.com/aparraga/braviarc/archive/0.3.4.zip' + '#braviarc==0.3.4'] BRAVIA_CONFIG_FILE = 'bravia.conf' CLIENTID_PREFIX = 'HomeAssistant' diff --git a/requirements_all.txt b/requirements_all.txt index 9bde6532ba7..d4cabc5affb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -137,7 +137,7 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 # homeassistant.components.media_player.braviatv -https://github.com/aparraga/braviarc/archive/0.3.3.zip#braviarc==0.3.3 +https://github.com/aparraga/braviarc/archive/0.3.4.zip#braviarc==0.3.4 # homeassistant.components.media_player.roku https://github.com/bah2830/python-roku/archive/3.1.2.zip#roku==3.1.2 From e5ef548f1072d72a6be6df53322b3f4b8692dbc8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 15:42:38 +0200 Subject: [PATCH 065/208] Use voluptuous for Acer projector switch (#3077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/switch/acer_projector.py | 88 +++++++++++-------- requirements_all.txt | 2 +- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index b0a6a93cb4d..5845e611c31 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -1,21 +1,40 @@ """ -Use serial protocol of acer projector to obtain state of the projector. +Use serial protocol of Acer projector to obtain state of the projector. -This component allows to control almost all projectors from acer using -their RS232 serial communication protocol. +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/switch.acer_projector/ """ import logging import re -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import (STATE_ON, STATE_OFF, STATE_UNKNOWN, - CONF_NAME, CONF_FILENAME) +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyserial==3.1.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_TIMEOUT = 'timeout' +CONF_WRITE_TIMEOUT = 'write_timeout' + +DEFAULT_NAME = 'Acer Projector' +DEFAULT_TIMEOUT = 1 +DEFAULT_WRITE_TIMEOUT = 1 -LAMP_HOURS = 'Lamp Hours' -INPUT_SOURCE = 'Input Source' ECO_MODE = 'ECO Mode' -MODEL = 'Model' + +ICON = 'mdi:projector' + +INPUT_SOURCE = 'Input Source' + LAMP = 'Lamp' +LAMP_HOURS = 'Lamp Hours' + +MODEL = 'Model' # Commands known to the projector CMD_DICT = {LAMP: '* 0 Lamp ?\r', @@ -26,38 +45,34 @@ CMD_DICT = {LAMP: '* 0 Lamp ?\r', STATE_ON: '* 0 IR 001\r', STATE_OFF: '* 0 IR 002\r'} -_LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyserial<=3.1'] - -ICON = 'mdi:projector' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILENAME): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT): + cv.positive_int, +}) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Connect with serial port and return Acer Projector.""" - serial_port = config.get(CONF_FILENAME, None) - name = config.get(CONF_NAME, 'Projector') - timeout = config.get('timeout', 1) - write_timeout = config.get('write_timeout', 1) + serial_port = config.get(CONF_FILENAME) + name = config.get(CONF_NAME) + timeout = config.get(CONF_TIMEOUT) + write_timeout = config.get(CONF_WRITE_TIMEOUT) - if not serial_port: - _LOGGER.error('Missing path of serial device') - return - - devices = [] - devices.append(AcerSwitch(serial_port, name, timeout, write_timeout)) - add_devices_callback(devices) + add_devices([AcerSwitch(serial_port, name, timeout, write_timeout)]) class AcerSwitch(SwitchDevice): """Represents an Acer Projector as an switch.""" - def __init__(self, serial_port, name='Projector', - timeout=1, write_timeout=1, **kwargs): + def __init__(self, serial_port, name, timeout, write_timeout, **kwargs): """Init of the Acer projector.""" import serial - self.ser = serial.Serial(port=serial_port, timeout=timeout, - write_timeout=write_timeout, **kwargs) + self.ser = serial.Serial( + port=serial_port, timeout=timeout, write_timeout=write_timeout, + **kwargs) self._serial_port = serial_port self._name = name self._state = False @@ -73,18 +88,17 @@ class AcerSwitch(SwitchDevice): """Write to the projector and read the return.""" import serial ret = "" - # Sometimes the projector won't answer for no reason, - # or the projector was disconnected during runtime. - # Thisway the projector can be reconnected and will still - # work + # Sometimes the projector won't answer for no reason or the projector + # was disconnected during runtime. + # This way the projector can be reconnected and will still work try: if not self.ser.is_open: self.ser.open() msg = msg.encode('utf-8') self.ser.write(msg) - # size is an experience value there is no real limit. - # AFAIK there is no limit and no end character so - # we will usually need to wait for timeout + # Size is an experience value there is no real limit. + # AFAIK there is no limit and no end character so we will usually + # need to wait for timeout ret = self.ser.read_until(size=20).decode('utf-8') except serial.SerialException: _LOGGER.error('Problem comunicating with %s', self._serial_port) diff --git a/requirements_all.txt b/requirements_all.txt index d4cabc5affb..fdc234780f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -346,7 +346,7 @@ pynx584==0.2 pyowm==2.4.0 # homeassistant.components.switch.acer_projector -pyserial<=3.1 +pyserial==3.1.1 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp From 28e939afcf7443923c75facab425fd74c618a4d6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 2 Sep 2016 15:59:08 +0200 Subject: [PATCH 066/208] Use voluptuous for twilio (#3134) --- homeassistant/components/notify/twilio_sms.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py index f7700240b67..ddcf8849b78 100644 --- a/homeassistant/components/notify/twilio_sms.py +++ b/homeassistant/components/notify/twilio_sms.py @@ -6,27 +6,29 @@ https://home-assistant.io/components/notify.twilio_sms/ """ import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TARGET, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["twilio==5.4.0"] + CONF_ACCOUNT_SID = "account_sid" CONF_AUTH_TOKEN = "auth_token" CONF_FROM_NUMBER = "from_number" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCOUNT_SID): cv.string, + vol.Required(CONF_AUTH_TOKEN): cv.string, + vol.Required(CONF_FROM_NUMBER): vol.Match(r"^\+?[1-9]\d{1,14}$"), +}) + def get_service(hass, config): """Get the Twilio SMS notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_ACCOUNT_SID, - CONF_AUTH_TOKEN, - CONF_FROM_NUMBER]}, - _LOGGER): - return None - # pylint: disable=import-error from twilio.rest import TwilioRestClient From 81628b01c2b47912f466fa0e2cb92a499f29dce4 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 2 Sep 2016 15:59:38 +0200 Subject: [PATCH 067/208] Use voluptuous for webostv (#3135) --- homeassistant/components/notify/webostv.py | 27 ++++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py index 34463dc6e45..e8276255925 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -6,33 +6,30 @@ https://home-assistant.io/components/notify.webostv/ """ import logging -from homeassistant.components.notify import (BaseNotificationService, DOMAIN) -from homeassistant.const import (CONF_HOST, CONF_NAME) -from homeassistant.helpers import validate_config +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import (BaseNotificationService, + PLATFORM_SCHEMA) +from homeassistant.const import CONF_HOST + +_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/TheRealLink/pylgtv' '/archive/v0.1.2.zip' '#pylgtv==0.1.2'] -_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) def get_service(hass, config): """Return the notify service.""" - if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_HOST, CONF_NAME]}, - _LOGGER): - return None - - host = config.get(CONF_HOST, None) - - if not host: - _LOGGER.error('No host provided.') - return None - from pylgtv import WebOsClient from pylgtv import PyLGTVPairException - client = WebOsClient(host) + client = WebOsClient(config.get(CONF_HOST)) try: client.register() From 40c71b5d963d752402ccdc3de12faa58c974743e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 16:09:09 +0200 Subject: [PATCH 068/208] Use voluptuous for Command line platforms (#2968) * Migrate to voluptuous * Fix pylint issues * Remove FIXME * Split setup test * Test with bootstrap * Remove lon and lat * Fix pylint issues --- .../components/binary_sensor/command_line.py | 52 +++++++++--------- .../components/cover/command_line.py | 53 ++++++++++++++----- .../components/notify/command_line.py | 20 ++++--- .../components/sensor/command_line.py | 36 +++++++------ .../components/switch/command_line.py | 49 ++++++++++++----- homeassistant/const.py | 14 +++++ .../binary_sensor/test_command_line.py | 23 ++++---- tests/components/cover/test_command_line.py | 16 +++--- tests/components/notify/test_command_line.py | 22 ++++---- tests/components/sensor/test_command_line.py | 26 ++++----- tests/components/switch/test_command_line.py | 22 ++++---- 11 files changed, 199 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/binary_sensor/command_line.py b/homeassistant/components/binary_sensor/command_line.py index e589506eac7..f56f9cb7a39 100644 --- a/homeassistant/components/binary_sensor/command_line.py +++ b/homeassistant/components/binary_sensor/command_line.py @@ -7,46 +7,50 @@ https://home-assistant.io/components/binary_sensor.command_line/ import logging from datetime import timedelta -from homeassistant.components.binary_sensor import (BinarySensorDevice, - SENSOR_CLASSES) +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, SENSOR_CLASSES_SCHEMA, PLATFORM_SCHEMA) from homeassistant.components.sensor.command_line import CommandSensorData -from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.const import ( + CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_NAME, CONF_VALUE_TEMPLATE, + CONF_SENSOR_CLASS, CONF_COMMAND) from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Binary Command Sensor" -DEFAULT_SENSOR_CLASS = None +DEFAULT_NAME = 'Binary Command Sensor' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' -# Return cached results if last scan was less then this time ago MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Command Sensor.""" - if config.get('command') is None: - _LOGGER.error('Missing required variable: "command"') - return False + """Setup the Command line Binary Sensor.""" + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + payload_off = config.get(CONF_PAYLOAD_OFF) + payload_on = config.get(CONF_PAYLOAD_ON) + sensor_class = config.get(CONF_SENSOR_CLASS) + value_template = config.get(CONF_VALUE_TEMPLATE) - sensor_class = config.get('sensor_class') - if sensor_class not in SENSOR_CLASSES: - _LOGGER.warning('Unknown sensor class: %s', sensor_class) - sensor_class = DEFAULT_SENSOR_CLASS - - data = CommandSensorData(config.get('command')) + data = CommandSensorData(command) add_devices([CommandBinarySensor( - hass, - data, - config.get('name', DEFAULT_NAME), - sensor_class, - config.get('payload_on', DEFAULT_PAYLOAD_ON), - config.get('payload_off', DEFAULT_PAYLOAD_OFF), - config.get(CONF_VALUE_TEMPLATE) - )]) + hass, data, name, sensor_class, payload_on, payload_off, + value_template)]) # pylint: disable=too-many-arguments, too-many-instance-attributes diff --git a/homeassistant/components/cover/command_line.py b/homeassistant/components/cover/command_line.py index c2c8050f09f..0a1da9d7a20 100644 --- a/homeassistant/components/cover/command_line.py +++ b/homeassistant/components/cover/command_line.py @@ -7,29 +7,54 @@ https://home-assistant.io/components/cover.command_line/ import logging import subprocess -from homeassistant.components.cover import CoverDevice -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, + CONF_COMMAND_STOP, CONF_COVERS, CONF_VALUE_TEMPLATE, CONF_FRIENDLY_NAME) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template _LOGGER = logging.getLogger(__name__) +COVER_SCHEMA = vol.Schema({ + vol.Optional(CONF_COMMAND_CLOSE, default='true'): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default='true'): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default='true'): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE, default='{{ value }}'): cv.template, +}) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup cover controlled by shell commands.""" - covers = config.get('covers', {}) - devices = [] + devices = config.get(CONF_COVERS, {}) + covers = [] - for dev_name, properties in covers.items(): - devices.append( + for device_name, device_config in devices.items(): + covers.append( CommandCover( hass, - properties.get('name', dev_name), - properties.get('opencmd', 'true'), - properties.get('closecmd', 'true'), - properties.get('stopcmd', 'true'), - properties.get('statecmd', False), - properties.get(CONF_VALUE_TEMPLATE, '{{ value }}'))) - add_devices_callback(devices) + device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_COMMAND_OPEN), + device_config.get(CONF_COMMAND_CLOSE), + device_config.get(CONF_COMMAND_STOP), + device_config.get(CONF_COMMAND_STATE), + device_config.get(CONF_VALUE_TEMPLATE), + ) + ) + + if not covers: + _LOGGER.error("No covers added") + return False + + add_devices(covers) # pylint: disable=too-many-arguments, too-many-instance-attributes diff --git a/homeassistant/components/notify/command_line.py b/homeassistant/components/notify/command_line.py index df77560c22b..9b637d71188 100644 --- a/homeassistant/components/notify/command_line.py +++ b/homeassistant/components/notify/command_line.py @@ -6,21 +6,25 @@ https://home-assistant.io/components/notify.command_line/ """ import logging import subprocess -from homeassistant.helpers import validate_config + +import voluptuous as vol + +from homeassistant.const import (CONF_COMMAND, CONF_NAME) from homeassistant.components.notify import ( - DOMAIN, BaseNotificationService) + BaseNotificationService, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + def get_service(hass, config): """Get the Command Line notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['command']}, - _LOGGER): - return None - - command = config['command'] + command = config[CONF_COMMAND] return CommandLineNotificationService(command) diff --git a/homeassistant/components/sensor/command_line.py b/homeassistant/components/sensor/command_line.py index eb1fb4603e2..f26d2680a26 100644 --- a/homeassistant/components/sensor/command_line.py +++ b/homeassistant/components/sensor/command_line.py @@ -8,35 +8,41 @@ import logging import subprocess from datetime import timedelta -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, CONF_COMMAND) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Command Sensor" +DEFAULT_NAME = 'Command Sensor' -# Return cached results if last scan was less then this time ago MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Command Sensor.""" - if config.get('command') is None: - _LOGGER.error('Missing required variable: "command"') - return False + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + value_template = config.get(CONF_VALUE_TEMPLATE) - data = CommandSensorData(config.get('command')) + data = CommandSensorData(command) - add_devices_callback([CommandSensor( - hass, - data, - config.get('name', DEFAULT_NAME), - config.get('unit_of_measurement'), - config.get(CONF_VALUE_TEMPLATE) - )]) + add_devices([CommandSensor(hass, data, name, unit, value_template)]) # pylint: disable=too-many-arguments diff --git a/homeassistant/components/switch/command_line.py b/homeassistant/components/switch/command_line.py index 40b83371f9a..e20a47cf084 100644 --- a/homeassistant/components/switch/command_line.py +++ b/homeassistant/components/switch/command_line.py @@ -7,30 +7,53 @@ https://home-assistant.io/components/switch.command_line/ import logging import subprocess -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, CONF_COMMAND_OFF, + CONF_COMMAND_ON, CONF_COMMAND_STATE) from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +SWITCH_SCHEMA = vol.Schema({ + vol.Optional(CONF_COMMAND_OFF, default='true'): cv.string, + vol.Optional(CONF_COMMAND_ON, default='true'): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by shell commands.""" - switches = config.get('switches', {}) - devices = [] + devices = config.get(CONF_SWITCHES, {}) + switches = [] - for dev_name, properties in switches.items(): - devices.append( + for device_name, device_config in devices.items(): + switches.append( CommandSwitch( hass, - properties.get('name', dev_name), - properties.get('oncmd', 'true'), - properties.get('offcmd', 'true'), - properties.get('statecmd', False), - properties.get(CONF_VALUE_TEMPLATE, False))) + device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_COMMAND_ON), + device_config.get(CONF_COMMAND_OFF), + device_config.get(CONF_COMMAND_STATE), + device_config.get(CONF_VALUE_TEMPLATE) + ) + ) - add_devices_callback(devices) + if not switches: + _LOGGER.error("No switches added") + return False + + add_devices(switches) # pylint: disable=too-many-instance-attributes diff --git a/homeassistant/const.py b/homeassistant/const.py index 5bb11679076..f3c016015e6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -28,7 +28,15 @@ CONF_BEFORE = 'before' CONF_BELOW = 'below' CONF_BLACKLIST = 'blacklist' CONF_CODE = 'code' +CONF_COMMAND = 'command' +CONF_COMMAND_CLOSE = 'command_close' +CONF_COMMAND_OFF = 'command_off' +CONF_COMMAND_ON = 'command_on' +CONF_COMMAND_OPEN = 'command_open' +CONF_COMMAND_STATE = 'command_state' +CONF_COMMAND_STOP = 'command_stop' CONF_CONDITION = 'condition' +CONF_COVERS = 'covers' CONF_CUSTOMIZE = 'customize' CONF_DEVICE = 'device' CONF_DEVICES = 'devices' @@ -40,6 +48,7 @@ CONF_ENTITY_NAMESPACE = 'entity_namespace' CONF_EVENT = 'event' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FRIENDLY_NAME = 'friendly_name' CONF_HOST = 'host' CONF_HOSTS = 'hosts' CONF_ICON = 'icon' @@ -54,7 +63,10 @@ CONF_OFFSET = 'offset' CONF_OPTIMISTIC = 'optimistic' CONF_PASSWORD = 'password' CONF_PAYLOAD = 'payload' +CONF_PAYLOAD_OFF = 'payload_off' +CONF_PAYLOAD_ON = 'payload_on' CONF_PENDING_TIME = 'pending_time' +CONF_PIN = 'pin' CONF_PLATFORM = 'platform' CONF_PORT = 'port' CONF_PREFIX = 'prefix' @@ -62,9 +74,11 @@ CONF_RESOURCE = 'resource' CONF_RESOURCES = 'resources' CONF_SCAN_INTERVAL = 'scan_interval' CONF_SENSOR_CLASS = 'sensor_class' +CONF_SENSORS = 'sensors' CONF_SSL = 'ssl' CONF_STATE = 'state' CONF_STRUCTURE = 'structure' +CONF_SWITCHES = 'switches' CONF_TEMPERATURE_UNIT = 'temperature_unit' CONF_TIME_ZONE = 'time_zone' CONF_TOKEN = 'token' diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py index 758911db353..62b856bbc23 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -3,6 +3,7 @@ import unittest from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.components.binary_sensor import command_line +from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -24,6 +25,7 @@ class TestCommandSensorBinarySensor(unittest.TestCase): 'command': 'echo 1', 'payload_on': '1', 'payload_off': '0'} + devices = [] def add_dev_callback(devs): @@ -31,8 +33,7 @@ class TestCommandSensorBinarySensor(unittest.TestCase): for dev in devs: devices.append(dev) - command_line.setup_platform( - self.hass, config, add_dev_callback) + command_line.setup_platform(self.hass, config, add_dev_callback) self.assertEqual(1, len(devices)) entity = devices[0] @@ -41,19 +42,13 @@ class TestCommandSensorBinarySensor(unittest.TestCase): def test_setup_bad_config(self): """Test the setup with a bad configuration.""" - config = {} + config = {'name': 'test', + 'platform': 'not_command_line', + } - devices = [] - - def add_dev_callback(devs): - """Add callback to add devices.""" - for dev in devs: - devices.append(dev) - - self.assertFalse(command_line.setup_platform( - self.hass, config, add_dev_callback)) - - self.assertEqual(0, len(devices)) + self.assertFalse(bootstrap.setup_component(self.hass, 'test', { + 'command_line': config, + })) def test_template(self): """Test setting the state with a template.""" diff --git a/tests/components/cover/test_command_line.py b/tests/components/cover/test_command_line.py index bab0137f4f8..e4ef6793127 100644 --- a/tests/components/cover/test_command_line.py +++ b/tests/components/cover/test_command_line.py @@ -17,12 +17,10 @@ class TestCommandCover(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = ha.HomeAssistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.rs = cmd_rs.CommandCover(self.hass, 'foo', - 'cmd_open', 'cmd_close', - 'cmd_stop', 'cmd_state', - None) # FIXME + 'command_open', 'command_close', + 'command_stop', 'command_state', + None) def teardown_method(self, method): """Stop down everything that was started.""" @@ -47,10 +45,10 @@ class TestCommandCover(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'cover_status') test_cover = { - 'statecmd': 'cat {}'.format(path), - 'opencmd': 'echo 1 > {}'.format(path), - 'closecmd': 'echo 1 > {}'.format(path), - 'stopcmd': 'echo 0 > {}'.format(path), + 'command_state': 'cat {}'.format(path), + 'command_open': 'echo 1 > {}'.format(path), + 'command_close': 'echo 1 > {}'.format(path), + 'command_stop': 'echo 0 > {}'.format(path), 'value_template': '{{ value }}' } self.assertTrue(cover.setup(self.hass, { diff --git a/tests/components/notify/test_command_line.py b/tests/components/notify/test_command_line.py index ffe156deb9d..d350b0e4b37 100644 --- a/tests/components/notify/test_command_line.py +++ b/tests/components/notify/test_command_line.py @@ -2,13 +2,12 @@ import os import tempfile import unittest +from unittest.mock import patch import homeassistant.components.notify as notify - +from homeassistant import bootstrap from tests.common import get_test_home_assistant -from unittest.mock import patch - class TestCommandLine(unittest.TestCase): """Test the command line notifications.""" @@ -21,20 +20,23 @@ class TestCommandLine(unittest.TestCase): """Stop down everything that was started.""" self.hass.stop() + def test_setup(self): + """Test setup.""" + assert bootstrap.setup_component(self.hass, 'notify', { + 'notify': { + 'name': 'test', + 'platform': 'command_line', + 'command': 'echo $(cat); exit 1', + }}) + def test_bad_config(self): - """Test set up the platform with bad/missing config.""" + """Test set up the platform with bad/missing configuration.""" self.assertFalse(notify.setup(self.hass, { 'notify': { 'name': 'test', 'platform': 'bad_platform', } })) - self.assertFalse(notify.setup(self.hass, { - 'notify': { - 'name': 'test', - 'platform': 'command_line', - } - })) def test_command_line_output(self): """Test the command line output.""" diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py index bd083f7b63e..b089a82356b 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -2,7 +2,7 @@ import unittest from homeassistant.components.sensor import command_line - +from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -21,7 +21,8 @@ class TestCommandSensorSensor(unittest.TestCase): """Test sensor setup.""" config = {'name': 'Test', 'unit_of_measurement': 'in', - 'command': 'echo 5'} + 'command': 'echo 5' + } devices = [] def add_dev_callback(devs): @@ -29,8 +30,7 @@ class TestCommandSensorSensor(unittest.TestCase): for dev in devs: devices.append(dev) - command_line.setup_platform( - self.hass, config, add_dev_callback) + command_line.setup_platform(self.hass, config, add_dev_callback) self.assertEqual(1, len(devices)) entity = devices[0] @@ -40,19 +40,13 @@ class TestCommandSensorSensor(unittest.TestCase): def test_setup_bad_config(self): """Test setup with a bad configuration.""" - config = {} + config = {'name': 'test', + 'platform': 'not_command_line', + } - devices = [] - - def add_dev_callback(devs): - """Add a callback to add devices.""" - for dev in devs: - devices.append(dev) - - self.assertFalse(command_line.setup_platform( - self.hass, config, add_dev_callback)) - - self.assertEqual(0, len(devices)) + self.assertFalse(bootstrap.setup_component(self.hass, 'test', { + 'command_line': config, + })) def test_template(self): """Test command sensor with template.""" diff --git a/tests/components/switch/test_command_line.py b/tests/components/switch/test_command_line.py index f71fb9c25aa..5e17710f8fd 100644 --- a/tests/components/switch/test_command_line.py +++ b/tests/components/switch/test_command_line.py @@ -27,8 +27,8 @@ class TestCommandSwitch(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'switch_status') test_switch = { - 'oncmd': 'echo 1 > {}'.format(path), - 'offcmd': 'echo 0 > {}'.format(path), + 'command_on': 'echo 1 > {}'.format(path), + 'command_off': 'echo 0 > {}'.format(path), } self.assertTrue(switch.setup(self.hass, { 'switch': { @@ -59,9 +59,9 @@ class TestCommandSwitch(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'switch_status') test_switch = { - 'statecmd': 'cat {}'.format(path), - 'oncmd': 'echo 1 > {}'.format(path), - 'offcmd': 'echo 0 > {}'.format(path), + 'command_state': 'cat {}'.format(path), + 'command_on': 'echo 1 > {}'.format(path), + 'command_off': 'echo 0 > {}'.format(path), 'value_template': '{{ value=="1" }}' } self.assertTrue(switch.setup(self.hass, { @@ -95,9 +95,9 @@ class TestCommandSwitch(unittest.TestCase): oncmd = json.dumps({'status': 'ok'}) offcmd = json.dumps({'status': 'nope'}) test_switch = { - 'statecmd': 'cat {}'.format(path), - 'oncmd': 'echo \'{}\' > {}'.format(oncmd, path), - 'offcmd': 'echo \'{}\' > {}'.format(offcmd, path), + 'command_state': 'cat {}'.format(path), + 'command_on': 'echo \'{}\' > {}'.format(oncmd, path), + 'command_off': 'echo \'{}\' > {}'.format(offcmd, path), 'value_template': '{{ value_json.status=="ok" }}' } self.assertTrue(switch.setup(self.hass, { @@ -129,9 +129,9 @@ class TestCommandSwitch(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'switch_status') test_switch = { - 'statecmd': 'cat {}'.format(path), - 'oncmd': 'echo 1 > {}'.format(path), - 'offcmd': 'echo 0 > {}'.format(path), + 'command_state': 'cat {}'.format(path), + 'command_on': 'echo 1 > {}'.format(path), + 'command_off': 'echo 0 > {}'.format(path), } self.assertTrue(switch.setup(self.hass, { 'switch': { From a0a509ceea7168e7d9529ae018b9fd5ef9a173f1 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 16:59:05 +0200 Subject: [PATCH 069/208] Add coinmarketcap sensor (#3064) --- .coveragerc | 1 + .../components/sensor/coinmarketcap.py | 125 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 129 insertions(+) create mode 100644 homeassistant/components/sensor/coinmarketcap.py diff --git a/.coveragerc b/.coveragerc index 48ea0375587..0c8647a9ed7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -205,6 +205,7 @@ omit = homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py + homeassistant/components/sensor/coinmarketcap.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/deutsche_bahn.py homeassistant/components/sensor/dht.py diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py new file mode 100644 index 00000000000..83adcac7fea --- /dev/null +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -0,0 +1,125 @@ +""" +Details about crypto currencies from CoinMarketCap. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.coinmarketcap/ +""" +import logging +from datetime import timedelta +import json +from urllib.error import HTTPError + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['coinmarketcap==2.0.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_24H_VOLUME_USD = '24h_volume_usd' +ATTR_AVAILABLE_SUPPLY = 'available_supply' +ATTR_MARKET_CAP = 'market_cap_usd' +ATTR_NAME = 'name' +ATTR_PERCENT_CHANGE_24H = 'percent_change_24h' +ATTR_PERCENT_CHANGE_7D = 'percent_change_7d' +ATTR_PRICE = 'price_usd' +ATTR_SYMBOL = 'symbol' +ATTR_TOTAL_SUPPLY = 'total_supply' + +CONF_CURRENCY = 'currency' + +DEFAULT_CURRENCY = 'bitcoin' + +ICON = 'mdi:currency-usd' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the CoinMarketCap sensor.""" + currency = config.get(CONF_CURRENCY) + + try: + CoinMarketCapData(currency).update() + except HTTPError: + _LOGGER.warning('Currency "%s" is not available. Using "bitcoin"', + currency) + currency = DEFAULT_CURRENCY + + add_devices([CoinMarketCapSensor(CoinMarketCapData(currency))]) + + +# pylint: disable=too-few-public-methods +class CoinMarketCapSensor(Entity): + """Representation of a CoinMarketCap sensor.""" + + def __init__(self, data): + """Initialize the sensor.""" + self.data = data + self._ticker = None + self._unit_of_measurement = 'USD' + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._ticker.get('name') + + @property + def state(self): + """Return the state of the sensor.""" + return self._ticker.get('price_usd') + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_24H_VOLUME_USD: self._ticker.get('24h_volume_usd'), + ATTR_AVAILABLE_SUPPLY: self._ticker.get('available_supply'), + ATTR_MARKET_CAP: self._ticker.get('market_cap_usd'), + ATTR_PERCENT_CHANGE_24H: self._ticker.get('percent_change_24h'), + ATTR_PERCENT_CHANGE_7D: self._ticker.get('percent_change_7d'), + ATTR_SYMBOL: self._ticker.get('symbol'), + ATTR_TOTAL_SUPPLY: self._ticker.get('total_supply'), + } + + # pylint: disable=too-many-branches + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + self._ticker = json.loads( + self.data.ticker.decode('utf-8').strip('\n '))[0] + + +class CoinMarketCapData(object): + """Get the latest data and update the states.""" + + def __init__(self, currency): + """Initialize the data object.""" + self.currency = currency + self.ticker = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from blockchain.info.""" + from coinmarketcap import Market + + self.ticker = Market().ticker(self.currency) diff --git a/requirements_all.txt b/requirements_all.txt index fdc234780f4..a66314ad515 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -52,6 +52,9 @@ boto3==1.3.1 # homeassistant.components.http cherrypy==7.1.0 +# homeassistant.components.sensor.coinmarketcap +coinmarketcap==2.0.1 + # homeassistant.scripts.check_config colorlog>2.1,<3 From 3bbcf4d8b1d4c10c99033c09c0fcd75c2dfabeae Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 19:16:42 +0200 Subject: [PATCH 070/208] Migrate to voluptuous (#3142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/weblink.py | 37 ++++++++++++++++++----------- tests/components/test_weblink.py | 14 ++++++++--- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/weblink.py b/homeassistant/components/weblink.py index 08ba7eb036e..df9dcef9ac1 100644 --- a/homeassistant/components/weblink.py +++ b/homeassistant/components/weblink.py @@ -6,30 +6,39 @@ https://home-assistant.io/components/weblink/ """ import logging +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_ICON, CONF_URL) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify - -DOMAIN = "weblink" -DEPENDENCIES = [] - -ATTR_NAME = 'name' -ATTR_URL = 'url' -ATTR_ICON = 'icon' +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_ENTITIES = 'entities' + +DOMAIN = 'weblink' + +ENTITIES_SCHEMA = vol.Schema({ + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ENTITIES): [ENTITIES_SCHEMA], + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Setup weblink component.""" links = config.get(DOMAIN) - for link in links.get('entities'): - if ATTR_NAME not in link or ATTR_URL not in link: - _LOGGER.error("You need to set both %s and %s to add a %s", - ATTR_NAME, ATTR_URL, DOMAIN) - continue - Link(hass, link.get(ATTR_NAME), link.get(ATTR_URL), - link.get(ATTR_ICON)) + for link in links.get(CONF_ENTITIES): + Link(hass, link.get(CONF_NAME), link.get(CONF_URL), + link.get(CONF_URL)) return True diff --git a/tests/components/test_weblink.py b/tests/components/test_weblink.py index 70a32e850ed..bb539d902ff 100644 --- a/tests/components/test_weblink.py +++ b/tests/components/test_weblink.py @@ -2,6 +2,7 @@ import unittest from homeassistant.components import weblink +from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -17,16 +18,23 @@ class TestComponentWeblink(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() + def test_bad_config(self): + """Test if new entity is created.""" + self.assertFalse(bootstrap.setup_component(self.hass, 'weblink', { + 'weblink': { + 'entities': [{}], + } + })) + def test_entities_get_created(self): """Test if new entity is created.""" self.assertTrue(weblink.setup(self.hass, { weblink.DOMAIN: { 'entities': [ { - weblink.ATTR_NAME: 'My router', - weblink.ATTR_URL: 'http://127.0.0.1/' + weblink.CONF_NAME: 'My router', + weblink.CONF_URL: 'http://127.0.0.1/' }, - {} ] } })) From 6fdd7f5350dd466f2b7a0b1ec50da799165a0dcf Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Fri, 2 Sep 2016 13:18:32 -0600 Subject: [PATCH 071/208] Back out insteon hub and fan changes (#3062) --- homeassistant/components/fan/insteon_hub.py | 66 --------------- homeassistant/components/insteon_hub.py | 80 +++---------------- homeassistant/components/light/insteon_hub.py | 74 +++++++++-------- requirements_all.txt | 2 +- tests/components/fan/test_insteon_hub.py | 73 ----------------- 5 files changed, 49 insertions(+), 246 deletions(-) delete mode 100644 homeassistant/components/fan/insteon_hub.py delete mode 100644 tests/components/fan/test_insteon_hub.py diff --git a/homeassistant/components/fan/insteon_hub.py b/homeassistant/components/fan/insteon_hub.py deleted file mode 100644 index 4d65ee1f02b..00000000000 --- a/homeassistant/components/fan/insteon_hub.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Support for Insteon FanLinc. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/fan.insteon/ -""" - -import logging - -from homeassistant.components.fan import (FanEntity, SUPPORT_SET_SPEED, - SPEED_OFF, SPEED_LOW, SPEED_MED, - SPEED_HIGH) -from homeassistant.components.insteon_hub import (InsteonDevice, INSTEON, - filter_devices) -from homeassistant.const import STATE_UNKNOWN - -_LOGGER = logging.getLogger(__name__) - -DEVICE_CATEGORIES = [ - { - 'DevCat': 1, - 'SubCat': [46] - } -] - -DEPENDENCIES = ['insteon_hub'] - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Insteon Hub fan platform.""" - devs = [] - for device in filter_devices(INSTEON.devices, DEVICE_CATEGORIES): - devs.append(InsteonFanDevice(device)) - add_devices(devs) - - -class InsteonFanDevice(InsteonDevice, FanEntity): - """Represet an insteon fan device.""" - - def __init__(self, node: object) -> None: - """Initialize the device.""" - super(InsteonFanDevice, self).__init__(node) - self.speed = STATE_UNKNOWN # Insteon hub can't get state via REST - - def turn_on(self, speed: str=None): - """Turn the fan on.""" - self.set_speed(speed if speed else SPEED_MED) - - def turn_off(self): - """Turn the fan off.""" - self.set_speed(SPEED_OFF) - - def set_speed(self, speed: str) -> None: - """Set the fan speed.""" - if self._send_command('fan', payload={'speed', speed}): - self.speed = speed - - @property - def supported_features(self) -> int: - """Get the supported features for device.""" - return SUPPORT_SET_SPEED - - @property - def speed_list(self) -> list: - """Get the available speeds for the fan.""" - return [SPEED_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] diff --git a/homeassistant/components/insteon_hub.py b/homeassistant/components/insteon_hub.py index 3ad107886b8..306acab5361 100644 --- a/homeassistant/components/insteon_hub.py +++ b/homeassistant/components/insteon_hub.py @@ -8,93 +8,37 @@ import logging from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import validate_config, discovery -from homeassistant.helpers.entity import Entity - -DOMAIN = 'insteon_hub' # type: str -REQUIREMENTS = ['insteon_hub==0.5.0'] # type: list -INSTEON = None # type: Insteon -DEVCAT = 'DevCat' # type: str -SUBCAT = 'SubCat' # type: str -DEVICE_CLASSES = ['light', 'fan'] # type: list +DOMAIN = "insteon_hub" +REQUIREMENTS = ['insteon_hub==0.4.5'] +INSTEON = None _LOGGER = logging.getLogger(__name__) -def _is_successful(response: dict) -> bool: - """Check http response for successful status.""" - return 'status' in response and response['status'] == 'succeeded' +def setup(hass, config): + """Setup Insteon Hub component. - -def filter_devices(devices: list, categories: list) -> list: - """Filter insteon device list by category/subcategory.""" - categories = (categories - if isinstance(categories, list) - else [categories]) - matching_devices = [] - for device in devices: - if any( - device.DevCat == c[DEVCAT] and - (SUBCAT not in c or device.SubCat in c[SUBCAT]) - for c in categories): - matching_devices.append(device) - return matching_devices - - -def setup(hass, config: dict) -> bool: - """Setup Insteon Hub component.""" + This will automatically import associated lights. + """ if not validate_config( config, {DOMAIN: [CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY]}, _LOGGER): return False - from insteon import Insteon + import insteon username = config[DOMAIN][CONF_USERNAME] password = config[DOMAIN][CONF_PASSWORD] api_key = config[DOMAIN][CONF_API_KEY] global INSTEON - INSTEON = Insteon(username, password, api_key) + INSTEON = insteon.Insteon(username, password, api_key) if INSTEON is None: - _LOGGER.error('Could not connect to Insteon service.') + _LOGGER.error("Could not connect to Insteon service.") return - for device_class in DEVICE_CLASSES: - discovery.load_platform(hass, device_class, DOMAIN, {}, config) + discovery.load_platform(hass, 'light', DOMAIN, {}, config) + return True - - -class InsteonDevice(Entity): - """Represents an insteon device.""" - - def __init__(self: Entity, node: object) -> None: - """Initialize the insteon device.""" - self._node = node - - def update(self: Entity) -> None: - """Update state of the device.""" - pass - - @property - def name(self: Entity) -> str: - """Name of the insteon device.""" - return self._node.DeviceName - - @property - def unique_id(self: Entity) -> str: - """Unique identifier for the device.""" - return self._node.DeviceID - - @property - def supported_features(self: Entity) -> int: - """Supported feature flags.""" - return 0 - - def _send_command(self: Entity, command: str, level: int=None, - payload: dict=None) -> bool: - """Send command to insteon device.""" - resp = self._node.send_command(command, payload=payload, level=level, - wait=True) - return _is_successful(resp) diff --git a/homeassistant/components/light/insteon_hub.py b/homeassistant/components/light/insteon_hub.py index 29254735ced..70beadb6c1d 100644 --- a/homeassistant/components/light/insteon_hub.py +++ b/homeassistant/components/light/insteon_hub.py @@ -4,76 +4,74 @@ Support for Insteon Hub lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/insteon_hub/ """ -from homeassistant.components.insteon_hub import (INSTEON, InsteonDevice) +from homeassistant.components.insteon_hub import INSTEON from homeassistant.components.light import (ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) SUPPORT_INSTEON_HUB = SUPPORT_BRIGHTNESS -DEPENDENCIES = ['insteon_hub'] - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Insteon Hub light platform.""" devs = [] for device in INSTEON.devices: if device.DeviceCategory == "Switched Lighting Control": - devs.append(InsteonLightDevice(device)) + devs.append(InsteonToggleDevice(device)) if device.DeviceCategory == "Dimmable Lighting Control": - devs.append(InsteonDimmableDevice(device)) + devs.append(InsteonToggleDevice(device)) add_devices(devs) -class InsteonLightDevice(InsteonDevice, Light): - """A representation of a light device.""" +class InsteonToggleDevice(Light): + """An abstract Class for an Insteon node.""" - def __init__(self, node: object) -> None: + def __init__(self, node): """Initialize the device.""" - super(InsteonLightDevice, self).__init__(node) + self.node = node self._value = 0 - def update(self) -> None: - """Update state of the device.""" - resp = self._node.send_command('get_status', wait=True) + @property + def name(self): + """Return the the name of the node.""" + return self.node.DeviceName + + @property + def unique_id(self): + """Return the ID of this insteon node.""" + return self.node.DeviceID + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._value / 100 * 255 + + def update(self): + """Update state of the sensor.""" + resp = self.node.send_command('get_status', wait=True) try: self._value = resp['response']['level'] except KeyError: pass @property - def is_on(self) -> None: + def is_on(self): """Return the boolean response if the node is on.""" return self._value != 0 - def turn_on(self, **kwargs) -> None: - """Turn device on.""" - if self._send_command('on'): - self._value = 100 - - def turn_off(self, **kwargs) -> None: - """Turn device off.""" - if self._send_command('off'): - self._value = 0 - - -class InsteonDimmableDevice(InsteonLightDevice): - """A representation for a dimmable device.""" - @property - def brightness(self) -> int: - """Return the brightness of this light between 0..255.""" - return round(self._value / 100 * 255, 0) # type: int - - @property - def supported_features(self) -> int: + def supported_features(self): """Flag supported features.""" return SUPPORT_INSTEON_HUB - def turn_on(self, **kwargs) -> None: + def turn_on(self, **kwargs): """Turn device on.""" - level = 100 # type: int if ATTR_BRIGHTNESS in kwargs: - level = round(kwargs[ATTR_BRIGHTNESS] / 255 * 100, 0) # type: int + self._value = kwargs[ATTR_BRIGHTNESS] / 255 * 100 + self.node.send_command('on', self._value) + else: + self._value = 100 + self.node.send_command('on') - if self._send_command('on', level=level): - self._value = level + def turn_off(self, **kwargs): + """Turn device off.""" + self.node.send_command('off') diff --git a/requirements_all.txt b/requirements_all.txt index a66314ad515..4852dd7d7d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -207,7 +207,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 influxdb==3.0.0 # homeassistant.components.insteon_hub -insteon_hub==0.5.0 +insteon_hub==0.4.5 # homeassistant.components.media_player.kodi jsonrpc-requests==0.3 diff --git a/tests/components/fan/test_insteon_hub.py b/tests/components/fan/test_insteon_hub.py deleted file mode 100644 index dfdb4b7a9f0..00000000000 --- a/tests/components/fan/test_insteon_hub.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for the insteon hub fan platform.""" -import unittest - -from homeassistant.const import (STATE_OFF, STATE_ON) -from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_HIGH, - ATTR_SPEED) -from homeassistant.components.fan.insteon_hub import (InsteonFanDevice, - SUPPORT_SET_SPEED) - - -class Node(object): - """Fake insteon node.""" - - def __init__(self, name, id, dev_cat, sub_cat): - """Initialize fake insteon node.""" - self.DeviceName = name - self.DeviceID = id - self.DevCat = dev_cat - self.SubCat = sub_cat - self.response = None - - def send_command(self, command, payload, level, wait): - """Send fake command.""" - return self.response - - -class TestInsteonHubFanDevice(unittest.TestCase): - """Test around insteon hub fan device methods.""" - - _NODE = Node('device', '12345', '1', '46') - - def setUp(self): - """Initialize test data.""" - self._DEVICE = InsteonFanDevice(self._NODE) - - def tearDown(self): - """Tear down test data.""" - self._DEVICE = None - - def test_properties(self): - """Test basic properties.""" - self.assertEqual(self._NODE.DeviceName, self._DEVICE.name) - self.assertEqual(self._NODE.DeviceID, self._DEVICE.unique_id) - self.assertEqual(SUPPORT_SET_SPEED, self._DEVICE.supported_features) - - for speed in [STATE_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH]: - self.assertIn(speed, self._DEVICE.speed_list) - - def test_turn_on(self): - """Test the turning on device.""" - self._NODE.response = { - 'status': 'succeeded' - } - self.assertEqual(STATE_OFF, self._DEVICE.state) - self._DEVICE.turn_on() - - self.assertEqual(STATE_ON, self._DEVICE.state) - - self._DEVICE.turn_on(SPEED_MED) - - self.assertEqual(STATE_ON, self._DEVICE.state) - self.assertEqual(SPEED_MED, self._DEVICE.state_attributes[ATTR_SPEED]) - - def test_turn_off(self): - """Test turning off device.""" - self._NODE.response = { - 'status': 'succeeded' - } - self.assertEqual(STATE_OFF, self._DEVICE.state) - self._DEVICE.turn_on() - self.assertEqual(STATE_ON, self._DEVICE.state) - self._DEVICE.turn_off() - self.assertEqual(STATE_OFF, self._DEVICE.state) From fb9627deda6864181f4e40c8176b237412c7a1b6 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 2 Sep 2016 23:25:35 +0200 Subject: [PATCH 072/208] Move details to docs (#3146) --- homeassistant/components/switch/netio.py | 56 +----------------------- 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index b33e71df49d..7d30990e823 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -1,63 +1,9 @@ """ -Netio switch component. - -The Netio platform allows you to control your [Netio] -(http://www.netio-products.com/en/overview/) Netio4, Netio4 All and Netio 230B. -These are smart outlets controllable through ethernet and/or WiFi that reports -consumptions (Netio4all). - -To use these devices in your installation, add the following to your -configuration.yaml file: -``` -switch: - - platform: netio - host: netio-living - outlets: - 1: "AppleTV" - 2: "Htpc" - 3: "Lampe Gauche" - 4: "Lampe Droite" - - platform: netio - host: 192.168.1.43 - port: 1234 - username: user - password: pwd - outlets: - 1: "Nothing..." - 4: "Lampe du fer" -``` - -To get pushed updates from the netio devices, one can add this lua code in the -device interface as an action triggered on "Netio" "System variables updated" -with an 'Always' schedule: - -`` --- this will send socket and consumption status updates via CGI --- to given address. Associate with 'System variables update' event --- to get consumption updates when they show up - -local address='ha:8123' -local path = '/api/netio/' - - -local output = {} -for i = 1, 4 do for _, what in pairs({'state', 'consumption', - 'cumulatedConsumption', 'consumptionStart'}) do - local varname = string.format('output%d_%s', i, what) - table.insert(output, - varname..'='..tostring(devices.system[varname]):gsub(" ","|")) -end end - -local qs = table.concat(output, '&') -local url = string.format('http://%s%s?%s', address, path, qs) -devices.system.CustomCGI{url=url} -``` - +The Netio switch component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.netio/ """ - import logging from collections import namedtuple from datetime import timedelta From b5ae005accb9dddf3d324ebf844f2a207a6409d3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 2 Sep 2016 14:50:10 -0700 Subject: [PATCH 073/208] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../components/frontend/www_static/core.js.gz | Bin 32161 -> 32161 bytes .../frontend/www_static/frontend.html | 2 +- .../frontend/www_static/frontend.html.gz | Bin 124571 -> 124575 bytes .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-dev-event.html.gz | Bin 2639 -> 2639 bytes .../panels/ha-panel-dev-info.html.gz | Bin 1308 -> 1308 bytes .../panels/ha-panel-dev-service.html.gz | Bin 2824 -> 2824 bytes .../panels/ha-panel-dev-state.html.gz | Bin 2772 -> 2772 bytes .../panels/ha-panel-dev-template.html.gz | Bin 7290 -> 7290 bytes .../panels/ha-panel-history.html.gz | Bin 6842 -> 6842 bytes .../www_static/panels/ha-panel-iframe.html.gz | Bin 403 -> 403 bytes .../panels/ha-panel-logbook.html.gz | Bin 7344 -> 7344 bytes .../www_static/panels/ha-panel-map.html.gz | Bin 43920 -> 43920 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2282 -> 2282 bytes .../www_static/webcomponents-lite.min.js.gz | Bin 12355 -> 12355 bytes 17 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index f9862e7148a..ed29056a047 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -2,7 +2,7 @@ FINGERPRINTS = { "core.js": "1fd10c1fcdf56a61f60cf861d5a0368c", - "frontend.html": "eb9bda51654858cd32036fb0880e5a17", + "frontend.html": "6b436c50bdef16da2db8af8e1537a405", "mdi.html": "710b84acc99b32514f52291aba9cd8e8", "panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b", "panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169", diff --git a/homeassistant/components/frontend/www_static/core.js.gz b/homeassistant/components/frontend/www_static/core.js.gz index be968b067e39460d3f5a57752cd5f488d9cf0542..0375a9714ed215141f7c64cb01c4a2e0107d9df1 100644 GIT binary patch delta 17 ZcmZ4Zn{nZ9MmG6w4i3gI8`);p0sufj2EPCR delta 17 YcmZ4Zn{nZ9MmG6w4i0hljcl`P0X!oG=l}o! diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 97057d312b0..7b53a4fad70 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -2,4 +2,4 @@ },_distributeDirtyRoots:function(){for(var e,t=this.shadyRoot._dirtyRoots,o=0,i=t.length;o0?~setTimeout(e,t):(this._twiddle.textContent=this._twiddleContent++,this._callbacks.push(e),this._currVal++)},cancel:function(e){if(e<0)clearTimeout(~e);else{var t=e-this._lastVal;if(t>=0){if(!this._callbacks[t])throw"invalid async handle: "+e;this._callbacks[t]=null}}},_atEndOfMicrotask:function(){for(var e=this._callbacks.length,t=0;t \ No newline at end of file +this.currentTarget=t,this.defaultPrevented=!1,this.eventPhase=Event.AT_TARGET,this.timeStamp=Date.now()},i=window.Element.prototype.animate;window.Element.prototype.animate=function(n,r){var o=i.call(this,n,r);o._cancelHandlers=[],o.oncancel=null;var a=o.cancel;o.cancel=function(){a.call(this);var i=new e(this,null,t()),n=this._cancelHandlers.concat(this.oncancel?[this.oncancel]:[]);setTimeout(function(){n.forEach(function(t){t.call(i.target,i)})},0)};var s=o.addEventListener;o.addEventListener=function(t,e){"function"==typeof e&&"cancel"==t?this._cancelHandlers.push(e):s.call(this,t,e)};var u=o.removeEventListener;return o.removeEventListener=function(t,e){if("cancel"==t){var i=this._cancelHandlers.indexOf(e);i>=0&&this._cancelHandlers.splice(i,1)}else u.call(this,t,e)},o}}}(),function(t){var e=document.documentElement,i=null,n=!1;try{var r=getComputedStyle(e).getPropertyValue("opacity"),o="0"==r?"1":"0";i=e.animate({opacity:[o,o]},{duration:1}),i.currentTime=0,n=getComputedStyle(e).getPropertyValue("opacity")==o}catch(t){}finally{i&&i.cancel()}if(!n){var a=window.Element.prototype.animate;window.Element.prototype.animate=function(e,i){return window.Symbol&&Symbol.iterator&&Array.prototype.from&&e[Symbol.iterator]&&(e=Array.from(e)),Array.isArray(e)||null===e||(e=t.convertToArrayForm(e)),a.call(this,e,i)}}}(c),!function(t,e,i){function n(t){var i=e.timeline;i.currentTime=t,i._discardAnimations(),0==i._animations.length?o=!1:requestAnimationFrame(n)}var r=window.requestAnimationFrame;window.requestAnimationFrame=function(t){return r(function(i){e.timeline._updateAnimationsPromises(),t(i),e.timeline._updateAnimationsPromises()})},e.AnimationTimeline=function(){this._animations=[],this.currentTime=void 0},e.AnimationTimeline.prototype={getAnimations:function(){return this._discardAnimations(),this._animations.slice()},_updateAnimationsPromises:function(){e.animationsWithPromises=e.animationsWithPromises.filter(function(t){return t._updatePromises()})},_discardAnimations:function(){this._updateAnimationsPromises(),this._animations=this._animations.filter(function(t){return"finished"!=t.playState&&"idle"!=t.playState})},_play:function(t){var i=new e.Animation(t,this);return this._animations.push(i),e.restartWebAnimationsNextTick(),i._updatePromises(),i._animation.play(),i._updatePromises(),i},play:function(t){return t&&t.remove(),this._play(t)}};var o=!1;e.restartWebAnimationsNextTick=function(){o||(o=!0,requestAnimationFrame(n))};var a=new e.AnimationTimeline;e.timeline=a;try{Object.defineProperty(window.document,"timeline",{configurable:!0,get:function(){return a}})}catch(t){}try{window.document.timeline=a}catch(t){}}(c,e,f),function(t,e,i){e.animationsWithPromises=[],e.Animation=function(e,i){if(this.id="",e&&e._id&&(this.id=e._id),this.effect=e,e&&(e._animation=this),!i)throw new Error("Animation with null timeline is not supported");this._timeline=i,this._sequenceNumber=t.sequenceNumber++,this._holdTime=0,this._paused=!1,this._isGroup=!1,this._animation=null,this._childAnimations=[],this._callback=null,this._oldPlayState="idle",this._rebuildUnderlyingAnimation(),this._animation.cancel(),this._updatePromises()},e.Animation.prototype={_updatePromises:function(){var t=this._oldPlayState,e=this.playState;return this._readyPromise&&e!==t&&("idle"==e?(this._rejectReadyPromise(),this._readyPromise=void 0):"pending"==t?this._resolveReadyPromise():"pending"==e&&(this._readyPromise=void 0)),this._finishedPromise&&e!==t&&("idle"==e?(this._rejectFinishedPromise(),this._finishedPromise=void 0):"finished"==e?this._resolveFinishedPromise():"finished"==t&&(this._finishedPromise=void 0)),this._oldPlayState=this.playState,this._readyPromise||this._finishedPromise},_rebuildUnderlyingAnimation:function(){this._updatePromises();var t,i,n,r,o=!!this._animation;o&&(t=this.playbackRate,i=this._paused,n=this.startTime,r=this.currentTime,this._animation.cancel(),this._animation._wrapper=null,this._animation=null),(!this.effect||this.effect instanceof window.KeyframeEffect)&&(this._animation=e.newUnderlyingAnimationForKeyframeEffect(this.effect),e.bindAnimationForKeyframeEffect(this)),(this.effect instanceof window.SequenceEffect||this.effect instanceof window.GroupEffect)&&(this._animation=e.newUnderlyingAnimationForGroup(this.effect),e.bindAnimationForGroup(this)),this.effect&&this.effect._onsample&&e.bindAnimationForCustomEffect(this),o&&(1!=t&&(this.playbackRate=t),null!==n?this.startTime=n:null!==r?this.currentTime=r:null!==this._holdTime&&(this.currentTime=this._holdTime),i&&this.pause()),this._updatePromises()},_updateChildren:function(){if(this.effect&&"idle"!=this.playState){var t=this.effect._timing.delay;this._childAnimations.forEach(function(i){this._arrangeChildren(i,t),this.effect instanceof window.SequenceEffect&&(t+=e.groupChildDuration(i.effect))}.bind(this))}},_setExternalAnimation:function(t){if(this.effect&&this._isGroup)for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 09792deb421b31164a20d3b7a4b94ba69fef0695..c102c9ea863fe9fc695888ac6e8b9b0781f4fd56 100644 GIT binary patch delta 9675 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 J70Z;u2LK?n55WKc diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 3d77da03460..7e455e2be1c 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 3d77da034605a573cdeaab70797ffd6f3bfa6104 +Subproject commit 7e455e2be1cb7cc4f55628b063019bea548a3182 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz index 01076509ebfab9b256de52c1429b28bac71210f6..449168e542233cb05c7ac7f88a6857436733c74a 100644 GIT binary patch delta 15 WcmX>va$bZ@zMF%C@ykXwPc8r_$^?c0 delta 15 WcmX>va$bZ@zMF$XoP8skCl>%Ctpl$B diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index 4ede258f804c3c0a8c39a79dbc1bd1cbe3fd2636..5e0b23a2b1b0939c9a708267c058e0984989edfe 100644 GIT binary patch delta 15 WcmbQkHHV8$zMF%C@ykXwF;)N|#{<*= delta 15 WcmbQkHHV8$zMF$XoP8sk7%KoAssjB0 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz index 6e8bcb42b1a611e33ccdab7160a2b5732b7231f7..354c6134e76f2a7101374902b49895e27b9f5517 100644 GIT binary patch delta 15 WcmeAW>kwm;@8;lO{IZdai5mbPtpm0I delta 15 WcmeAW>kwm;@8;kTXWz)i#0>x&kOJQT diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index 2194d2ec7d3b7a858cc4bcf7b3d1633bad21e56f..551bd1b7cc5f3fe04086a6a5abcd22f6c99de140 100644 GIT binary patch delta 15 Wcmca2dPS5?zMF%C@ykZGb6fx^dIc8% delta 15 Wcmca2dPS5?zMF$XoP8tPIW7PtT?9Y? diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 68f6c6ae8f360cdc10508d5b655482333138fff7..2913e87a75bb0d6735c6baa3ecc20f8ac84a5f6a 100644 GIT binary patch delta 15 Wcmexm@ymiuzMF%C@ykZG5*Yw6js;-= delta 15 Wcmexm@ymiuzMF$XoP8r(i3|WNaRiD0 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz index 9ef641247a43b6aaaf4af399d9bc3aefd097e65d..28da4222fc2c07e3674f96aa1fe419eea5415d92 100644 GIT binary patch delta 15 WcmdmGy33SJzMF%C@ykZGEm8m}z6BWo delta 15 WcmdmGy33SJzMF$XoP8tP7AXKFp#(wz diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz index 975e92e05a8465965e02669577a5cdacd5470c9c..01af9e0efe544d9c7d7f2fd5457fc22cd06bd0e1 100644 GIT binary patch delta 15 WcmbQtJeiqIzMF%C@ykZGUPb^P{{$xh delta 15 WcmbQtJeiqIzMF$XoP8r(FCzdO;sa0s diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index dab7a8d6902b9a7f8c12147d0d7f99ee04e417ac..268a16b5a1ba5903a8504f14c53bf8bcacaf8807 100644 GIT binary patch delta 15 WcmdmBxxtc6zMF%C@ykZGRWbl5`~?L7 delta 15 WcmdmBxxtc6zMF$XoP8tPDj5JI-vllI diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index af4e0a2bdf5a948def7339606d2e9213a11487c5..4b4f778ffd3a89e963bd9951f7ba0f62459912f5 100644 GIT binary patch delta 17 ZcmbPmooT{#CN}wQ4i3gI8`-*60{}Wi26O-b delta 17 YcmbPmooT{#CN}wQ4i0hljci@30X8QEod5s; diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 62ffe2e933c..6b874dd62db 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","e9eb8d2d7895d952b731c1cc5c940c2c"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-1fd10c1fcdf56a61f60cf861d5a0368c.js","800ebb1bbb48274790f2ee1a2e53a24c"],["/static/frontend-eb9bda51654858cd32036fb0880e5a17.html","cc037c890ad90ae62a1bc19044fa8ba6"],["/static/mdi-710b84acc99b32514f52291aba9cd8e8.html","149c8eaf6bb78a9b642c7bcedab86900"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e["delete"](a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))})["catch"](function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;a4B=~S^4oo}I-CLxvLW8v5-3p)0=kpih z14=WXH+=Gf3hRYgj>p@nvZ=`v8)=$$zhFE*66b3)a_Kr#SeMh@yt`m&R#h^UTN@nz z(+bV9QJ2ok7q8zq{t`i9+g)08|319CpPc7SZ3xTB8``XM>A-qjZ8tjo&^5K=f4?Ci zZTa7R?9%xGp~ZIOT$p>$7md2R{KxATzXmpP>IGRbIPC`7(Yi%xBZoBTaxuTUa;A>& zV3DQ_nT%PIiUJY^F~(Xko+OY13g_CViMs90QqwimSanl{HxslzpF~pTIS0&Hm`bJv zBmilqq9}$4L@0U`te!AL#Z;&`%d$-78Hyqenaa6N!bHU9^^~=4CQ*!`(j0UYV#>7E z5;SJWSPn(V(_*J4!y+n_%+gRvCK=LOp+t<3r4S2NoLAFfds9lB2$9Gn&p<(1#K^RY zLx}*WRH`ur*lWr|8RsdJ3FfrXEDj?spio*RVXnh3X)<7N5~nPW!n7bcNt(v7j6=br zA||WD9NAt^m}W^XvY3P9f`G$9YAM3lZZXfYF92t7wXUox2COJloa99srKlnn+C^(O;A9cYCvMrKXEM=JJLKAxFnJrOSnSjm|i$T*vC2>~B1Q_F~khzML z%G6Fn$|6o~6tM~;m53pbO( zG(>4GgAX0a%^5=>xDX=F$rV|_bco0y#3X_+mPc}T8QZ+N+ul=6^J}7*dTakH5uJa+eS2|nZD{DlOLK&1;%I+rJ}H)R1{I1 zrkN%`LXpH_2mp!;mPTI_^>evcnysu%BwFOT=Al5D^ElE3Z^(s=!jL=`e@)gesLIW1 zD@-GgFyDa_-D z%R_0Ut=m%#H}+-1i|{x44c7I-x|A0Mi^xe~NCrWMAr&x+A1oN>JWI~X3Fr^4XO!-V zP>_*UwA(_#rNB_gw4nSsf~Z`<0y`ptR=r;lH1M9%@($bgVzIRbqBzXC;*#f4LbWpo ziUcyHh#00E;85Bv3fZjIO-eHgx&8_$QpG_QNgbG>_Cwn$~KZa=$76XQi8>}r>b3Xzvnzv+m{u+?=KI(0oUvnJxWHOA3g5o^>G{qvuJJCMx%1Q) z@H}50nOk-*X)o`VJQyqEp_j!t>4T#KlFI!)6FX*U$F)ktHo$o)lbLNh1LUmb@=BFl_fLf0B z?%|W!eR@Wf3p;91uh_NMx3Xt-eQ3&hr)qd7(;mgo61ruN!H_2J5AwwEsafvcG`ryH zdNh6^ZO84^i2)WmGq(o%Ko5TH*AJfa`y{KHq0yuGm>-9*TOZp*QT&YW(7&n=jQ8dB zz30t(d6aj3U;eWVs+ur$O6p^&9d-Oe%P_cw(!8xrS-JfVx`6V#cK3zi{K4S#IPz%f z-b168ZL=yn3`o8EecQraKs3B^AE6oCK(#?S#ys*M^jSb|CKzl6?cGUxbS;dp@IMZqHb|3mVy<3@9FLuv+J=RVk>foQD~!h{h2On${_VQoJ#z=H|4fI; z&$cR5pSP!z_qR>^5nE#W&nu`)ZDrYGA?1tr=RZ-Ka0E$Dl=~_pA61#ds&RHlzOkp^ z7CT}u^{zC_KOU+B(L9#Fz@7^4y(>rB2^4ZqR1MpCHI>bjwsyaL-fSvW|La>bvT)#N zFss9nZs-L3P3MekO&Rr8U5>ta@$E4j#9-TF`hK{N*z+N~-gL_+l6y9s`3YUB=-bw>(b2#u|cl<41QgvT8bXPlgB7o*1<5*~0ncw;>D<>gBfX z^s)6l6}c@qx0J`tt@G4fR^Pk$^^9VLl7h|PFS~|n1{W6|m78HksyLdsTB>F7po zIw}sMdRHUspdWL+8LilvVUyuquX;D_^o#q)+(GyqyW@+FpxM12?!DRn0N}6jn>iE! E0F8isSpWb4 literal 2282 zcmV@S*%RC~IN$1?hiMr{`QsX7BplrtqE=OQ}J_@DGGRh$%ERjSD z9&?_gDhwkYa=}E0g4M$qD1wS8Nz+tjDTq8_L}gUREEdsmJw>JKQ5XSJnsOa7NQl;2 zat$dElJT6;B;Tk>ArEsU(}XEWBmsQOm56|+36BKHkE>~+xhy1%gotIFrCjkOkAP?u zF$tW5Qi+Cy^R1?g$tX*Rj3L8~rV$IN;JMN&W|?MBX)@fvC`w2cvLr`2ags!ljF_OI z1)Z=A$W{+a(l`@oM7g8_frDIXDOhB;n5F3xz!_L9ORI`-lIJRlvpfkCP$3C39zw1O zBTCxj+KW-3xD zRT~Wn2`Rc!M2dwf79pdIWKon0rdS^8tk+-xD&$z!4UDM9h>=uFrQ?JrR3qX#OQ=E( zVUo$IcubQx%Hm9_kfBH|NR|KGxj+G3*hiSHO5UucaT)3qgEzL+==7Poumx3(9 z%C#%4uyuQ^`K5gs(VYE`zrnJap`3{3ISJ88jG;k11%?F-;|Fp=DNW;}asvE=>j|cN zjKPzlR_Jp4_zYSkf<|2|5E_1gX?YFJg_y0a;bFuws<7~8VT`pi z;}{8ON*)qODCfJ=|Z&Qq{h$t*jm~t9{$yFw( zbAIl39Po}WVCuA2qYWvHF4<`0oVYENI%r^4Ac1rY^WtD6Xl0E=>^PoZ%qq-L>_k5B z1|=G#u(up}5UFQ+HvVm878m1lpDXnX60)Vw>(`Zn->3IIhUH~TPJC)<@XN*=5xR5 zHOfPcgK9XGJCd+tvZP*lx3{hgT4Y)|KbdY{UpPa@!f*nWtQCCu`1;kxF|P4WoT>BJ z7w&n!+%vamKj2=z&e4@6q73Q*K81E|qXO%;#OsZHMkf2f8E=cpWZT-c^#UGi>G*nY zU3cKyZejVlbEew!<{$GEO6w7sZeG^DqF&Hq|6*K3$~)eFuIEf0+l1<(M9q&)Gy%38 z&Gr2!v-$K4D;Kobpq`O)uWMzG>iSj})kanSj;1|`pE+;m9R_`xTi}tZ;XXh!xa8#u@EG&JgS^WEbTh(WGid8h+@ot@{0RS9 z*$CkN4ML}I*99-zQ>R7yN7ydUipoJ?x}SrS&dz=iw)T6m{ECS5!C)8oN8q&Qm`ili zKAyd&H)p1!8=U+fxF0#}@ki0-PeEzVe)n*mIsKu=Imc;#1S?qP|@e zP@y5t-+%q=bk(itT^RJIq&1G1EJurzd%p&E@e~<(6El72h(40K8d^;;Gmw}1wruhY z4|kR2nCSs?xt2csW5RlAa*E8=36aC0Q(@aMvoW&2M`VTJ@SyO!XU=b~`{J3~bNwwI zCU0$3C~w!NlZ&gm`3?=T{q2HRg|@Qnu@Ljc`|Dq@P1u8^Bg$=+kq@fOZq+!tBj4Cl za04x}m-jPdU)8HpRsVi&1{Mw& z^k%g%&^4Zbe`=k9tto@fs?)(w@1O6(fef}jrf-M4h&>*%%T+snB)KR3nIF-`ioU*0 zXl-uzyE*+x<20|Y-tAmnxJX-tDJ``g;&*s|(%pYJ7>-a}f^yc^B=%-xeUoAz_Il+0 zM#Ot6A$M<4Fp=#Q#o<m5#3E zs>R|ks5UjS3c4}Zo8XF#88#WdcB(hi4!^j6&K-!~zB`_D1kUdKaOX|_2e7+O-#HWj E0Kx@&w*UYD diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz index 96420a8d9ee2d9e1ff335a003d9815b43bc21a83..0ea3263ccd3327e33716dce2c79d437936829db4 100644 GIT binary patch delta 15 WcmX?{a5#ZYzMF%C@ykXwTLS Date: Sat, 3 Sep 2016 00:09:14 +0200 Subject: [PATCH 074/208] Use constants (#3148) --- homeassistant/components/switch/orvibo.py | 18 ++++++++---------- homeassistant/const.py | 2 ++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/switch/orvibo.py b/homeassistant/components/switch/orvibo.py index 274b2cd40ca..0ce1426dd1f 100644 --- a/homeassistant/components/switch/orvibo.py +++ b/homeassistant/components/switch/orvibo.py @@ -8,34 +8,32 @@ import logging import voluptuous as vol -from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_SWITCHES, CONF_MAC, CONF_DISCOVERY) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['orvibo==1.1.1'] + _LOGGER = logging.getLogger(__name__) -CONF_SWITCHES = 'switches' -CONF_HOST = 'host' -CONF_NAME = 'name' -CONF_MAC = 'mac' -CONF_DISCOVERY = 'discovery' DEFAULT_NAME = 'Orvibo S20 Switch' DEFAULT_DISCOVERY = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, vol.Required(CONF_SWITCHES, default=[]): vol.All(cv.ensure_list, [{ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string - }]) + }]), + vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, }) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Find and return S20 switches.""" + """Setup S20 switches.""" from orvibo.s20 import discover, S20, S20Exception switch_data = {} @@ -51,7 +49,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): for host, data in switch_data.items(): try: - switches.append(S20Switch(data.get(CONF_NAME, DEFAULT_NAME), + switches.append(S20Switch(data.get(CONF_NAME), S20(host, mac=data.get(CONF_MAC)))) _LOGGER.info("Initialized S20 at %s", host) except S20Exception: diff --git a/homeassistant/const.py b/homeassistant/const.py index f3c016015e6..ce0d829e76b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -41,6 +41,7 @@ CONF_CUSTOMIZE = 'customize' CONF_DEVICE = 'device' CONF_DEVICES = 'devices' CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' +CONF_DISCOVERY = 'discovery' CONF_DISPLAY_OPTIONS = 'display_options' CONF_ELEVATION = 'elevation' CONF_ENTITY_ID = 'entity_id' @@ -55,6 +56,7 @@ CONF_ICON = 'icon' CONF_ID = 'id' CONF_LATITUDE = 'latitude' CONF_LONGITUDE = 'longitude' +CONF_MAC = 'mac' CONF_METHOD = 'method' CONF_MONITORED_CONDITIONS = 'monitored_conditions' CONF_MONITORED_VARIABLES = 'monitored_variables' From 795121d5a8066bd504b7974cd3bb46c24d927774 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 3 Sep 2016 09:35:33 +0200 Subject: [PATCH 075/208] Update ordering (#3149) --- homeassistant/components/sensor/forecast.py | 29 ++++++++++----------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/forecast.py b/homeassistant/components/sensor/forecast.py index 4f3b2cd17c7..213760fee0d 100644 --- a/homeassistant/components/sensor/forecast.py +++ b/homeassistant/components/sensor/forecast.py @@ -6,20 +6,28 @@ https://home-assistant.io/components/sensor.forecast/ """ import logging from datetime import timedelta + import voluptuous as vol from requests.exceptions import ConnectionError as ConnectError, \ HTTPError, Timeout -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_API_KEY, CONF_NAME, - CONF_MONITORED_CONDITIONS) +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['python-forecastio==1.3.4'] + _LOGGER = logging.getLogger(__name__) +CONF_UNITS = 'units' + +DEFAULT_NAME = 'Forecast.io' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { @@ -57,22 +65,16 @@ SENSOR_TYPES = { 'precip_intensity_max': ['Daily Max Precip Intensity', 'mm', 'in', 'mm', 'mm', 'mm'], } -DEFAULT_NAME = "Forecast.io" -CONF_UNITS = 'units' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']) }) -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Forecast.io sensor.""" # Validate the configuration @@ -100,7 +102,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) - # Initialize and add all of the sensors. sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(ForeCastSensor(forecast_data, variable, name)) @@ -249,10 +250,8 @@ class ForeCastData(object): import forecastio try: - self.data = forecastio.load_forecast(self._api_key, - self.latitude, - self.longitude, - units=self.units) + self.data = forecastio.load_forecast( + self._api_key, self.latitude, self.longitude, units=self.units) except (ConnectError, HTTPError, Timeout, ValueError) as error: raise ValueError("Unable to init Forecast.io. - %s", error) self.unit_system = self.data.json['flags']['units'] From 5dc63c17c85dfd99ac5335b180cfb8070504b94b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 3 Sep 2016 10:56:41 +0200 Subject: [PATCH 076/208] Migrate to voluptuous (#3092) --- homeassistant/components/sensor/dht.py | 48 ++++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 109e539c599..461c2fb1eeb 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -7,7 +7,12 @@ https://home-assistant.io/components/sensor.dht/ import logging from datetime import timedelta -from homeassistant.const import TEMP_FAHRENHEIT +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + TEMP_FAHRENHEIT, CONF_NAME, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit @@ -18,18 +23,29 @@ REQUIREMENTS = ['http://github.com/adafruit/Adafruit_Python_DHT/archive/' '#Adafruit_DHT==1.3.0'] _LOGGER = logging.getLogger(__name__) + CONF_PIN = 'pin' CONF_SENSOR = 'sensor' + +DEFAULT_NAME = 'DHT Sensor' + +# DHT11 is able to deliver data once per second, DHT22 once every two +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + SENSOR_TEMPERATURE = 'temperature' SENSOR_HUMIDITY = 'humidity' SENSOR_TYPES = { SENSOR_TEMPERATURE: ['Temperature', None], SENSOR_HUMIDITY: ['Humidity', '%'] } -DEFAULT_NAME = "DHT Sensor" -# Return cached results if last scan was less then this time ago -# DHT11 is able to deliver data once per second, DHT22 once every two -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSOR): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -46,23 +62,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensor = available_sensors.get(config.get(CONF_SENSOR)) pin = config.get(CONF_PIN) - if not sensor or not pin: - _LOGGER.error( - "Config error " - "Please check your settings for DHT, sensor not supported.") - return None + if not sensor: + _LOGGER.error("DHT sensor type is not supported") + return False data = DHTClient(Adafruit_DHT, sensor, pin) dev = [] - name = config.get('name', DEFAULT_NAME) + name = config.get(CONF_NAME) try: - for variable in config['monitored_conditions']: - if variable not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) - else: - dev.append( - DHTSensor(data, variable, SENSOR_TYPES[variable][1], name)) + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append(DHTSensor( + data, variable, SENSOR_TYPES[variable][1], name)) except KeyError: pass @@ -109,8 +120,7 @@ class DHTSensor(Entity): if (temperature >= -20) and (temperature < 80): self._state = temperature if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(celsius_to_fahrenheit(temperature), - 1) + self._state = round(celsius_to_fahrenheit(temperature), 1) elif self.type == SENSOR_HUMIDITY: humidity = round(data[SENSOR_HUMIDITY], 1) if (humidity >= 0) and (humidity <= 100): From a08ac8597169c101a751a8a7c541985ce1c25fea Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 3 Sep 2016 17:01:05 +0200 Subject: [PATCH 077/208] Display the error instead of the traceback (notify.slack) (#3079) * Display the error instead of the traceback * Remove name for check --- homeassistant/components/notify/slack.py | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 39ca0197d0f..e49862b06da 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -6,27 +6,33 @@ https://home-assistant.io/components/notify.slack/ """ import logging -from homeassistant.components.notify import DOMAIN, BaseNotificationService +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['slacker==0.9.24'] + _LOGGER = logging.getLogger(__name__) +CONF_CHANNEL = 'default_channel' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_CHANNEL): cv.string, +}) + # pylint: disable=unused-variable def get_service(hass, config): """Get the Slack notification service.""" import slacker - if not validate_config({DOMAIN: config}, - {DOMAIN: ['default_channel', CONF_API_KEY]}, - _LOGGER): - return None - try: return SlackNotificationService( - config['default_channel'], + config[CONF_CHANNEL], config[CONF_API_KEY]) except slacker.Error: @@ -61,5 +67,5 @@ class SlackNotificationService(BaseNotificationService): self.slack.chat.post_message(channel, message, as_user=True, attachments=attachments) - except slacker.Error: - _LOGGER.exception("Could not send slack notification") + except slacker.Error as err: + _LOGGER.error("Could not send slack notification. Error: %s", err) From 601395bc12e0a9061079e1f08b1132a93c2353f5 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sat, 3 Sep 2016 10:38:17 -0600 Subject: [PATCH 078/208] Automatic ODB device tracker & device tracker attributes (#3035) --- .../components/device_tracker/__init__.py | 25 +- .../components/device_tracker/automatic.py | 161 +++++++++++ .../device_tracker/test_automatic.py | 254 ++++++++++++++++++ 3 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/device_tracker/automatic.py create mode 100644 tests/components/device_tracker/test_automatic.py diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a4f65ab4ea4..4247213087b 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -62,6 +62,7 @@ ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_GPS = 'gps' ATTR_BATTERY = 'battery' +ATTR_ATTRIBUTES = 'attributes' PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): cv.positive_int, # seconds @@ -86,10 +87,11 @@ def is_on(hass: HomeAssistantType, entity_id: str=None): return hass.states.is_state(entity, STATE_HOME) +# pylint: disable=too-many-arguments def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, host_name: str=None, location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery=None): # pylint: disable=too-many-arguments + battery=None, attributes: dict=None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -99,6 +101,9 @@ def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, (ATTR_GPS, gps), (ATTR_GPS_ACCURACY, gps_accuracy), (ATTR_BATTERY, battery)) if value is not None} + if attributes: + for key, value in attributes: + data[key] = value hass.services.call(DOMAIN, SERVICE_SEE, data) @@ -164,7 +169,7 @@ def setup(hass: HomeAssistantType, config: ConfigType): """Service to see a device.""" args = {key: value for key, value in call.data.items() if key in (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY)} + ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} tracker.see(**args) descriptions = load_yaml_config_file( @@ -202,7 +207,7 @@ class DeviceTracker(object): def see(self, mac: str=None, dev_id: str=None, host_name: str=None, location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery: str=None): + battery: str=None, attributes: dict=None): """Notify the device tracker that you see a device.""" with self.lock: if mac is None and dev_id is None: @@ -218,7 +223,7 @@ class DeviceTracker(object): if device: device.seen(host_name, location_name, gps, gps_accuracy, - battery) + battery, attributes) if device.track: device.update_ha_state() return @@ -232,7 +237,8 @@ class DeviceTracker(object): if mac is not None: self.mac_to_dev[mac] = device - device.seen(host_name, location_name, gps, gps_accuracy, battery) + device.seen(host_name, location_name, gps, gps_accuracy, battery, + attributes) if device.track: device.update_ha_state() @@ -267,6 +273,7 @@ class Device(Entity): gps_accuracy = 0 last_seen = None # type: dt_util.dt.datetime battery = None # type: str + attributes = None # type: dict # Track if the last update of this device was HOME. last_update_home = False @@ -330,6 +337,10 @@ class Device(Entity): if self.battery: attr[ATTR_BATTERY] = self.battery + if self.attributes: + for key, value in self.attributes: + attr[key] = value + return attr @property @@ -338,13 +349,15 @@ class Device(Entity): return self.away_hide and self.state != STATE_HOME def seen(self, host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=0, battery: str=None): + gps: GPSType=None, gps_accuracy=0, battery: str=None, + attributes: dict=None): """Mark the device as seen.""" self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name self.gps_accuracy = gps_accuracy or 0 self.battery = battery + self.attributes = attributes self.gps = None if gps is not None: try: diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py new file mode 100644 index 00000000000..927c515b3a5 --- /dev/null +++ b/homeassistant/components/device_tracker/automatic.py @@ -0,0 +1,161 @@ +""" +Support for the Automatic platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.automatic/ +""" +from datetime import timedelta +import logging +import re +import requests + +import voluptuous as vol + +from homeassistant.components.device_tracker import (PLATFORM_SCHEMA, + ATTR_ATTRIBUTES) +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle, datetime as dt_util + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) + +CONF_CLIENT_ID = 'client_id' +CONF_SECRET = 'secret' +CONF_DEVICES = 'devices' + +SCOPE = 'scope:location scope:vehicle:profile scope:user:profile scope:trip' + +ATTR_ACCESS_TOKEN = 'access_token' +ATTR_EXPIRES_IN = 'expires_in' +ATTR_RESULTS = 'results' +ATTR_VEHICLE = 'vehicle' +ATTR_ENDED_AT = 'ended_at' +ATTR_END_LOCATION = 'end_location' + +URL_AUTHORIZE = 'https://accounts.automatic.com/oauth/access_token/' +URL_VEHICLES = 'https://api.automatic.com/vehicle/' +URL_TRIPS = 'https://api.automatic.com/trip/' + +_VEHICLE_ID_REGEX = re.compile( + (URL_VEHICLES + '(.*)?[/]$').replace('/', r'\/')) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_SECRET): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]) +}) + + +def setup_scanner(hass, config: dict, see): + """Validate the configuration and return an Automatic scanner.""" + try: + AutomaticDeviceScanner(config, see) + except requests.HTTPError as err: + _LOGGER.error(str(err)) + return False + + return True + + +class AutomaticDeviceScanner(object): + """A class representing an Automatic device.""" + + def __init__(self, config: dict, see) -> None: + """Initialize the automatic device scanner.""" + self._devices = config.get(CONF_DEVICES, None) + self._access_token_payload = { + 'username': config.get(CONF_USERNAME), + 'password': config.get(CONF_PASSWORD), + 'client_id': config.get(CONF_CLIENT_ID), + 'client_secret': config.get(CONF_SECRET), + 'grant_type': 'password', + 'scope': SCOPE + } + self._headers = None + self._token_expires = dt_util.now() + self.last_results = {} + self.last_trips = {} + self.see = see + + self.scan_devices() + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [item['id'] for item in self.last_results] + + def get_device_name(self, device): + """Get the device name from id.""" + vehicle = [item['display_name'] for item in self.last_results + if item['id'] == device] + + return vehicle[0] + + def _update_headers(self): + """Get the access token from automatic.""" + if self._headers is None or self._token_expires <= dt_util.now(): + resp = requests.post( + URL_AUTHORIZE, + data=self._access_token_payload) + + resp.raise_for_status() + + json = resp.json() + + access_token = json[ATTR_ACCESS_TOKEN] + self._token_expires = dt_util.now() + timedelta( + seconds=json[ATTR_EXPIRES_IN]) + self._headers = { + 'Authorization': 'Bearer {}'.format(access_token) + } + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self) -> None: + """Update the device info.""" + _LOGGER.info('Updating devices') + self._update_headers() + + response = requests.get(URL_VEHICLES, headers=self._headers) + + response.raise_for_status() + + self.last_results = [item for item in response.json()[ATTR_RESULTS] + if self._devices is None or item[ + 'display_name'] in self._devices] + + response = requests.get(URL_TRIPS, headers=self._headers) + + if response.status_code == 200: + for trip in response.json()[ATTR_RESULTS]: + vehicle_id = _VEHICLE_ID_REGEX.match( + trip[ATTR_VEHICLE]).group(1) + if vehicle_id not in self.last_trips: + self.last_trips[vehicle_id] = trip + elif self.last_trips[vehicle_id][ATTR_ENDED_AT] < trip[ + ATTR_ENDED_AT]: + self.last_trips[vehicle_id] = trip + + for vehicle in self.last_results: + dev_id = vehicle.get('id') + + attrs = { + 'fuel_level': vehicle.get('fuel_level_percent') + } + + kwargs = { + 'dev_id': dev_id, + 'mac': dev_id, + ATTR_ATTRIBUTES: attrs + } + + if dev_id in self.last_trips: + end_location = self.last_trips[dev_id][ATTR_END_LOCATION] + kwargs['gps'] = (end_location['lat'], end_location['lon']) + kwargs['gps_accuracy'] = end_location['accuracy_m'] + + self.see(**kwargs) diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py new file mode 100644 index 00000000000..e026d91a43c --- /dev/null +++ b/tests/components/device_tracker/test_automatic.py @@ -0,0 +1,254 @@ +"""Test the automatic device tracker platform.""" + +import logging +import requests +import unittest +from unittest.mock import patch + +from homeassistant.components.device_tracker.automatic import ( + URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner, + AutomaticDeviceScanner) + +_LOGGER = logging.getLogger(__name__) + +INVALID_USERNAME = 'bob' +VALID_USERNAME = 'jim' +PASSWORD = 'password' +CLIENT_ID = '12345' +CLIENT_SECRET = '54321' +FUEL_LEVEL = 77.2 +LATITUDE = 32.82336 +LONGITUDE = -117.23743 +ACCURACY = 8 +DISPLAY_NAME = 'My Vehicle' + + +def mocked_requests(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + @property + def content(self): + """Return the content of the response.""" + return self.json() + + def raise_for_status(self): + """Raise an HTTPError if status is not 200.""" + if self.status_code != 200: + raise requests.HTTPError(self.status_code) + + data = kwargs.get('data') + + if data and data.get('username', None) == INVALID_USERNAME: + return MockResponse({ + "error": "invalid_credentials" + }, 401) + elif str(args[0]).startswith(URL_AUTHORIZE): + return MockResponse({ + "user": { + "sid": "sid", + "id": "id" + }, + "token_type": "Bearer", + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "expires_in": 31521669, + "scope": "" + }, 200) + elif str(args[0]).startswith(URL_VEHICLES): + return MockResponse({ + "_metadata": { + "count": 2, + "next": None, + "previous": None + }, + "results": [ + { + "url": "https://api.automatic.com/vehicle/vid/", + "id": "vid", + "created_at": "2016-03-05T20:05:16.240000Z", + "updated_at": "2016-08-29T01:52:59.597898Z", + "make": "Honda", + "model": "Element", + "year": 2007, + "submodel": "EX", + "display_name": DISPLAY_NAME, + "fuel_grade": "regular", + "fuel_level_percent": FUEL_LEVEL, + "active_dtcs": [] + }] + }, 200) + elif str(args[0]).startswith(URL_TRIPS): + return MockResponse({ + "_metadata": { + "count": 1594, + "next": "https://api.automatic.com/trip/?page=2", + "previous": None + }, + "results": [ + { + "url": "https://api.automatic.com/trip/tid1/", + "id": "tid1", + "driver": "https://api.automatic.com/user/uid/", + "user": "https://api.automatic.com/user/uid/", + "started_at": "2016-08-28T19:37:23.986000Z", + "ended_at": "2016-08-28T19:43:22.500000Z", + "distance_m": 3931.6, + "duration_s": 358.5, + "vehicle": "https://api.automatic.com/vehicle/vid/", + "start_location": { + "lat": 32.87336, + "lon": -117.22743, + "accuracy_m": 10 + }, + "start_address": { + "name": "123 Fake St, Nowhere, NV 12345", + "display_name": "123 Fake St, Nowhere, NV", + "street_number": "Unknown", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "end_location": { + "lat": LATITUDE, + "lon": LONGITUDE, + "accuracy_m": ACCURACY + }, + "end_address": { + "name": "321 Fake St, Nowhere, NV 12345", + "display_name": "321 Fake St, Nowhere, NV", + "street_number": "Unknown", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "path": "path", + "vehicle_events": [], + "start_timezone": "America/Denver", + "end_timezone": "America/Denver", + "idling_time_s": 0, + "tags": [] + }, + { + "url": "https://api.automatic.com/trip/tid2/", + "id": "tid2", + "driver": "https://api.automatic.com/user/uid/", + "user": "https://api.automatic.com/user/uid/", + "started_at": "2016-08-28T18:48:00.727000Z", + "ended_at": "2016-08-28T18:55:25.800000Z", + "distance_m": 3969.1, + "duration_s": 445.1, + "vehicle": "https://api.automatic.com/vehicle/vid/", + "start_location": { + "lat": 32.87336, + "lon": -117.22743, + "accuracy_m": 11 + }, + "start_address": { + "name": "123 Fake St, Nowhere, NV, USA", + "display_name": "Fake St, Nowhere, NV", + "street_number": "123", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "end_location": { + "lat": 32.82336, + "lon": -117.23743, + "accuracy_m": 10 + }, + "end_address": { + "name": "321 Fake St, Nowhere, NV, USA", + "display_name": "Fake St, Nowhere, NV", + "street_number": "Unknown", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "path": "path", + "vehicle_events": [], + "start_timezone": "America/Denver", + "end_timezone": "America/Denver", + "idling_time_s": 0, + "tags": [] + } + ] + }, 200) + else: + _LOGGER.debug('UNKNOWN ROUTE') + + +class TestAutomatic(unittest.TestCase): + """Test cases around the automatic device scanner.""" + + def see_mock(self, **kwargs): + """Mock see function.""" + self.assertEqual('vid', kwargs.get('dev_id')) + self.assertEqual(FUEL_LEVEL, + kwargs.get('attributes', {}).get('fuel_level')) + self.assertEqual((LATITUDE, LONGITUDE), kwargs.get('gps')) + self.assertEqual(ACCURACY, kwargs.get('gps_accuracy')) + + def setUp(self): + """Set up test data.""" + + def tearDown(self): + """Tear down test data.""" + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_invalid_credentials(self, mock_get, mock_post): + """Test error is raised with invalid credentials.""" + config = { + 'platform': 'automatic', + 'username': INVALID_USERNAME, + 'password': PASSWORD, + 'client_id': CLIENT_ID, + 'secret': CLIENT_SECRET + } + + self.assertFalse(setup_scanner(None, config, self.see_mock)) + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_valid_credentials(self, mock_get, mock_post): + """Test error is raised with invalid credentials.""" + config = { + 'platform': 'automatic', + 'username': VALID_USERNAME, + 'password': PASSWORD, + 'client_id': CLIENT_ID, + 'secret': CLIENT_SECRET + } + + self.assertTrue(setup_scanner(None, config, self.see_mock)) + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_device_attributes(self, mock_get, mock_post): + """Test device attributes are set on load.""" + config = { + 'platform': 'automatic', + 'username': VALID_USERNAME, + 'password': PASSWORD, + 'client_id': CLIENT_ID, + 'secret': CLIENT_SECRET + } + + scanner = AutomaticDeviceScanner(config, self.see_mock) + + self.assertEqual(DISPLAY_NAME, scanner.get_device_name('vid')) From 70888532f8369378942795f8e4954126968a8ae4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 01:30:21 +0200 Subject: [PATCH 079/208] Migrate to voluptuous (#3173) --- homeassistant/components/sensor/lastfm.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 3001171081e..2e493399d5b 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -5,12 +5,25 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.lastfm/ """ import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pylast==1.6.0'] + +CONF_USERS = 'users' ICON = 'mdi:lastfm' -REQUIREMENTS = ['pylast==1.6.0'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_USERS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) # pylint: disable=unused-argument @@ -18,9 +31,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Last.fm platform.""" import pylast as lastfm network = lastfm.LastFMNetwork(api_key=config.get(CONF_API_KEY)) + add_devices( [LastfmSensor(username, - network) for username in config.get("users", [])]) + network) for username in config.get(CONF_USERS)]) class LastfmSensor(Entity): From fe7f797ad9078e62e116154220ebcb0ad2f56b11 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 4 Sep 2016 01:30:48 +0200 Subject: [PATCH 080/208] Add voluptuous for tomato and SNMP (#3172) --- .../components/device_tracker/snmp.py | 17 ++++++++++------- .../components/device_tracker/tomato.py | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 9981b4d7ca6..56f9eb4aae6 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -9,9 +9,11 @@ import logging import threading from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -23,15 +25,16 @@ REQUIREMENTS = ['pysnmp==4.3.2'] CONF_COMMUNITY = "community" CONF_BASEOID = "baseoid" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_COMMUNITY): cv.string, + vol.Required(CONF_BASEOID): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return an snmp scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_COMMUNITY, CONF_BASEOID]}, - _LOGGER): - return None - scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index f5282feb733..f463c5a809d 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -11,10 +11,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -24,15 +25,16 @@ CONF_HTTP_ID = "http_id" _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HTTP_ID): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and returns a Tomato scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, - CONF_PASSWORD, CONF_HTTP_ID]}, - _LOGGER): - return None - return TomatoDeviceScanner(config[DOMAIN]) From 91a35221004758f6f8c10b74ec57f537936f9222 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 4 Sep 2016 01:32:43 +0200 Subject: [PATCH 081/208] Improve voluptuous and login errors for Asus device tracker (#3170) --- .../components/device_tracker/asuswrt.py | 43 +++++++++++++------ homeassistant/helpers/config_validation.py | 2 +- .../components/device_tracker/test_asuswrt.py | 2 +- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index a125607a00f..4fd2771db4f 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -26,19 +26,20 @@ CONF_PROTOCOL = 'protocol' CONF_MODE = 'mode' CONF_SSH_KEY = 'ssh_key' CONF_PUB_KEY = 'pub_key' +SECRET_GROUP = 'Password or SSH Key' PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY), PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), - vol.Optional(CONF_SSH_KEY): cv.isfile, - vol.Optional(CONF_PUB_KEY): cv.isfile + vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, + vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile })) @@ -101,6 +102,21 @@ class AsusWrtDeviceScanner(object): self.protocol = config[CONF_PROTOCOL] self.mode = config[CONF_MODE] + if self.protocol == 'ssh': + if self.ssh_key: + self.ssh_secret = {'ssh_key': self.ssh_key} + elif self.password: + self.ssh_secret = {'password': self.password} + else: + _LOGGER.error('No password or private key specified') + self.success_init = False + return + else: + if not self.password: + _LOGGER.error('No password specified') + self.success_init = False + return + self.lock = threading.Lock() self.last_results = {} @@ -149,15 +165,17 @@ class AsusWrtDeviceScanner(object): """Retrieve data from ASUSWRT via the ssh protocol.""" from pexpect import pxssh, exceptions + ssh = pxssh.pxssh() + try: + ssh.login(self.host, self.username, **self.ssh_secret) + except exceptions.EOF as err: + _LOGGER.error('Connection refused. Is SSH enabled?') + return None + except pxssh.ExceptionPxssh as err: + _LOGGER.error('Unable to connect via SSH: %s', str(err)) + return None + try: - ssh = pxssh.pxssh() - if self.ssh_key: - ssh.login(self.host, self.username, ssh_key=self.ssh_key) - elif self.password: - ssh.login(self.host, self.username, self.password) - else: - _LOGGER.error('No password or private key specified') - return None ssh.sendline(_IP_NEIGH_CMD) ssh.prompt() neighbors = ssh.before.split(b'\n')[1:-1] @@ -178,9 +196,6 @@ class AsusWrtDeviceScanner(object): except pxssh.ExceptionPxssh as exc: _LOGGER.error('Unexpected response from router: %s', exc) return None - except exceptions.EOF: - _LOGGER.error('Connection refused or no route to host') - return None def telnet_connection(self): """Retrieve data from ASUSWRT via the telnet protocol.""" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index c9a6917bb01..1e67effb97f 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -70,7 +70,7 @@ def isfile(value: Any) -> str: """Validate that the value is an existing file.""" if value is None: raise vol.Invalid('None is not file') - file_in = str(value) + file_in = os.path.expanduser(str(value)) if not os.path.isfile(file_in): raise vol.Invalid('not a file') diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index fc03426a7a1..a4d5ee64b32 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -138,7 +138,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) asuswrt.ssh_connection() ssh.login.assert_called_once_with('fake_host', 'fake_user', - 'fake_pass') + password='fake_pass') def test_ssh_login_without_password_or_pubkey(self): \ # pylint: disable=invalid-name From 68ef55a98214e33f331fd603ed6762cff64bdc24 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 Sep 2016 19:41:38 -0400 Subject: [PATCH 082/208] Add exclude option to nmap device tracker (#2983) * Add exclude option to nmap device tracker Adds an optional exclude paramater to nmap device tracker. Devices specified in the exclude list will never be scanned by nmap. This can help to reduce log spam. ex: ``` device_tracker: - platform: nmap_tracker hosts: 10.0.0.1/24 home_interval: 1 interval_seconds: 12 consider_home: 120 track_new_devices: yes exclude: - 10.0.0.2 - 10.0.0.1 ``` * Handle optional exclude * Style fixed --- .../components/device_tracker/nmap_tracker.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 7b9f2e9036b..e23d5f31145 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -22,7 +22,8 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) # Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" +CONF_HOME_INTERVAL = 'home_interval' +CONF_EXCLUDE = 'exclude' REQUIREMENTS = ['python-nmap==0.6.1'] @@ -60,6 +61,7 @@ class NmapDeviceScanner(object): self.last_results = [] self.hosts = config[CONF_HOSTS] + self.exclude = config.get(CONF_EXCLUDE, []) minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0) self.home_interval = timedelta(minutes=minutes) @@ -93,7 +95,8 @@ class NmapDeviceScanner(object): from nmap import PortScanner, PortScannerError scanner = PortScanner() - options = "-F --host-timeout 5s" + options = "-F --host-timeout 5s " + exclude = "--exclude " if self.home_interval: boundary = dt_util.now() - self.home_interval @@ -102,10 +105,16 @@ class NmapDeviceScanner(object): if last_results: # Pylint is confused here. # pylint: disable=no-member - options += " --exclude {}".format(",".join(device.ip for device - in last_results)) + exclude_hosts = self.exclude + [device.ip for device + in last_results] + else: + exclude_hosts = self.exclude else: last_results = [] + exclude_hosts = self.exclude + if exclude_hosts: + exclude = " --exclude {}".format(",".join(exclude_hosts)) + options += exclude try: result = scanner.scan(hosts=self.hosts, arguments=options) From 269e97c6dead5eb3ded24c417359aa4d1ee6dafe Mon Sep 17 00:00:00 2001 From: Heiko Rothe Date: Sun, 4 Sep 2016 01:43:33 +0200 Subject: [PATCH 083/208] Added Xbox Live component (#3013) * Added Xbox Live component * Added Xbox Live sensor to coveralls * Added init success checks * Added entity id --- .coveragerc | 1 + homeassistant/components/sensor/xbox_live.py | 112 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 116 insertions(+) create mode 100644 homeassistant/components/sensor/xbox_live.py diff --git a/.coveragerc b/.coveragerc index 0c8647a9ed7..06b8d219d21 100644 --- a/.coveragerc +++ b/.coveragerc @@ -251,6 +251,7 @@ omit = homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py homeassistant/components/sensor/worldclock.py + homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/switch/acer_projector.py homeassistant/components/switch/arest.py diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py new file mode 100644 index 00000000000..90983e1df83 --- /dev/null +++ b/homeassistant/components/sensor/xbox_live.py @@ -0,0 +1,112 @@ +""" +Sensor for Xbox Live account status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.xbox_live/ +""" +import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:xbox' + +REQUIREMENTS = ['xboxapi==0.1.1'] + +CONF_XUID = 'xuid' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Xbox platform.""" + from xboxapi import xbox_api + api = xbox_api.XboxApi(config.get(CONF_API_KEY)) + devices = [] + + for xuid in config.get(CONF_XUID): + new_device = XboxSensor(hass, api, xuid) + if new_device.success_init: + devices.append(new_device) + + if len(devices) > 0: + add_devices(devices) + else: + return False + + +# pylint: disable=too-many-instance-attributes +class XboxSensor(Entity): + """A class for the Xbox account.""" + + def __init__(self, hass, api, xuid): + """Initialize the sensor.""" + self._hass = hass + self._state = STATE_UNKNOWN + self._presence = {} + self._xuid = xuid + self._api = api + + # get profile info + profile = self._api.get_user_profile(self._xuid) + + if profile.get('success', True) \ + and profile.get('code', 0) != 28: + self.success_init = True + self._gamertag = profile.get('Gamertag') + self._picture = profile.get('GameDisplayPicRaw') + else: + self.success_init = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._gamertag + + @property + def entity_id(self): + """Return the entity ID.""" + return 'sensor.xbox_' + self._gamertag + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = {} + for device in self._presence: + for title in device.get('titles'): + attributes[ + '{} {}'.format(device.get('type'), + title.get('placement')) + ] = title.get('name') + + return attributes + + @property + def entity_picture(self): + """Avatar of the account.""" + return self._picture + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + + def update(self): + """Update state data from Xbox API.""" + presence = self._api.get_user_presence(self._xuid) + self._state = presence.get('state', STATE_UNKNOWN) + self._presence = presence.get('devices', {}) diff --git a/requirements_all.txt b/requirements_all.txt index 4852dd7d7d6..98c912ab41e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -494,6 +494,9 @@ websocket-client==0.37.0 # homeassistant.components.zigbee xbee-helper==0.0.7 +# homeassistant.components.sensor.xbox_live +xboxapi==0.1.1 + # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.yr xmltodict==0.10.2 From 09b53a0d5582c241c5fdd68a442df13e7f1614fd Mon Sep 17 00:00:00 2001 From: Steven Barnes Date: Sat, 3 Sep 2016 18:44:30 -0500 Subject: [PATCH 084/208] Adding link_names to post.message call (#3167) If you do not turn link_names on, Slack will not highlight @channel and @username messages. --- homeassistant/components/notify/slack.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index e49862b06da..10564609390 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -58,14 +58,12 @@ class SlackNotificationService(BaseNotificationService): channel = kwargs.get('target') or self._default_channel data = kwargs.get('data') - if data: - attachments = data.get('attachments') - else: - attachments = None + attachments = data.get('attachments') if data else None try: self.slack.chat.post_message(channel, message, as_user=True, - attachments=attachments) + attachments=attachments, + link_names=True) except slacker.Error as err: _LOGGER.error("Could not send slack notification. Error: %s", err) From 0198ba4eac61933dbb96bf19ca1122cdf129f86e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 01:45:31 +0200 Subject: [PATCH 085/208] Allow https (fixes #3150) (#3155) --- homeassistant/components/sensor/sabnzbd.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index a11d65d22bf..0f33a39bbcc 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES) + CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES, + CONF_SSL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -25,6 +26,7 @@ _THROTTLED_REFRESH = None DEFAULT_NAME = 'SABnzbd' DEFAULT_PORT = 8080 +DEFAULT_SSL = False MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) @@ -44,10 +46,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }) -# pylint: disable=unused-argument +# pylint: disable=unused-argument, too-many-locals def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the SABnzbd sensors.""" from pysabnzbd import SabnzbdApi, SabnzbdApiException @@ -57,14 +60,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) api_key = config.get(CONF_API_KEY) monitored_types = config.get(CONF_MONITORED_VARIABLES) - base_url = "http://{}:{}/".format(host, port) + use_ssl = config.get(CONF_SSL) + + if use_ssl: + uri_scheme = 'https://' + else: + uri_scheme = 'http://' + + base_url = "{}{}:{}/".format(uri_scheme, host, port) sab_api = SabnzbdApi(base_url, api_key) try: sab_api.check_available() except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed") + _LOGGER.error("Connection to SABnzbd API failed") return False # pylint: disable=global-statement From 290ec9b4acc0fa15b41f8c09e010221a60e91a68 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 01:45:49 +0200 Subject: [PATCH 086/208] Use constants (#3156) --- homeassistant/components/binary_sensor/rest.py | 10 ++++++---- homeassistant/components/sensor/rest.py | 12 +++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index 4a6e48ca5a3..71666b91d06 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -13,12 +13,15 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.sensor.rest import RestData from homeassistant.const import ( CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_SENSOR_CLASS) + CONF_SENSOR_CLASS, CONF_VERIFY_SSL) from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv +_LOGGER = logging.getLogger(__name__) + DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Binary Sensor' +DEFAULT_VERIFY_SSL = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -27,10 +30,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) -_LOGGER = logging.getLogger(__name__) - # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): @@ -39,7 +41,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get('verify_ssl', True) + verify_ssl = config.get(CONF_VERIFY_SSL) sensor_class = config.get(CONF_SENSOR_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 022477d77a9..def47c79f4d 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -12,13 +12,16 @@ import requests from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) + CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VERIFY_SSL) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv +_LOGGER = logging.getLogger(__name__) + DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' +DEFAULT_VERIFY_SSL = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -27,10 +30,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) -_LOGGER = logging.getLogger(__name__) - # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): @@ -39,7 +41,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get('verify_ssl', True) + verify_ssl = config.get(CONF_VERIFY_SSL) unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) @@ -58,7 +60,7 @@ class RestSensor(Entity): """Implementation of a REST sensor.""" def __init__(self, hass, rest, name, unit_of_measurement, value_template): - """Initialize the sensor.""" + """Initialize the REST sensor.""" self._hass = hass self.rest = rest self._name = name From db7f6a328feaba2231d9f49f418a2e438cb72c33 Mon Sep 17 00:00:00 2001 From: Open Home Automation Date: Sun, 4 Sep 2016 01:47:11 +0200 Subject: [PATCH 087/208] Bugfix: ctach Runtime errors (#3153) "RuntimeError: Disable scan failed" has been seen in a live installation --- .../components/device_tracker/bluetooth_le_tracker.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index ce8a535ff57..a9b95cf6a6b 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -58,10 +58,13 @@ def setup_scanner(hass, config, see): def discover_ble_devices(): """Discover Bluetooth LE devices.""" _LOGGER.debug("Discovering Bluetooth LE devices") - service = DiscoveryService() - devices = service.discover(duration) - _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) - + try: + service = DiscoveryService() + devices = service.discover(duration) + _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) + except RuntimeError as error: + _LOGGER.error("Error during Bluetooth LE scan: %s", error) + devices = [] return devices yaml_path = hass.config.path(YAML_DEVICES) From 02960ec482a83b2a8ed880840ef3a151d90c2602 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:09:02 +0200 Subject: [PATCH 088/208] Migrate to voluptuous (#3166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/switch/tplink.py | 28 +++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index a1de3621b9a..ddb10e74c37 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -4,25 +4,35 @@ Support for TPLink HS100/HS110 smart switch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.tplink/ """ -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import ( - CONF_HOST, CONF_NAME) +import logging + +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_NAME) +import homeassistant.helpers.config_validation as cv -# constants -DEVICE_DEFAULT_NAME = 'HS100' REQUIREMENTS = ['https://github.com/gadgetreactor/pyHS100/archive/' 'master.zip#pyHS100==0.1.2'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'TPLink Switch HS100' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the TPLink switch platform.""" from pyHS100.pyHS100 import SmartPlug host = config.get(CONF_HOST) - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + name = config.get(CONF_NAME) - add_devices_callback([SmartPlugSwitch(SmartPlug(host), - name)]) + add_devices([SmartPlugSwitch(SmartPlug(host), name)]) class SmartPlugSwitch(SwitchDevice): From 2aab77a4866e3a2b0144c66dd3094d323ae4b684 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:14:28 +0200 Subject: [PATCH 089/208] Migrate to voluptuous (#3164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/media_player/cast.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 351fb47a368..2b10448b241 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -7,38 +7,49 @@ https://home-assistant.io/components/media_player.cast/ # pylint: disable=import-error import logging +import voluptuous as vol + from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_STOP, MediaPlayerDevice) + SUPPORT_STOP, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pychromecast==0.7.2'] + +_LOGGER = logging.getLogger(__name__) + CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' + +DEFAULT_PORT = 8009 + SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP + KNOWN_HOSTS = [] -DEFAULT_PORT = 8009 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the cast platform.""" import pychromecast - logger = logging.getLogger(__name__) # import CEC IGNORE attributes ignore_cec = config.get(CONF_IGNORE_CEC, []) if isinstance(ignore_cec, list): pychromecast.IGNORE_CEC += ignore_cec else: - logger.error('CEC config "%s" must be a list.', CONF_IGNORE_CEC) + _LOGGER.error('CEC config "%s" must be a list.', CONF_IGNORE_CEC) hosts = [] @@ -49,7 +60,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts = [discovery_info] elif CONF_HOST in config: - hosts = [(config[CONF_HOST], DEFAULT_PORT)] + hosts = [(config.get(CONF_HOST), DEFAULT_PORT)] else: hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts() From 6a2f0fc45675e3dc05975e9b499a4c98c9e07d58 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:18:11 +0200 Subject: [PATCH 090/208] Migrate to voluptuous (#3163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/media_player/cmus.py | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/media_player/cmus.py b/homeassistant/components/media_player/cmus.py index 4726a1fa6a9..dde2e1d28e6 100644 --- a/homeassistant/components/media_player/cmus.py +++ b/homeassistant/components/media_player/cmus.py @@ -6,34 +6,47 @@ https://home-assistant.io/components/media_player.cmus/ """ import logging +import voluptuous as vol + + from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_SEEK, + SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_SEEK, PLATFORM_SCHEMA, MediaPlayerDevice) -from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING, - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT) +from homeassistant.const import ( + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, + CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pycmus==0.1.0'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pycmus==0.1.0'] + +DEFAULT_NAME = 'cmus' +DEFAULT_PORT = 3000 SUPPORT_CMUS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Inclusive(CONF_HOST, 'remote'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'remote'): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discover_info=None): """Setup the CMUS platform.""" from pycmus import exceptions - host = config.get(CONF_HOST, None) - password = config.get(CONF_PASSWORD, None) - port = config.get(CONF_PORT, None) - name = config.get(CONF_NAME, None) - if host and not password: - _LOGGER.error("A password must be set if using a remote cmus server") - return False + host = config.get(CONF_HOST) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + try: cmus_remote = CmusDevice(host, password, port, name) except exceptions.InvalidPassword: @@ -43,7 +56,7 @@ def setup_platform(hass, config, add_devices, discover_info=None): class CmusDevice(MediaPlayerDevice): - """Representation of a running CMUS.""" + """Representation of a running cmus.""" # pylint: disable=no-member, too-many-public-methods, abstract-method def __init__(self, server, password, port, name): @@ -51,13 +64,12 @@ class CmusDevice(MediaPlayerDevice): from pycmus import remote if server: - port = port or 3000 - self.cmus = remote.PyCmus(server=server, password=password, - port=port) - auto_name = "cmus-%s" % server + self.cmus = remote.PyCmus( + server=server, password=password, port=port) + auto_name = 'cmus-{}'.format(server) else: self.cmus = remote.PyCmus() - auto_name = "cmus-local" + auto_name = 'cmus-local' self._name = name or auto_name self.status = {} self.update() From 3b1c0a75028d44e349acd26200c2f6b8aa3b1515 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:20:45 +0200 Subject: [PATCH 091/208] Migrate to voluptuous (#3162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 and 🍪 for fixing quotes! --- .../components/media_player/denon.py | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index b4bcc9ae5ba..121fb6ae8b8 100644 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -7,32 +7,34 @@ https://home-assistant.io/components/media_player.denon/ import logging import telnetlib +import voluptuous as vol + from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) -from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Music station' + SUPPORT_DENON = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Denon platform.""" - if not config.get(CONF_HOST): - _LOGGER.error( - "Missing required configuration items in %s: %s", - DOMAIN, - CONF_HOST) - return False + denon = DenonDevice(config.get(CONF_NAME), config.get(CONF_HOST)) - denon = DenonDevice( - config.get("name", "Music station"), - config.get("host") - ) if denon.update(): add_devices([denon]) return True @@ -48,21 +50,21 @@ class DenonDevice(MediaPlayerDevice): """Initialize the Denon device.""" self._name = name self._host = host - self._pwstate = "PWSTANDBY" + self._pwstate = 'PWSTANDBY' self._volume = 0 self._muted = False - self._mediasource = "" + self._mediasource = '' @classmethod def telnet_request(cls, telnet, command): """Execute `command` and return the response.""" - telnet.write(command.encode("ASCII") + b"\r") - return telnet.read_until(b"\r", timeout=0.2).decode("ASCII").strip() + telnet.write(command.encode('ASCII') + b'\r') + return telnet.read_until(b'\r', timeout=0.2).decode('ASCII').strip() def telnet_command(self, command): """Establish a telnet connection and sends `command`.""" telnet = telnetlib.Telnet(self._host) - telnet.write(command.encode("ASCII") + b"\r") + telnet.write(command.encode('ASCII') + b'\r') telnet.read_very_eager() # skip response telnet.close() @@ -73,14 +75,14 @@ class DenonDevice(MediaPlayerDevice): except ConnectionRefusedError: return False - self._pwstate = self.telnet_request(telnet, "PW?") + self._pwstate = self.telnet_request(telnet, 'PW?') # PW? sends also SISTATUS, which is not interesting telnet.read_until(b"\r", timeout=0.2) - volume_str = self.telnet_request(telnet, "MV?")[len("MV"):] + volume_str = self.telnet_request(telnet, 'MV?')[len('MV'):] self._volume = int(volume_str) / 60 - self._muted = (self.telnet_request(telnet, "MU?") == "MUON") - self._mediasource = self.telnet_request(telnet, "SI?")[len("SI"):] + self._muted = (self.telnet_request(telnet, 'MU?') == 'MUON') + self._mediasource = self.telnet_request(telnet, 'SI?')[len('SI'):] telnet.close() return True @@ -93,9 +95,9 @@ class DenonDevice(MediaPlayerDevice): @property def state(self): """Return the state of the device.""" - if self._pwstate == "PWSTANDBY": + if self._pwstate == 'PWSTANDBY': return STATE_OFF - if self._pwstate == "PWON": + if self._pwstate == 'PWON': return STATE_ON return STATE_UNKNOWN @@ -122,41 +124,41 @@ class DenonDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" - self.telnet_command("PWSTANDBY") + self.telnet_command('PWSTANDBY') def volume_up(self): """Volume up media player.""" - self.telnet_command("MVUP") + self.telnet_command('MVUP') def volume_down(self): """Volume down media player.""" - self.telnet_command("MVDOWN") + self.telnet_command('MVDOWN') def set_volume_level(self, volume): """Set volume level, range 0..1.""" # 60dB max - self.telnet_command("MV" + str(round(volume * 60)).zfill(2)) + self.telnet_command('MV' + str(round(volume * 60)).zfill(2)) def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self.telnet_command("MU" + ("ON" if mute else "OFF")) + self.telnet_command('MU' + ('ON' if mute else 'OFF')) def media_play(self): """Play media media player.""" - self.telnet_command("NS9A") + self.telnet_command('NS9A') def media_pause(self): """Pause media player.""" - self.telnet_command("NS9B") + self.telnet_command('NS9B') def media_next_track(self): """Send the next track command.""" - self.telnet_command("NS9D") + self.telnet_command('NS9D') def media_previous_track(self): """Send the previous track command.""" - self.telnet_command("NS9E") + self.telnet_command('NS9E') def turn_on(self): """Turn the media player on.""" - self.telnet_command("PWON") + self.telnet_command('PWON') From 34ba4d3e09a281ebac0c8d9b305f0dfda829e404 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 4 Sep 2016 04:21:19 +0200 Subject: [PATCH 092/208] Exclude www_static from pydocstyle linting (#3175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index 98a4f54d55d..6d952083a31 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,3 +7,6 @@ norecursedirs = .git testing_config [flake8] exclude = .venv,.git,.tox,docs,www_static,venv,bin,lib,deps,build + +[pydocstyle] +match_dir = ^((?!\.|www_static).)*$ From 6f45906edaaf6a0e0b68924c06b4aebdc0086f86 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:21:59 +0200 Subject: [PATCH 093/208] Migrate to voluptuous (#3174) --- homeassistant/components/sensor/steam_online.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index a94eed9702e..ed12d4f7844 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -4,12 +4,24 @@ Sensor for Steam account status. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.steam_online/ """ +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['steamodd==4.21'] + +CONF_ACCOUNTS = 'accounts' ICON = 'mdi:steam' -REQUIREMENTS = ['steamodd==4.21'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ACCOUNTS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) # pylint: disable=unused-argument @@ -19,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): steamod.api.key.set(config.get(CONF_API_KEY)) add_devices( [SteamSensor(account, - steamod) for account in config.get('accounts', [])]) + steamod) for account in config.get(CONF_ACCOUNTS)]) class SteamSensor(Entity): From 8467d07a3d60aeb160c0f130573542befab7fcec Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:24:29 +0200 Subject: [PATCH 094/208] Migrate to voluptuous (#3171) --- .../components/sensor/mold_indicator.py | 95 ++++++++----------- tests/components/sensor/test_moldindicator.py | 13 +-- 2 files changed, 45 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index 4e59cd2cd62..b8f635ec593 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -7,48 +7,51 @@ https://home-assistant.io/components/sensor.mold_indicator/ import logging import math +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.util as util from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_state_change -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_NAME) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Mold Indicator" -CONF_INDOOR_TEMP = "indoor_temp_sensor" -CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" -CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" -CONF_CALIBRATION_FACTOR = "calibration_factor" +DEFAULT_NAME = 'Mold Indicator' +CONF_INDOOR_TEMP = 'indoor_temp_sensor' +CONF_OUTDOOR_TEMP = 'outdoor_temp_sensor' +CONF_INDOOR_HUMIDITY = 'indoor_humidity_sensor' +CONF_CALIBRATION_FACTOR = 'calibration_factor' MAGNUS_K2 = 17.62 MAGNUS_K3 = 243.12 -ATTR_DEWPOINT = "Dewpoint" -ATTR_CRITICAL_TEMP = "Est. Crit. Temp" +ATTR_DEWPOINT = 'Dewpoint' +ATTR_CRITICAL_TEMP = 'Est. Crit. Temp' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_INDOOR_TEMP): cv.entity_id, + vol.Required(CONF_OUTDOOR_TEMP): cv.entity_id, + vol.Required(CONF_INDOOR_HUMIDITY): cv.entity_id, + vol.Optional(CONF_CALIBRATION_FACTOR): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MoldIndicator sensor.""" - name = config.get('name', DEFAULT_NAME) + name = config.get(CONF_NAME, DEFAULT_NAME) indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) outdoor_temp_sensor = config.get(CONF_OUTDOOR_TEMP) indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) - calib_factor = util.convert(config.get(CONF_CALIBRATION_FACTOR), - float, None) + calib_factor = config.get(CONF_CALIBRATION_FACTOR) - if None in (indoor_temp_sensor, - outdoor_temp_sensor, indoor_humidity_sensor): - _LOGGER.error('Missing required key %s, %s or %s', - CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP, - CONF_INDOOR_HUMIDITY) - return False - - add_devices_callback([MoldIndicator( - hass, name, indoor_temp_sensor, - outdoor_temp_sensor, indoor_humidity_sensor, - calib_factor)]) + add_devices([MoldIndicator( + hass, name, indoor_temp_sensor, outdoor_temp_sensor, + indoor_humidity_sensor, calib_factor)]) # pylint: disable=too-many-instance-attributes @@ -83,16 +86,14 @@ class MoldIndicator(Entity): indoor_hum = hass.states.get(indoor_humidity_sensor) if indoor_temp: - self._indoor_temp = \ - MoldIndicator._update_temp_sensor(indoor_temp) + self._indoor_temp = MoldIndicator._update_temp_sensor(indoor_temp) if outdoor_temp: - self._outdoor_temp = \ - MoldIndicator._update_temp_sensor(outdoor_temp) + self._outdoor_temp = MoldIndicator._update_temp_sensor( + outdoor_temp) if indoor_hum: - self._indoor_hum = \ - MoldIndicator._update_hum_sensor(indoor_hum) + self._indoor_hum = MoldIndicator._update_hum_sensor(indoor_hum) self.update() @@ -130,19 +131,13 @@ class MoldIndicator(Entity): state.state) return None - # check unit - if unit != "%": - _LOGGER.error( - "Humidity sensor has unsupported unit: %s %s", - unit, - " (allowed: %)") + if unit != '%': + _LOGGER.error("Humidity sensor has unsupported unit: %s %s", + unit, " (allowed: %)") - # check range if hum > 100 or hum < 0: - _LOGGER.error( - "Humidity sensor out of range: %s %s", - hum, - " (allowed: 0-100%)") + _LOGGER.error("Humidity sensor out of range: %s %s", hum, + " (allowed: 0-100%)") return hum @@ -162,15 +157,10 @@ class MoldIndicator(Entity): return if entity_id == self._indoor_temp_sensor: - # update the indoor temp sensor self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) - elif entity_id == self._outdoor_temp_sensor: - # update outdoor temp sensor self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) - elif entity_id == self._indoor_humidity_sensor: - # update humidity self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) self.update() @@ -206,9 +196,8 @@ class MoldIndicator(Entity): self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ self._calib_factor - _LOGGER.debug( - "Estimated Critical Temperature: %f " + - TEMP_CELSIUS, self._crit_temp) + _LOGGER.debug("Estimated Critical Temperature: %f " + + TEMP_CELSIUS, self._crit_temp) # Then calculate the humidity at this point alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) @@ -242,7 +231,7 @@ class MoldIndicator(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" + return '%' @property def state(self): @@ -260,9 +249,7 @@ class MoldIndicator(Entity): else: return { ATTR_DEWPOINT: - util.temperature.celsius_to_fahrenheit( - self._dewpoint), + util.temperature.celsius_to_fahrenheit(self._dewpoint), ATTR_CRITICAL_TEMP: - util.temperature.celsius_to_fahrenheit( - self._crit_temp), + util.temperature.celsius_to_fahrenheit(self._crit_temp), } diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py index da2798e2a4d..c634c043db5 100644 --- a/tests/components/sensor/test_moldindicator.py +++ b/tests/components/sensor/test_moldindicator.py @@ -36,7 +36,7 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) @@ -59,13 +59,11 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) moldind = self.hass.states.get('sensor.mold_indicator') assert moldind - - # assert state assert moldind.state == '0' def test_calculation(self): @@ -76,7 +74,7 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) @@ -108,23 +106,20 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) - # Change indoor temp self.hass.states.set('test.indoortemp', '30', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.pool.block_till_done() assert self.hass.states.get('sensor.mold_indicator').state == '90' - # Change outdoor temp self.hass.states.set('test.outdoortemp', '25', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.pool.block_till_done() assert self.hass.states.get('sensor.mold_indicator').state == '57' - # Change humidity self.hass.states.set('test.indoorhumidity', '20', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.pool.block_till_done() From 3c615e2319b49b677b6c55050d8def91eff7aae4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:32:35 +0200 Subject: [PATCH 095/208] Use voluptuous for mFi switch (#3168) * Migrate to voluptuous * Take change configuration into account --- homeassistant/components/sensor/mfi.py | 40 ++++++++++++---------- homeassistant/components/switch/mfi.py | 46 +++++++++++++++----------- tests/components/sensor/test_mfi.py | 37 ++++++++------------- tests/components/switch/test_mfi.py | 4 +-- 4 files changed, 65 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index 90d07811304..1ba4cf9d5e0 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -7,23 +7,30 @@ https://home-assistant.io/components/sensor.mfi/ import logging import requests +import voluptuous as vol -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, STATE_ON, STATE_OFF, CONF_HOST) -from homeassistant.helpers import validate_config + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, STATE_ON, STATE_OFF, CONF_HOST, + CONF_SSL, CONF_VERIFY_SSL, CONF_PORT) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['mficlient==0.3.0'] _LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 6443 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = True + DIGITS = { 'volts': 1, 'amps': 1, 'active_power': 0, 'temperature': 1, } + SENSOR_MODELS = [ 'Ubiquiti mFi-THS', 'Ubiquiti mFi-CS', @@ -31,28 +38,27 @@ SENSOR_MODELS = [ 'Input Analog', 'Input Digital', ] -CONF_TLS = 'use_tls' -CONF_VERIFY_TLS = 'verify_tls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup mFi sensors.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_HOST, - CONF_USERNAME, - CONF_PASSWORD]}, - _LOGGER): - _LOGGER.error('A host, username, and password are required') - return False - host = config.get(CONF_HOST) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - use_tls = bool(config.get(CONF_TLS, True)) - verify_tls = bool(config.get(CONF_VERIFY_TLS, True)) - default_port = use_tls and 6443 or 6080 - port = int(config.get('port', default_port)) + use_tls = config.get(CONF_SSL) + verify_tls = config.get(CONF_VERIFY_SSL) + default_port = use_tls and DEFAULT_PORT or 6080 + port = int(config.get(CONF_PORT, default_port)) from mficlient.client import FailedToLogin, MFiClient diff --git a/homeassistant/components/switch/mfi.py b/homeassistant/components/switch/mfi.py index cca59111495..48e4741e770 100644 --- a/homeassistant/components/switch/mfi.py +++ b/homeassistant/components/switch/mfi.py @@ -7,43 +7,49 @@ https://home-assistant.io/components/switch.mfi/ import logging import requests +import voluptuous as vol -from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, CONF_SSL, + CONF_VERIFY_SSL) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['mficlient==0.3.0'] _LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 6443 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = True + SWITCH_MODELS = [ 'Outlet', 'Output 5v', 'Output 12v', 'Output 24v', ] -CONF_TLS = 'use_tls' -CONF_VERIFY_TLS = 'verify_tls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup mFi sensors.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['host', - CONF_USERNAME, - CONF_PASSWORD]}, - _LOGGER): - _LOGGER.error('A host, username, and password are required') - return False - - host = config.get('host') - username = config.get('username') - password = config.get('password') - use_tls = bool(config.get(CONF_TLS, True)) - verify_tls = bool(config.get(CONF_VERIFY_TLS, True)) - default_port = use_tls and 6443 or 6080 - port = int(config.get('port', default_port)) + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + use_tls = config.get(CONF_SSL) + verify_tls = config.get(CONF_VERIFY_SSL) + default_port = use_tls and DEFAULT_PORT or 6080 + port = int(config.get(CONF_PORT, default_port)) from mficlient.client import FailedToLogin, MFiClient diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index 8180ca152f3..f55451ff329 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -24,16 +24,14 @@ class TestMfiSensorSetup(unittest.TestCase): 'port': 6123, 'username': 'user', 'password': 'pass', - 'use_tls': True, - 'verify_tls': True, + 'ssl': True, + 'verify_ssl': True, } } def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 def teardown_method(self, method): """Stop everything that was started.""" @@ -54,9 +52,8 @@ class TestMfiSensorSetup(unittest.TestCase): mock_client.FailedToLogin = Exception() mock_client.MFiClient.side_effect = mock_client.FailedToLogin self.assertFalse( - self.PLATFORM.setup_platform(self.hass, - dict(self.GOOD_CONFIG), - None)) + self.PLATFORM.setup_platform( + self.hass, dict(self.GOOD_CONFIG), None)) @mock.patch('mficlient.client') def test_setup_failed_connect(self, mock_client): @@ -64,9 +61,8 @@ class TestMfiSensorSetup(unittest.TestCase): mock_client.FailedToLogin = Exception() mock_client.MFiClient.side_effect = requests.exceptions.ConnectionError self.assertFalse( - self.PLATFORM.setup_platform(self.hass, - dict(self.GOOD_CONFIG), - None)) + self.PLATFORM.setup_platform( + self.hass, dict(self.GOOD_CONFIG), None)) @mock.patch('mficlient.client.MFiClient') def test_setup_minimum(self, mock_client): @@ -74,9 +70,8 @@ class TestMfiSensorSetup(unittest.TestCase): config = dict(self.GOOD_CONFIG) del config[self.THING]['port'] assert self.COMPONENT.setup(self.hass, config) - mock_client.assert_called_once_with('foo', 'user', 'pass', - port=6443, use_tls=True, - verify=True) + mock_client.assert_called_once_with( + 'foo', 'user', 'pass', port=6443, use_tls=True, verify=True) @mock.patch('mficlient.client.MFiClient') def test_setup_with_port(self, mock_client): @@ -84,21 +79,19 @@ class TestMfiSensorSetup(unittest.TestCase): config = dict(self.GOOD_CONFIG) config[self.THING]['port'] = 6123 assert self.COMPONENT.setup(self.hass, config) - mock_client.assert_called_once_with('foo', 'user', 'pass', - port=6123, use_tls=True, - verify=True) + mock_client.assert_called_once_with( + 'foo', 'user', 'pass', port=6123, use_tls=True, verify=True) @mock.patch('mficlient.client.MFiClient') def test_setup_with_tls_disabled(self, mock_client): """Test setup without TLS.""" config = dict(self.GOOD_CONFIG) del config[self.THING]['port'] - config[self.THING]['use_tls'] = False - config[self.THING]['verify_tls'] = False + config[self.THING]['ssl'] = False + config[self.THING]['verify_ssl'] = False assert self.COMPONENT.setup(self.hass, config) - mock_client.assert_called_once_with('foo', 'user', 'pass', - port=6080, use_tls=False, - verify=False) + mock_client.assert_called_once_with( + 'foo', 'user', 'pass', port=6080, use_tls=False, verify=False) @mock.patch('mficlient.client.MFiClient') @mock.patch('homeassistant.components.sensor.mfi.MfiSensor') @@ -123,8 +116,6 @@ class TestMfiSensor(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.port = mock.MagicMock() self.sensor = mfi.MfiSensor(self.port, self.hass) diff --git a/tests/components/switch/test_mfi.py b/tests/components/switch/test_mfi.py index 5eccb88f2ca..95a1000cc46 100644 --- a/tests/components/switch/test_mfi.py +++ b/tests/components/switch/test_mfi.py @@ -22,6 +22,8 @@ class TestMfiSwitchSetup(test_mfi_sensor.TestMfiSensorSetup): 'port': 6123, 'username': 'user', 'password': 'pass', + 'ssl': True, + 'verify_ssl': True, } } @@ -48,8 +50,6 @@ class TestMfiSwitch(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.port = mock.MagicMock() self.switch = mfi.MfiSwitch(self.port) From b02b008fe5916a85464407cd29a0936b789242b7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 04:36:21 +0200 Subject: [PATCH 096/208] Migrate to voluptuous (#3144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/sensor/eliqonline.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 0a82f5d587c..421940b9c7d 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -7,18 +7,33 @@ https://home-assistant.io/components/sensor.eliqonline/ import logging from urllib.error import URLError -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, STATE_UNKNOWN +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['eliqonline==1.0.12'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['eliqonline==1.0.12'] -DEFAULT_NAME = "ELIQ Online" -UNIT_OF_MEASUREMENT = "W" -ICON = "mdi:speedometer" -CONF_CHANNEL_ID = "channel_id" +CONF_CHANNEL_ID = 'channel_id' + +DEFAULT_NAME = 'ELIQ Online' + +ICON = 'mdi:speedometer' + SCAN_INTERVAL = 60 +UNIT_OF_MEASUREMENT = 'W' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_CHANNEL_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ELIQ Online sensor.""" @@ -28,13 +43,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME, DEFAULT_NAME) channel_id = config.get(CONF_CHANNEL_ID) - if access_token is None: - _LOGGER.error( - "Configuration Error: " - "Please make sure you have configured your access token " - "that can be aquired from https://my.eliq.se/user/settings/api") - return False - api = eliqonline.API(access_token) try: From 4de971725601ed5f630ec103ad01cf5c624ad866 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 3 Sep 2016 21:52:31 -0700 Subject: [PATCH 097/208] Add the occupancy sensor_class (#3176) Such a complicated PR --- homeassistant/components/binary_sensor/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 2f751683265..18e33ffe738 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -27,6 +27,7 @@ SENSOR_CLASSES = [ 'moisture', # Specifically a wetness sensor 'motion', # Motion sensor 'moving', # On means moving, Off means stopped + 'occupancy', # On means occupied, Off means not occupied 'opening', # Door, window, etc. 'power', # Power, over-current, etc 'safety', # Generic on=unsafe, off=safe From 48e6befc13ab7d6e1343981d7b94a571ceefb3d1 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 3 Sep 2016 22:04:23 -0700 Subject: [PATCH 098/208] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../components/frontend/www_static/core.js.gz | Bin 32161 -> 32161 bytes .../frontend/www_static/frontend.html | 2 +- .../frontend/www_static/frontend.html.gz | Bin 124626 -> 124636 bytes .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-dev-event.html.gz | Bin 2639 -> 2639 bytes .../panels/ha-panel-dev-info.html.gz | Bin 1308 -> 1308 bytes .../panels/ha-panel-dev-service.html.gz | Bin 2824 -> 2824 bytes .../panels/ha-panel-dev-state.html.gz | Bin 2772 -> 2772 bytes .../panels/ha-panel-dev-template.html.gz | Bin 7290 -> 7290 bytes .../panels/ha-panel-history.html.gz | Bin 6842 -> 6842 bytes .../www_static/panels/ha-panel-iframe.html.gz | Bin 403 -> 403 bytes .../panels/ha-panel-logbook.html.gz | Bin 7344 -> 7344 bytes .../www_static/panels/ha-panel-map.html.gz | Bin 43920 -> 43920 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2279 -> 2279 bytes .../www_static/webcomponents-lite.min.js.gz | Bin 12355 -> 12355 bytes 17 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 6e6022942c6..3e635091aaf 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -2,7 +2,7 @@ FINGERPRINTS = { "core.js": "1fd10c1fcdf56a61f60cf861d5a0368c", - "frontend.html": "9d320762aaad836156219016e2e95093", + "frontend.html": "1903f9cc2ad4ed725c81f544e53d2ee4", "mdi.html": "710b84acc99b32514f52291aba9cd8e8", "panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b", "panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169", diff --git a/homeassistant/components/frontend/www_static/core.js.gz b/homeassistant/components/frontend/www_static/core.js.gz index 2a5768f9d84837492e7d46a555ee5eb2af54fb33..29ce0de683238a9ca18c6b900a7e5785f6afd1d1 100644 GIT binary patch delta 17 ZcmZ4Zn{nZ9MmG6w4i4wl8`);p0suf?2DktK delta 17 ZcmZ4Zn{nZ9MmG6w4vq`a8`);p0sulC2K4{{ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 9ae8f2e9a79..2b7e3f23be0 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 09511ab2c4ca71bb0a1b38699072925814c512a5..aff3fd48b4245aa7046d67544be495b39656463f 100644 GIT binary patch delta 23118 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 delta 23108 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 delta 15 WcmX>va$bZ@zMF&NLi9#9Pc8r{rv$tJ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index 7d48c5e889af5cbf0f095c9e2da0ead98e21e45c..4a0cb5d16e79224b8e5ea8210e523288dab0c93e 100644 GIT binary patch delta 15 WcmbQkHHV8$zMF%?dG$s%F;)N|<^#kwm;@8;leUcHfxi5mbP%mc3g delta 15 WcmeAW>kwm;@8;mR5WSI&i5mbRiUaHb diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index d7e5dc77812bd96a7eb91f4acc78b2a5807c9b3a..0fd2a7754eac377708ebfb77434c3cf51ad1e839 100644 GIT binary patch delta 15 Wcmca2dPS5?zMF%?dG$uNb6fx^nFSC4 delta 15 Wcmca2dPS5?zMF&NLi9$qb6fx`R|QP~ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index d68c4570b1a42e03d2efd4ffb42ef8332ce35d30..787c0b11929c098ddbfe4057ede3a9d5c88d85f2 100644 GIT binary patch delta 15 Wcmexm@ymiuzMF%?dG$uN5*Yw6tp!>D delta 15 Wcmexm@ymiuzMF&NLi9$q5*Yw8YXz48 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz index dab12d8ebc7c2e0a6787e77f8eeca811493cb7ae..0e4aaa558a2decf78305013600de0943513220a1 100644 GIT binary patch delta 15 WcmdmGy33SJzMF%?dG$uNEm8m}-31Z= delta 15 WcmdmGy33SJzMF&NLi9$qEm8n0n*~n* diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz index b9c963dcd45b8039ba81498b8facff7deb9962a4..650dfe9f1e7291ff249b2ba8cf3a573a88344adb 100644 GIT binary patch delta 15 WcmbQtJeiqIzMF%?dG$uNUPb^Q9t0o& delta 15 WcmbQtJeiqIzMF&NLi9$qUPb^R+yq?! diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index a88302f4eb5037e82edddff0a34a0e5768757393..3512f61878c2df1a9e061c677facc4c3ff30cb22 100644 GIT binary patch delta 15 WcmdmBxxtc6zMF%?dG$uNRWbl68wCCU delta 15 WcmdmBxxtc6zMF&NLi9$qRWbl7*#$cQ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index a557094acdc5fd5c215ff3dfae175a11c680804b..d0538f72d06b73d091c342b32904c81cc9c1d8ad 100644 GIT binary patch delta 17 ZcmbPmooT{#CN}wQ4i4wl8`-*60{}W>25kTU delta 17 ZcmbPmooT{#CN}wQ4vq`a8`-*60{}cB2C4u6 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 603c32884a0..1ec7b9be402 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","a06024ac6ce6aa5981eb2773361c8dcd"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-1fd10c1fcdf56a61f60cf861d5a0368c.js","800ebb1bbb48274790f2ee1a2e53a24c"],["/static/frontend-9d320762aaad836156219016e2e95093.html","08389b74f3e949b3e1feda0361546f73"],["/static/mdi-710b84acc99b32514f52291aba9cd8e8.html","149c8eaf6bb78a9b642c7bcedab86900"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e["delete"](a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))})["catch"](function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;aCx+7c|i%)%*=SnuPINTHGYcO)|I%B>p#+`ZB!Q3p$ zcr4d8IR4E7%)C~o&WHD(K0E##xPqoVwdnqTqknd9oL6;a5X;eLbk;d__;OjU9UXsb z>&o$8u24u*{I^>>b$$lkKr?Vo%$?_pT3w(1^V9oxfz6z1hF0`JyX4Ja*?_c}gBo-( zo1UFHW5;(AMY5C=K~<7*tumsOWZUVGjhDQFvK=e99D$AbD3mgP z%P5D8utX9qc+7c{sxXXr$ORJ}3aAGd14R^5l%#1YvlK+0FrqT5V-}0(xSpcY^(c&h zDNVT!86-q&ExCpi2+4TPXp(Q#q>zWXl4-(}B$5C=qn>V5rf z$5~D}=7&@nWE;UW$}yl^Cqkr{$Rfd9ON9i*MV91*bb>clqtWfSsYPw-@yG6G^mARR zz!;69RLIbiOoUOAq#EOhmPL$l&J`6T34bK&O);CBwX9UcT4b4~OaMZE5``M!WmL$J zF^pLBBUx`j6|2QsmI0RO@L1j{`Zil?Z56|7qzs1(42OmeKBJ&1}0pFxX6(5QT;9ZHk!{ z5rqv3OHLy&xyl4}&d>dh1K#lkOr7>>v>}DjB^!;L6Ssv@2Mx@B3M7z@VO|`p1g)%* zh#klCi&=#=ij&9(-k?N-6!w-Q4 z$%#)b4SxCf2Kj2zj~z%)$hJIKax}PE3~6EARH!l|hS4E9WQE?c)n)=vWv|ppvzM zFCSmO+62Zm-ib4H9(%(*&zF1V7VQW0{Md&kz>%Z5zJFylubyG&f))qVGji^AqwG;#-|C{;sOq25vk8O!rgR zt@drgEPh5kH|U>LJI4F+{Lb?xojk0&ZZ3agld3{YodV}r97i30*D?sMcwxR&rYPOc zgEqkWuH0>-IDRlVJdQm0bdz`nTrGBke*{i@j=97z?c>=;dUIwv>fq%6!2QT!k3Whw ze+o)__PdAk%;^s`&N+UQ0S7C8_n;R~k&!ns(}#iRGpXyL)f6iOd8r@ECeQG2S6Pmk z9x#_{>BB!Jtd}OI$XuNeIUG6_jtw)LBl|ugD-4GRjo&?UeskRy&)lBtZ}BjBYr8^u zyFQ&T6-uKP}xTQ9N6t--m%IYT@@nj(I>n;v=-U{S@ Lb}0;eITQc@8GV?- delta 1873 zcmV-X2d?<%5$6#GABzYG&|8rPPXZ8pkykVr5B4@J`10`=tERm3?v8(2!PVds7+k?^ zk)tDj?@mq7Klq9pj{n=06WBH1@Y%z4T=LEJ5>DaHf8>+k+m+cd?Lc>Dk%AHopud|h zd@`L*--~Y`O^;sx$_q-UW@bJduD8m%!bq&7shjNw!{MGdUxSfz*BSF=G49N}4(4W2 z#$&m*!SQbvVCJX^kMIS0M5XCjR#msB8dkV`EEi>!-Tnmz%XfyJ`4st6}}uA(^0 zlQ01lk}%^Twk0pt2l)L!c2fv^4~(L0R=QjuM@Qd(s!G7^Rem42w$UxSPc^ z!W?Hg<(MB*Wsq$I(rc&*mZAuwBuO>Kk>_#5I@hQmN%$jCZ;IL6tYxJl)*{O^WddY>Mx#(8yo?GN zGKLY0ekAKHsA9EP3)4VDh-gYunoFj!wvw1oz$PFRpzI$B`v|jD$(yw_E<>GSGRH!2 z9n(C~T+kTdQjkRU)qNe&DrnxH&|9PloQcBCm{xjF*Jy$z_5W~{s6|G z(lkCQC%}JfN?^IiLh%%TwSsjE1eF4qkV%g9vjq|N4D;e(C1_=h zMC>@8U(713QJh3R@CGFsq_DRfc@U{*dN%%TWfm9XbDt~q3lg%W&+FHfg5Rh2Glu15 zOHO=hY4FR(H^^6$e(XScLbm0>lAC#j(t{S65Zn>v$`Vlq^#GqjyLM24^;qKd#y%sHec+6@ z#bmM_?b><)kBxME?OQh;_;y-YzV3pl_M-X6B8Ad=M5dd6hjpx|7qmFP7#ET9j?bU# zJyXXHp}Hth^J5!LfFnn9egDdAUOmIk1uYJ!XXM=LM%kmfzSTvwQPn@AX%F&e&f9s1 zL0={pJ9XmtI4rlH>rHaC9U5;@+x~cUV1R|rOs#=F#~*s`SNDr zTkYF~S^SJWZqPrgc8vGs`JLxYI(b-k-CX|0CRK%)It9+LIF360u4NEh@xpwmOi{X> z2W^1$UAfyvar|I#cpQ1Kb(44oTrKtk{|KD+9CL|b+Q+kx^ybWT)WOOBf%}oe9)A>V z{uGq<>~|06nbRL?oO9fh0S7C8zd&R zc6~azxT>4)&>-92E_hXFE6W}Wv0l8t{sqT`JxDsD+;$oHpv&xbjiVkN`Np1t8)%We z)Yrny|9qj^UfS;HbS+mcHitpA>5)~?t-0O=zu1^zi{Wdhdb92Djr-@qf%xr*RXITQc@4qcjB diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz index 2414f30203e063a5d3ecec4a1a11ee21403a1be0..0fab88a2b1ad540c1401524f899b6bf20414b5ea 100644 GIT binary patch delta 15 WcmX?{a5#ZYzMF%?dG$s%TLS Date: Sun, 4 Sep 2016 10:06:16 +0200 Subject: [PATCH 099/208] Use voluptuous for Unifi, Ubus (#3125) --- .../components/device_tracker/ubus.py | 16 ++--- .../components/device_tracker/unifi.py | 35 +++++------ tests/components/device_tracker/test_unifi.py | 60 +++++++++---------- 3 files changed, 52 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 736c1ba3168..5eaa4bf2fca 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -11,10 +11,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -22,14 +23,15 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and return an ubus scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = UbusDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 2ae3f76e5e6..d654c3e3eef 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -6,10 +6,11 @@ https://home-assistant.io/components/device_tracker.unifi/ """ import logging import urllib +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config # Unifi package doesn't list urllib3 as a requirement REQUIREMENTS = ['urllib3', 'unifi==1.2.5'] @@ -18,28 +19,24 @@ _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default='localhost'): cv.string, + vol.Optional(CONF_SITE_ID, default='default'): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PORT, default=8443): cv.port +}) + def get_scanner(hass, config): """Setup Unifi device_tracker.""" from unifi.controller import Controller - if not validate_config(config, {DOMAIN: [CONF_USERNAME, - CONF_PASSWORD]}, - _LOGGER): - _LOGGER.error('Invalid configuration') - return False - - this_config = config[DOMAIN] - host = this_config.get(CONF_HOST, 'localhost') - username = this_config.get(CONF_USERNAME) - password = this_config.get(CONF_PASSWORD) - site_id = this_config.get(CONF_SITE_ID, 'default') - - try: - port = int(this_config.get(CONF_PORT, 8443)) - except ValueError: - _LOGGER.error('Invalid port (must be numeric like 8443)') - return False + host = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + site_id = config[DOMAIN].get(CONF_SITE_ID) + port = config[DOMAIN].get(CONF_PORT) try: ctrl = Controller(host, username, password, port, 'v4', site_id) diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index e3f64cc84c3..8e43eb7485e 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -3,9 +3,12 @@ import unittest from unittest import mock import urllib -from homeassistant.components.device_tracker import unifi as unifi -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from unifi import controller +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN, unifi as unifi +from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, + CONF_PLATFORM) class TestUnifiScanner(unittest.TestCase): @@ -16,13 +19,14 @@ class TestUnifiScanner(unittest.TestCase): def test_config_minimal(self, mock_ctrl, mock_scanner): """Test the setup with minimal configuration.""" config = { - 'device_tracker': { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', - } + }) } result = unifi.get_scanner(None, config) - self.assertEqual(unifi.UnifiScanner.return_value, result) + self.assertEqual(mock_scanner.return_value, result) mock_ctrl.assert_called_once_with('localhost', 'foo', 'password', 8443, 'v4', 'default') mock_scanner.assert_called_once_with(mock_ctrl.return_value) @@ -32,49 +36,38 @@ class TestUnifiScanner(unittest.TestCase): def test_config_full(self, mock_ctrl, mock_scanner): """Test the setup with full configuration.""" config = { - 'device_tracker': { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', CONF_HOST: 'myhost', 'port': 123, 'site_id': 'abcdef01', - } + }) } result = unifi.get_scanner(None, config) - self.assertEqual(unifi.UnifiScanner.return_value, result) + self.assertEqual(mock_scanner.return_value, result) mock_ctrl.assert_called_once_with('myhost', 'foo', 'password', 123, 'v4', 'abcdef01') mock_scanner.assert_called_once_with(mock_ctrl.return_value) - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_error(self, mock_ctrl, mock_scanner): + def test_config_error(self): """Test for configuration errors.""" - config = { - 'device_tracker': { + with self.assertRaises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + # no username + CONF_PLATFORM: unifi.DOMAIN, CONF_HOST: 'myhost', 'port': 123, - } - } - result = unifi.get_scanner(None, config) - self.assertFalse(result) - self.assertFalse(mock_ctrl.called) - - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_badport(self, mock_ctrl, mock_scanner): - """Test the setup with a bad port.""" - config = { - 'device_tracker': { + }) + with self.assertRaises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', CONF_HOST: 'myhost', - 'port': 'foo', - } - } - result = unifi.get_scanner(None, config) - self.assertFalse(result) - self.assertFalse(mock_ctrl.called) + 'port': 'foo', # bad port! + }) @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') @mock.patch.object(controller, 'Controller') @@ -82,6 +75,7 @@ class TestUnifiScanner(unittest.TestCase): """Test for controller failure.""" config = { 'device_tracker': { + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', } @@ -91,7 +85,7 @@ class TestUnifiScanner(unittest.TestCase): result = unifi.get_scanner(None, config) self.assertFalse(result) - def test_scanner_update(self): + def test_scanner_update(self): # pylint: disable=no-self-use """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ @@ -102,7 +96,7 @@ class TestUnifiScanner(unittest.TestCase): unifi.UnifiScanner(ctrl) ctrl.get_clients.assert_called_once_with() - def test_scanner_update_error(self): + def test_scanner_update_error(self): # pylint: disable=no-self-use """Test the scanner update for error.""" ctrl = mock.MagicMock() ctrl.get_clients.side_effect = urllib.error.HTTPError( From 0f37d8d8eb484552fbf6d64834a9ba1fc3b2d5bd Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 4 Sep 2016 03:04:12 -0700 Subject: [PATCH 100/208] Using alert with Hue maintains prior state (#3147) * When using flash with hue, dont change the on/off state of the light so that it will naturally return to its previous state once flash is complete * ATTR_FLASH not ATTR_EFFECT --- homeassistant/components/light/__init__.py | 1 + homeassistant/components/light/hue.py | 13 +++++++++++++ homeassistant/components/light/services.yaml | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 23afa58b628..f1bc83dfd17 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -98,6 +98,7 @@ LIGHT_TURN_ON_SCHEMA = vol.Schema({ LIGHT_TURN_OFF_SCHEMA = vol.Schema({ ATTR_ENTITY_ID: cv.entity_ids, ATTR_TRANSITION: VALID_TRANSITION, + ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]), }) LIGHT_TOGGLE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index b818f4ee932..3ac7f3ae5f6 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -264,8 +264,10 @@ class HueLight(Light): if flash == FLASH_LONG: command['alert'] = 'lselect' + del command['on'] elif flash == FLASH_SHORT: command['alert'] = 'select' + del command['on'] elif self.bridge_type == 'hue': command['alert'] = 'none' @@ -290,6 +292,17 @@ class HueLight(Light): # 900 seconds. command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10) + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + command['alert'] = 'lselect' + del command['on'] + elif flash == FLASH_SHORT: + command['alert'] = 'select' + del command['on'] + elif self.bridge_type == 'hue': + command['alert'] = 'none' + self.bridge.set_light(self.light_id, command) def update(self): diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 392be490dc3..d6a6931652b 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -60,6 +60,12 @@ turn_off: description: Duration in seconds it takes to get to next state example: 60 + flash: + description: If the light should flash + values: + - short + - long + toggle: description: Toggles a light From 74980d95632ea4e4211e9cc9f2ee488ac1cec159 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 4 Sep 2016 03:15:55 -0700 Subject: [PATCH 101/208] MQTT fan platform (#3095) * Add fan.mqtt, allow brightness to be passed and mapped to a fan speed for compatibility with emulated_hue * Pylint/Flake8 fixes * Remove brightness * Add more features, like custom oscillation/speed payloads and setting the speed list * Flake8 fixes * flake8/pylint fixes * Use constants * block fan.mqtt from coverage * Fix oscillating comment --- .coveragerc | 1 + homeassistant/components/emulated_hue.py | 2 +- homeassistant/components/fan/__init__.py | 1 + homeassistant/components/fan/demo.py | 4 +- homeassistant/components/fan/mqtt.py | 276 +++++++++++++++++++++++ 5 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/fan/mqtt.py diff --git a/.coveragerc b/.coveragerc index 06b8d219d21..8afca0e55a9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -138,6 +138,7 @@ omit = homeassistant/components/device_tracker/ubus.py homeassistant/components/discovery.py homeassistant/components/downloader.py + homeassistant/components/fan/mqtt.py homeassistant/components/feedreader.py homeassistant/components/foursquare.py homeassistant/components/garage_door/rpi_gpio.py diff --git a/homeassistant/components/emulated_hue.py b/homeassistant/components/emulated_hue.py index f7a353d5c7f..d39a1602ec2 100755 --- a/homeassistant/components/emulated_hue.py +++ b/homeassistant/components/emulated_hue.py @@ -44,7 +44,7 @@ DEFAULT_LISTEN_PORT = 8300 DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene'] DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ - 'switch', 'light', 'group', 'input_boolean', 'media_player' + 'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan' ] HUE_API_STATE_ON = 'on' diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 13244569dbb..a129ece3609 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -39,6 +39,7 @@ SERVICE_OSCILLATE = 'oscillate' SPEED_OFF = 'off' SPEED_LOW = 'low' SPEED_MED = 'med' +SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' ATTR_SPEED = 'speed' diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index 83508063fa9..ba2deb83125 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -1,5 +1,5 @@ """ -Demo garage door platform that has a fake fan. +Demo fan platform that has a fake fan. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ @@ -19,7 +19,7 @@ DEMO_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup demo garage door platform.""" + """Setup demo fan platform.""" add_devices_callback([ DemoFan(hass, FAN_NAME, STATE_OFF), ]) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py new file mode 100644 index 00000000000..9d824a715c2 --- /dev/null +++ b/homeassistant/components/fan/mqtt.py @@ -0,0 +1,276 @@ +""" +Support for MQTT fans. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/fan.mqtt/ +""" +import logging +from functools import partial + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.const import (CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, + STATE_ON, STATE_OFF) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import render_with_possible_json_value +from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_MEDIUM, + SPEED_HIGH, FanEntity, + SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, + SPEED_OFF, ATTR_SPEED) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ["mqtt"] + +CONF_STATE_VALUE_TEMPLATE = "state_value_template" +CONF_SPEED_STATE_TOPIC = "speed_state_topic" +CONF_SPEED_COMMAND_TOPIC = "speed_command_topic" +CONF_SPEED_VALUE_TEMPLATE = "speed_value_template" +CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic" +CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic" +CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template" +CONF_PAYLOAD_ON = "payload_on" +CONF_PAYLOAD_OFF = "payload_off" +CONF_PAYLOAD_OSCILLATION_ON = "payload_oscillation_on" +CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off" +CONF_PAYLOAD_LOW_SPEED = "payload_low_speed" +CONF_PAYLOAD_MEDIUM_SPEED = "payload_medium_speed" +CONF_PAYLOAD_HIGH_SPEED = "payload_high_speed" +CONF_SPEED_LIST = "speeds" + +DEFAULT_NAME = "MQTT Fan" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_OPTIMISTIC = False + +OSCILLATE_ON_PAYLOAD = "oscillate_on" +OSCILLATE_OFF_PAYLOAD = "oscillate_off" + +OSCILLATION = "oscillation" + +PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SPEED_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SPEED_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_OSCILLATION_ON, + default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_PAYLOAD_OSCILLATION_OFF, + default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string, + vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MED): cv.string, + vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string, + vol.Optional(CONF_SPEED_LIST, + default=[SPEED_OFF, SPEED_LOW, + SPEED_MED, SPEED_HIGH]): cv.ensure_list, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MQTT fan platform.""" + add_devices_callback([MqttFan( + hass, + config[CONF_NAME], + { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, + CONF_SPEED_STATE_TOPIC, + CONF_SPEED_COMMAND_TOPIC, + CONF_OSCILLATION_STATE_TOPIC, + CONF_OSCILLATION_COMMAND_TOPIC, + ) + }, + { + CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), + OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE) + }, + config[CONF_QOS], + config[CONF_RETAIN], + { + STATE_ON: config[CONF_PAYLOAD_ON], + STATE_OFF: config[CONF_PAYLOAD_OFF], + OSCILLATE_ON_PAYLOAD: config[CONF_PAYLOAD_OSCILLATION_ON], + OSCILLATE_OFF_PAYLOAD: config[CONF_PAYLOAD_OSCILLATION_OFF], + SPEED_LOW: config[CONF_PAYLOAD_LOW_SPEED], + SPEED_MEDIUM: config[CONF_PAYLOAD_MEDIUM_SPEED], + SPEED_HIGH: config[CONF_PAYLOAD_HIGH_SPEED], + }, + config[CONF_SPEED_LIST], + config[CONF_OPTIMISTIC], + )]) + + +# pylint: disable=too-many-instance-attributes +class MqttFan(FanEntity): + """A MQTT fan component.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, topic, templates, qos, retain, payload, + speed_list, optimistic): + """Initialize MQTT fan.""" + self._hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._retain = retain + self._payload = payload + self._speed_list = speed_list + self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + self._optimistic_oscillation = (optimistic or + topic[CONF_OSCILLATION_STATE_TOPIC] + is None) + self._optimistic_speed = (optimistic or + topic[CONF_SPEED_STATE_TOPIC] is None) + self._state = False + self._supported_features = 0 + self._supported_features |= (topic[CONF_OSCILLATION_STATE_TOPIC] + is not None and SUPPORT_OSCILLATE) + self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] + is not None and SUPPORT_SET_SPEED) + + templates = {key: ((lambda value: value) if tpl is None else + partial(render_with_possible_json_value, hass, tpl)) + for key, tpl in templates.items()} + + def state_received(topic, payload, qos): + """A new MQTT message has been received.""" + payload = templates[CONF_STATE](payload) + if payload == self._payload[STATE_ON]: + self._state = True + elif payload == self._payload[STATE_OFF]: + self._state = False + + self.update_ha_state() + + if self._topic[CONF_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, self._topic[CONF_STATE_TOPIC], + state_received, self._qos) + + def speed_received(topic, payload, qos): + """A new MQTT message for the speed has been received.""" + payload = templates[ATTR_SPEED](payload) + if payload == self._payload[SPEED_LOW]: + self._speed = SPEED_LOW + elif payload == self._payload[SPEED_MEDIUM]: + self._speed = SPEED_MED + elif payload == self._payload[SPEED_HIGH]: + self._speed = SPEED_HIGH + self.update_ha_state() + + if self._topic[CONF_SPEED_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, self._topic[CONF_SPEED_STATE_TOPIC], + speed_received, self._qos) + self._speed = SPEED_OFF + elif self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + self._speed = SPEED_OFF + else: + self._speed = SPEED_OFF + + def oscillation_received(topic, payload, qos): + """A new MQTT message has been received.""" + payload = templates[OSCILLATION](payload) + if payload == self._payload[OSCILLATE_ON_PAYLOAD]: + self._oscillation = True + elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: + self._oscillation = False + self.update_ha_state() + + if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, + self._topic[CONF_OSCILLATION_STATE_TOPIC], + oscillation_received, self._qos) + self._oscillation = False + if self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None: + self._oscillation = False + else: + self._oscillation = False + + @property + def should_poll(self): + """No polling needed for a MQTT fan.""" + return False + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def name(self) -> str: + """Get entity name.""" + return self._name + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def speed(self): + """Return the current speed.""" + return self._speed + + @property + def oscillating(self): + """Return the oscillation state.""" + return self._oscillation + + def turn_on(self, speed: str=SPEED_MED) -> None: + """Turn on the entity.""" + mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC], + self._payload[STATE_ON], self._qos, self._retain) + self.set_speed(speed) + + def turn_off(self) -> None: + """Turn off the entity.""" + mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC], + self._payload[STATE_OFF], self._qos, self._retain) + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + mqtt_payload = SPEED_OFF + if speed == SPEED_LOW: + mqtt_payload = self._payload[SPEED_LOW] + elif speed == SPEED_MED: + mqtt_payload = self._payload[SPEED_MEDIUM] + elif speed == SPEED_HIGH: + mqtt_payload = self._payload[SPEED_HIGH] + else: + mqtt_payload = speed + self._speed = speed + mqtt.publish(self._hass, self._topic[CONF_SPEED_COMMAND_TOPIC], + mqtt_payload, self._qos, self._retain) + self.update_ha_state() + + def oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + if self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + self._oscillation = oscillating + mqtt.publish(self._hass, + self._topic[CONF_OSCILLATION_COMMAND_TOPIC], + self._oscillation, self._qos, self._retain) + self.update_ha_state() From 641d531be3b552bc2e0d5344528ff57bcded94b4 Mon Sep 17 00:00:00 2001 From: Ben Bangert Date: Sun, 4 Sep 2016 05:36:44 -0700 Subject: [PATCH 102/208] Add Sphinx API doc generation (#3029) * add's sphinx project to docs/ dir * include core/helpers autodocs for API reference --- .gitignore | 5 +- docs/Makefile | 225 ++++++++++++++++++++++++ docs/build/.empty | 0 docs/make.bat | 281 ++++++++++++++++++++++++++++++ docs/source/api/core.rst | 18 ++ docs/source/api/entity.rst | 12 ++ docs/source/api/event.rst | 20 +++ docs/source/conf.py | 343 +++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 22 +++ requirements_docs.txt | 2 + 10 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 docs/Makefile create mode 100644 docs/build/.empty create mode 100644 docs/make.bat create mode 100644 docs/source/api/core.rst create mode 100644 docs/source/api/entity.rst create mode 100644 docs/source/api/event.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 requirements_docs.txt diff --git a/.gitignore b/.gitignore index b73dcef1073..147d68c36d3 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ virtualization/vagrant/.vagrant virtualization/vagrant/config # Visual Studio Code -.vscode \ No newline at end of file +.vscode + +# Built docs +docs/build \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000000..c2cf05dc0e4 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,225 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Home-Assistant.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Home-Assistant.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Home-Assistant" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Home-Assistant" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/build/.empty b/docs/build/.empty new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000000..7713f1cadb0 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Home-Assistant.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Home-Assistant.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst new file mode 100644 index 00000000000..a32bdc24d11 --- /dev/null +++ b/docs/source/api/core.rst @@ -0,0 +1,18 @@ +.. _core_module: + +:mod:`homeassistant.core` +------------------------- + +.. automodule:: homeassistant.core + +.. autoclass:: Config + :members: + +.. autoclass:: EventBus + :members: + +.. autoclass:: StateMachine + :members: + +.. autoclass:: ServiceRegistry + :members: diff --git a/docs/source/api/entity.rst b/docs/source/api/entity.rst new file mode 100644 index 00000000000..99ae43dc3ae --- /dev/null +++ b/docs/source/api/entity.rst @@ -0,0 +1,12 @@ +.. _helpers_entity_module: + +:mod:`homeassistant.helpers.entity` +----------------------------------- + +.. automodule:: homeassistant.helpers.entity + +.. autoclass:: Entity + :members: + +.. autoclass:: ToggleEntity + :members: diff --git a/docs/source/api/event.rst b/docs/source/api/event.rst new file mode 100644 index 00000000000..b1295b81409 --- /dev/null +++ b/docs/source/api/event.rst @@ -0,0 +1,20 @@ +.. _helpers_event_module: + +:mod:`homeassistant.helpers.event` +---------------------------------- + +.. automodule:: homeassistant.helpers.event + +.. autofunction:: track_state_change + +.. autofunction:: track_point_in_time + +.. autofunction:: track_point_in_utc_time + +.. autofunction:: track_sunrise + +.. autofunction:: track_sunset + +.. autofunction:: track_utc_time_change + +.. autofunction:: track_time_change diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000000..2644130f4c6 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Home-Assistant documentation build configuration file, created by +# sphinx-quickstart on Sun Aug 28 13:13:10 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx_autodoc_typehints', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Home Assistant' +copyright = '2016, Home Assistant Team' +author = 'Home Assistant Team' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.27' +# The full version, including alpha/beta/rc tags. +release = '0.27.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = 'Home-Assistant v0.27.0' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +# html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Home-Assistantdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Home-Assistant.tex', 'Home-Assistant Documentation', + 'Home-Assistant Team', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# It false, will not define \strong, \code, itleref, \crossref ... but only +# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added +# packages. +# +# latex_keep_old_macro_names = True + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'home-assistant', 'Home-Assistant Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Home-Assistant', 'Home-Assistant Documentation', + author, 'Home-Assistant', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000000..a6157dc7aac --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +================================ +Home Assistant API Documentation +================================ + +Public API documentation for `Home Assistant developers`_. + +Contents: + +.. toctree:: + :maxdepth: 2 + :glob: + + api/* + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. _Home Assistant developers: https://home-assistant.io/developers/ diff --git a/requirements_docs.txt b/requirements_docs.txt new file mode 100644 index 00000000000..7ed9c3b77b4 --- /dev/null +++ b/requirements_docs.txt @@ -0,0 +1,2 @@ +Sphinx==1.4.6 +sphinx-autodoc-typehints==1.1.0 From e9813b219e240fcbd67ff6b6e101333ebfe5a35a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 4 Sep 2016 17:15:52 +0200 Subject: [PATCH 103/208] Allow reloading automation without restarting HA (#3002) --- homeassistant/bootstrap.py | 133 ++++++++++-------- .../components/automation/__init__.py | 103 ++++++++++---- .../components/automation/services.yaml | 34 +++++ homeassistant/helpers/entity.py | 4 + homeassistant/helpers/entity_component.py | 60 +++++--- tests/components/automation/test_init.py | 130 +++++++++++++++++ tests/test_bootstrap.py | 8 +- 7 files changed, 365 insertions(+), 107 deletions(-) create mode 100644 homeassistant/components/automation/services.yaml diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4b526c40b38..3e8ed6ad77f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -90,67 +90,12 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: domain, domain) return False + config = prepare_setup_component(hass, config, domain) + + if config is None: + return False + component = loader.get_component(domain) - missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', []) - if dep not in hass.config.components] - - if missing_deps: - _LOGGER.error( - 'Not initializing %s because not all dependencies loaded: %s', - domain, ", ".join(missing_deps)) - return False - - if hasattr(component, 'CONFIG_SCHEMA'): - try: - config = component.CONFIG_SCHEMA(config) - except vol.MultipleInvalid as ex: - log_exception(ex, domain, config) - return False - - elif hasattr(component, 'PLATFORM_SCHEMA'): - platforms = [] - for p_name, p_config in config_per_platform(config, domain): - # Validate component specific platform schema - try: - p_validated = component.PLATFORM_SCHEMA(p_config) - except vol.MultipleInvalid as ex: - log_exception(ex, domain, p_config) - return False - - # Not all platform components follow same pattern for platforms - # So if p_name is None we are not going to validate platform - # (the automation component is one of them) - if p_name is None: - platforms.append(p_validated) - continue - - platform = prepare_setup_platform(hass, config, domain, - p_name) - - if platform is None: - return False - - # Validate platform specific schema - if hasattr(platform, 'PLATFORM_SCHEMA'): - try: - p_validated = platform.PLATFORM_SCHEMA(p_validated) - except vol.MultipleInvalid as ex: - log_exception(ex, '{}.{}'.format(domain, p_name), - p_validated) - return False - - platforms.append(p_validated) - - # Create a copy of the configuration with all config for current - # component removed and add validated config back in. - filter_keys = extract_domain_configs(config, domain) - config = {key: value for key, value in config.items() - if key not in filter_keys} - config[domain] = platforms - - if not _handle_requirements(hass, component, domain): - return False - _CURRENT_SETUP.append(domain) try: @@ -182,6 +127,74 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: return True +def prepare_setup_component(hass: core.HomeAssistant, config: dict, + domain: str): + """Prepare setup of a component and return processed config.""" + # pylint: disable=too-many-return-statements + component = loader.get_component(domain) + missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', []) + if dep not in hass.config.components] + + if missing_deps: + _LOGGER.error( + 'Not initializing %s because not all dependencies loaded: %s', + domain, ", ".join(missing_deps)) + return None + + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + except vol.MultipleInvalid as ex: + log_exception(ex, domain, config) + return None + + elif hasattr(component, 'PLATFORM_SCHEMA'): + platforms = [] + for p_name, p_config in config_per_platform(config, domain): + # Validate component specific platform schema + try: + p_validated = component.PLATFORM_SCHEMA(p_config) + except vol.MultipleInvalid as ex: + log_exception(ex, domain, p_config) + return None + + # Not all platform components follow same pattern for platforms + # So if p_name is None we are not going to validate platform + # (the automation component is one of them) + if p_name is None: + platforms.append(p_validated) + continue + + platform = prepare_setup_platform(hass, config, domain, + p_name) + + if platform is None: + return None + + # Validate platform specific schema + if hasattr(platform, 'PLATFORM_SCHEMA'): + try: + p_validated = platform.PLATFORM_SCHEMA(p_validated) + except vol.MultipleInvalid as ex: + log_exception(ex, '{}.{}'.format(domain, p_name), + p_validated) + return None + + platforms.append(p_validated) + + # Create a copy of the configuration with all config for current + # component removed and add validated config back in. + filter_keys = extract_domain_configs(config, domain) + config = {key: value for key, value in config.items() + if key not in filter_keys} + config[domain] = platforms + + if not _handle_requirements(hass, component, domain): + return None + + return config + + def prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, platform_name: str) -> Optional[ModuleType]: """Load a platform and makes sure dependencies are setup.""" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 6f5396afa15..40715bca502 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -6,10 +6,13 @@ https://home-assistant.io/components/automation/ """ from functools import partial import logging +import os import voluptuous as vol -from homeassistant.bootstrap import prepare_setup_platform +from homeassistant.bootstrap import ( + prepare_setup_platform, prepare_setup_component) +from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) @@ -46,6 +49,7 @@ METHOD_IF_ACTION = 'if_action' ATTR_LAST_TRIGGERED = 'last_triggered' ATTR_VARIABLES = 'variables' SERVICE_TRIGGER = 'trigger' +SERVICE_RELOAD = 'reload' _LOGGER = logging.getLogger(__name__) @@ -112,6 +116,8 @@ TRIGGER_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_VARIABLES, default={}): dict, }) +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + def is_on(hass, entity_id=None): """ @@ -148,40 +154,23 @@ def trigger(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TRIGGER, data) +def reload(hass): + """Reload the automation from config.""" + hass.services.call(DOMAIN, SERVICE_RELOAD) + + def setup(hass, config): """Setup the automation.""" - # pylint: disable=too-many-locals component = EntityComponent(_LOGGER, DOMAIN, hass) - success = False - for config_key in extract_domain_configs(config, DOMAIN): - conf = config[config_key] - - for list_no, config_block in enumerate(conf): - name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, - list_no) - - action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) - - if CONF_CONDITION in config_block: - cond_func = _process_if(hass, config, config_block) - - if cond_func is None: - continue - else: - def cond_func(variables): - """Condition will always pass.""" - return True - - attach_triggers = partial(_process_trigger, hass, config, - config_block.get(CONF_TRIGGER, []), name) - entity = AutomationEntity(name, attach_triggers, cond_func, action) - component.add_entities((entity,)) - success = True + success = _process_config(hass, config, component) if not success: return False + descriptions = conf_util.load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + def trigger_service_handler(service_call): """Handle automation triggers.""" for entity in component.extract_from_service(service_call): @@ -192,11 +181,34 @@ def setup(hass, config): for entity in component.extract_from_service(service_call): getattr(entity, service_call.service)() + def reload_service_handler(service_call): + """Remove all automations and load new ones from config.""" + try: + path = conf_util.find_config_file(hass.config.config_dir) + conf = conf_util.load_yaml_config_file(path) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + conf = prepare_setup_component(hass, conf, DOMAIN) + + if conf is None: + return + + component.reset() + _process_config(hass, conf, component) + hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler, + descriptions.get(SERVICE_TRIGGER), schema=TRIGGER_SERVICE_SCHEMA) + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_service_handler, + descriptions.get(SERVICE_RELOAD), + schema=RELOAD_SERVICE_SCHEMA) + for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE): hass.services.register(DOMAIN, service, service_handler, + descriptions.get(service), schema=SERVICE_SCHEMA) return True @@ -263,6 +275,43 @@ class AutomationEntity(ToggleEntity): self._last_triggered = utcnow() self.update_ha_state() + def remove(self): + """Remove automation from HASS.""" + self.turn_off() + super().remove() + + +def _process_config(hass, config, component): + """Process config and add automations.""" + success = False + + for config_key in extract_domain_configs(config, DOMAIN): + conf = config[config_key] + + for list_no, config_block in enumerate(conf): + name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, + list_no) + + action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + + if CONF_CONDITION in config_block: + cond_func = _process_if(hass, config, config_block) + + if cond_func is None: + continue + else: + def cond_func(variables): + """Condition will always pass.""" + return True + + attach_triggers = partial(_process_trigger, hass, config, + config_block.get(CONF_TRIGGER, []), name) + entity = AutomationEntity(name, attach_triggers, cond_func, action) + component.add_entities((entity,)) + success = True + + return success + def _get_action(hass, config, name): """Return an action based on a configuration.""" diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml new file mode 100644 index 00000000000..ee22b671eca --- /dev/null +++ b/homeassistant/components/automation/services.yaml @@ -0,0 +1,34 @@ +turn_on: + description: Enable an automation. + + fields: + entity_id: + description: Name of the automation to turn on. + example: 'automation.notify_home' + +turn_off: + description: Disable an automation. + + fields: + entity_id: + description: Name of the automation to turn off. + example: 'automation.notify_home' + +toggle: + description: Toggle an automation. + + fields: + entity_id: + description: Name of the automation to toggle on/off. + example: 'automation.notify_home' + +trigger: + description: Trigger the action of an automation. + + fields: + entity_id: + description: Name of the automation to trigger. + example: 'automation.notify_home' + +reload: + description: Reload the automation configuration. diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 61cda43d431..0b4768b809d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -195,6 +195,10 @@ class Entity(object): return self.hass.states.set( self.entity_id, state, attr, self.force_update) + def remove(self) -> None: + """Remove entitiy from HASS.""" + self.hass.states.remove(self.entity_id) + def _attr_setter(self, name, typ, attr, attrs): """Helper method to populate attributes based on properties.""" if attr in attrs: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 898a445c788..e853d20df89 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,13 +32,14 @@ class EntityComponent(object): self.entities = {} self.group = None - self.is_polling = False self.config = None self.lock = Lock() - self.add_entities = EntityPlatform(self, self.scan_interval, - None).add_entities + self._platforms = { + 'core': EntityPlatform(self, self.scan_interval, None), + } + self.add_entities = self._platforms['core'].add_entities def setup(self, config): """Set up a full entity component. @@ -85,17 +86,22 @@ class EntityComponent(object): return # Config > Platform > Component - scan_interval = platform_config.get( - CONF_SCAN_INTERVAL, - getattr(platform, 'SCAN_INTERVAL', self.scan_interval)) + scan_interval = (platform_config.get(CONF_SCAN_INTERVAL) or + getattr(platform, 'SCAN_INTERVAL', None) or + self.scan_interval) entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE) + key = (platform_type, scan_interval, entity_namespace) + + if key not in self._platforms: + self._platforms[key] = EntityPlatform(self, scan_interval, + entity_namespace) + entity_platform = self._platforms[key] + try: - platform.setup_platform( - self.hass, platform_config, - EntityPlatform(self, scan_interval, - entity_namespace).add_entities, - discovery_info) + platform.setup_platform(self.hass, platform_config, + entity_platform.add_entities, + discovery_info) self.hass.config.components.append( '{}.{}'.format(self.domain, platform_type)) @@ -135,6 +141,22 @@ class EntityComponent(object): if self.group is not None: self.group.update_tracked_entity_ids(self.entities.keys()) + def reset(self): + """Remove entities and reset the entity component to initial values.""" + with self.lock: + for platform in self._platforms.values(): + platform.reset() + + self._platforms = { + 'core': self._platforms['core'] + } + self.entities = {} + self.config = None + + if self.group is not None: + self.group.stop() + self.group = None + class EntityPlatform(object): """Keep track of entities for a single platform.""" @@ -146,7 +168,7 @@ class EntityPlatform(object): self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.platform_entities = [] - self.is_polling = False + self._unsub_polling = None def add_entities(self, new_entities): """Add entities for a single platform.""" @@ -157,17 +179,23 @@ class EntityPlatform(object): self.component.update_group() - if self.is_polling or \ + if self._unsub_polling is not None or \ not any(entity.should_poll for entity in self.platform_entities): return - self.is_polling = True - - track_utc_time_change( + self._unsub_polling = track_utc_time_change( self.component.hass, self._update_entity_states, second=range(0, 60, self.scan_interval)) + def reset(self): + """Remove all entities and reset data.""" + for entity in self.platform_entities: + entity.remove() + if self._unsub_polling is not None: + self._unsub_polling() + self._unsub_polling = None + def _update_entity_states(self, now): """Update the states of all the polling entities.""" with self.component.lock: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 77727ca56b5..f244bb3a23b 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch from homeassistant.bootstrap import _setup_component import homeassistant.components.automation as automation from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant @@ -414,3 +415,132 @@ class TestAutomation(unittest.TestCase): automation.turn_on(self.hass, entity_id) self.hass.pool.block_till_done() assert automation.is_on(self.hass, entity_id) + + @patch('homeassistant.config.load_yaml_config_file', return_value={ + automation.DOMAIN: { + 'alias': 'bye', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event2', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + def test_reload_config_service(self, mock_load_yaml): + """Test the reload config service.""" + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + assert self.hass.states.get('automation.hello') is not None + assert self.hass.states.get('automation.bye') is None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + assert len(self.calls) == 1 + assert self.calls[0].data.get('event') == 'test_event' + + automation.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.get('automation.hello') is None + assert self.hass.states.get('automation.bye') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + + self.hass.bus.fire('test_event2') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 + assert self.calls[1].data.get('event') == 'test_event2' + + @patch('homeassistant.config.load_yaml_config_file', return_value={ + automation.DOMAIN: 'not valid', + }) + def test_reload_config_when_invalid_config(self, mock_load_yaml): + """Test the reload config service handling invalid config.""" + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + assert len(self.calls) == 1 + assert self.calls[0].data.get('event') == 'test_event' + + automation.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 + + def test_reload_config_handles_load_fails(self): + """Test the reload config service.""" + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + assert len(self.calls) == 1 + assert self.calls[0].data.get('event') == 'test_event' + + with patch('homeassistant.config.load_yaml_config_file', + side_effect=HomeAssistantError('bla')): + automation.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index f9abe764866..0ed70ecef77 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -211,19 +211,19 @@ class TestBootstrap: deps = ['non_existing'] loader.set_component('comp', MockModule('comp', dependencies=deps)) - assert not bootstrap._setup_component(self.hass, 'comp', None) + assert not bootstrap._setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components self.hass.config.components.append('non_existing') - assert bootstrap._setup_component(self.hass, 'comp', None) + assert bootstrap._setup_component(self.hass, 'comp', {}) def test_component_failing_setup(self): """Test component that fails setup.""" loader.set_component( 'comp', MockModule('comp', setup=lambda hass, config: False)) - assert not bootstrap._setup_component(self.hass, 'comp', None) + assert not bootstrap._setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components def test_component_exception_setup(self): @@ -234,7 +234,7 @@ class TestBootstrap: loader.set_component('comp', MockModule('comp', setup=exception_setup)) - assert not bootstrap._setup_component(self.hass, 'comp', None) + assert not bootstrap._setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components def test_home_assistant_core_config_validation(self): From cd67368bb75f8b174b17649c3df37bb75a75bc3b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 18:27:19 +0200 Subject: [PATCH 104/208] Migrate to voluptuous (#3182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/sensor/gtfs.py | 177 ++++++++++++------------ 1 file changed, 89 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index ad954899e6d..a9c6f36bf54 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -9,29 +9,47 @@ import logging import datetime import threading -from homeassistant.helpers.entity import Entity +import voluptuous as vol -_LOGGER = logging.getLogger(__name__) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" "pygtfs==0.1.3"] -ICON = "mdi:train" +_LOGGER = logging.getLogger(__name__) + +CONF_DATA = 'data' +CONF_DESTINATION = 'destination' +CONF_ORIGIN = 'origin' + +DEFAULT_NAME = 'GTFS Sensor' +DEFAULT_PATH = 'gtfs' + +ICON = 'mdi:train' + +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ORIGIN): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_DATA): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) -TIME_FORMAT = "%Y-%m-%d %H:%M:%S" # pylint: disable=too-many-locals - - def get_next_departure(sched, start_station_id, end_station_id): """Get the next departure for the given sched.""" origin_station = sched.stops_by_id(start_station_id)[0] destination_station = sched.stops_by_id(end_station_id)[0] now = datetime.datetime.now() - day_name = now.strftime("%A").lower() - now_str = now.strftime("%H:%M:%S") + day_name = now.strftime('%A').lower() + now_str = now.strftime('%H:%M:%S') from sqlalchemy.sql import text @@ -78,9 +96,9 @@ def get_next_departure(sched, start_station_id, end_station_id): for row in result: item = row - today = datetime.datetime.today().strftime("%Y-%m-%d") - departure_time_string = "{} {}".format(today, item[2]) - arrival_time_string = "{} {}".format(today, item[3]) + today = datetime.datetime.today().strftime('%Y-%m-%d') + departure_time_string = '{} {}'.format(today, item[2]) + arrival_time_string = '{} {}'.format(today, item[3]) departure_time = datetime.datetime.strptime(departure_time_string, TIME_FORMAT) arrival_time = datetime.datetime.strptime(arrival_time_string, @@ -91,72 +109,61 @@ def get_next_departure(sched, start_station_id, end_station_id): route = sched.routes_by_id(item[1])[0] - origin_stoptime_arrival_time = "{} {}".format(today, item[4]) - - origin_stoptime_departure_time = "{} {}".format(today, item[5]) - - dest_stoptime_arrival_time = "{} {}".format(today, item[11]) - - dest_stoptime_depart_time = "{} {}".format(today, item[12]) + origin_stoptime_arrival_time = '{} {}'.format(today, item[4]) + origin_stoptime_departure_time = '{} {}'.format(today, item[5]) + dest_stoptime_arrival_time = '{} {}'.format(today, item[11]) + dest_stoptime_depart_time = '{} {}'.format(today, item[12]) origin_stop_time_dict = { - "Arrival Time": origin_stoptime_arrival_time, - "Departure Time": origin_stoptime_departure_time, - "Drop Off Type": item[6], "Pickup Type": item[7], - "Shape Dist Traveled": item[8], "Headsign": item[9], - "Sequence": item[10] + 'Arrival Time': origin_stoptime_arrival_time, + 'Departure Time': origin_stoptime_departure_time, + 'Drop Off Type': item[6], 'Pickup Type': item[7], + 'Shape Dist Traveled': item[8], 'Headsign': item[9], + 'Sequence': item[10] } destination_stop_time_dict = { - "Arrival Time": dest_stoptime_arrival_time, - "Departure Time": dest_stoptime_depart_time, - "Drop Off Type": item[13], "Pickup Type": item[14], - "Shape Dist Traveled": item[15], "Headsign": item[16], - "Sequence": item[17] + 'Arrival Time': dest_stoptime_arrival_time, + 'Departure Time': dest_stoptime_depart_time, + 'Drop Off Type': item[13], 'Pickup Type': item[14], + 'Shape Dist Traveled': item[15], 'Headsign': item[16], + 'Sequence': item[17] } return { - "trip_id": item[0], - "trip": sched.trips_by_id(item[0])[0], - "route": route, - "agency": sched.agencies_by_id(route.agency_id)[0], - "origin_station": origin_station, - "departure_time": departure_time, - "destination_station": destination_station, - "arrival_time": arrival_time, - "seconds_until_departure": seconds_until, - "minutes_until_departure": minutes_until, - "origin_stop_time": origin_stop_time_dict, - "destination_stop_time": destination_stop_time_dict + 'trip_id': item[0], + 'trip': sched.trips_by_id(item[0])[0], + 'route': route, + 'agency': sched.agencies_by_id(route.agency_id)[0], + 'origin_station': origin_station, + 'departure_time': departure_time, + 'destination_station': destination_station, + 'arrival_time': arrival_time, + 'seconds_until_departure': seconds_until, + 'minutes_until_departure': minutes_until, + 'origin_stop_time': origin_stop_time_dict, + 'destination_stop_time': destination_stop_time_dict } def setup_platform(hass, config, add_devices, discovery_info=None): """Get the GTFS sensor.""" - if config.get("origin") is None: - _LOGGER.error("Origin must be set in the GTFS configuration!") - return False - - if config.get("destination") is None: - _LOGGER.error("Destination must be set in the GTFS configuration!") - return False - - if config.get("data") is None: - _LOGGER.error("Data must be set in the GTFS configuration!") - return False - - gtfs_dir = hass.config.path("gtfs") + gtfs_dir = hass.config.path(DEFAULT_PATH) + data = config.get(CONF_DATA) + origin = config.get(CONF_ORIGIN) + destination = config.get(CONF_DESTINATION) + name = config.get(CONF_NAME) if not os.path.exists(gtfs_dir): os.makedirs(gtfs_dir) - if not os.path.exists(os.path.join(gtfs_dir, config["data"])): + if not os.path.exists(os.path.join(gtfs_dir, data)): _LOGGER.error("The given GTFS data file/folder was not found!") return False import pygtfs - split_file_name = os.path.splitext(config["data"]) + split_file_name = os.path.splitext(data) sqlite_file = "{}.sqlite".format(split_file_name[0]) joined_path = os.path.join(gtfs_dir, sqlite_file) @@ -164,27 +171,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=no-member if len(gtfs.feeds) < 1: - pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, - config["data"])) + pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) + + add_devices([GTFSDepartureSensor(gtfs, name, origin, destination)]) - dev = [] - dev.append(GTFSDepartureSensor(gtfs, config["origin"], - config["destination"])) - add_devices(dev) # pylint: disable=too-many-instance-attributes,too-few-public-methods - - class GTFSDepartureSensor(Entity): """Implementation of an GTFS departures sensor.""" - def __init__(self, pygtfs, origin, destination): + def __init__(self, pygtfs, name, origin, destination): """Initialize the sensor.""" self._pygtfs = pygtfs self.origin = origin self.destination = destination - self._name = "GTFS Sensor" - self._unit_of_measurement = "min" + self._name = name + self._unit_of_measurement = 'min' self._state = 0 self._attributes = {} self.lock = threading.Lock() @@ -220,23 +222,22 @@ class GTFSDepartureSensor(Entity): with self.lock: self._departure = get_next_departure(self._pygtfs, self.origin, self.destination) - self._state = self._departure["minutes_until_departure"] + self._state = self._departure['minutes_until_departure'] - origin_station = self._departure["origin_station"] - destination_station = self._departure["destination_station"] - origin_stop_time = self._departure["origin_stop_time"] - destination_stop_time = self._departure["destination_stop_time"] - agency = self._departure["agency"] - route = self._departure["route"] - trip = self._departure["trip"] + origin_station = self._departure['origin_station'] + destination_station = self._departure['destination_station'] + origin_stop_time = self._departure['origin_stop_time'] + destination_stop_time = self._departure['destination_stop_time'] + agency = self._departure['agency'] + route = self._departure['route'] + trip = self._departure['trip'] - name = "{} {} to {} next departure" + name = '{} {} to {} next departure' self._name = name.format(agency.agency_name, origin_station.stop_id, destination_station.stop_id) # Build attributes - self._attributes = {} def dict_for_table(resource): @@ -247,22 +248,22 @@ class GTFSDepartureSensor(Entity): def append_keys(resource, prefix=None): """Properly format key val pairs to append to attributes.""" for key, val in resource.items(): - if val == "" or val is None or key == "feed_id": + if val == "" or val is None or key == 'feed_id': continue - pretty_key = key.replace("_", " ") + pretty_key = key.replace('_', ' ') pretty_key = pretty_key.title() - pretty_key = pretty_key.replace("Id", "ID") - pretty_key = pretty_key.replace("Url", "URL") + pretty_key = pretty_key.replace('Id', 'ID') + pretty_key = pretty_key.replace('Url', 'URL') if prefix is not None and \ pretty_key.startswith(prefix) is False: - pretty_key = "{} {}".format(prefix, pretty_key) + pretty_key = '{} {}'.format(prefix, pretty_key) self._attributes[pretty_key] = val - append_keys(dict_for_table(agency), "Agency") - append_keys(dict_for_table(route), "Route") - append_keys(dict_for_table(trip), "Trip") - append_keys(dict_for_table(origin_station), "Origin Station") + append_keys(dict_for_table(agency), 'Agency') + append_keys(dict_for_table(route), 'Route') + append_keys(dict_for_table(trip), 'Trip') + append_keys(dict_for_table(origin_station), 'Origin Station') append_keys(dict_for_table(destination_station), - "Destination Station") - append_keys(origin_stop_time, "Origin Stop") - append_keys(destination_stop_time, "Destination Stop") + 'Destination Station') + append_keys(origin_stop_time, 'Origin Stop') + append_keys(destination_stop_time, 'Destination Stop') From b4c8d10dbcbdb8d8ccb6f1dcb227ba6378f321d1 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 4 Sep 2016 18:32:12 +0200 Subject: [PATCH 105/208] Migrate to voluptuous (#3179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/sensor/torque.py | 38 ++++++++++++++++------- homeassistant/const.py | 1 + 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index 55c6aef31d0..c05217692ac 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -4,19 +4,28 @@ Support for the Torque OBD application. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.torque/ """ - +import logging import re -from homeassistant.helpers.entity import Entity -from homeassistant.components.http import HomeAssistantView +import voluptuous as vol -DOMAIN = 'torque' -DEPENDENCIES = ['http'] -SENSOR_EMAIL_FIELD = 'eml' -DEFAULT_NAME = 'vehicle' -ENTITY_NAME_FORMAT = '{0} {1}' +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_EMAIL, CONF_NAME) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) API_PATH = '/api/torque' + +DEFAULT_NAME = 'vehicle' +DEPENDENCIES = ['http'] +DOMAIN = 'torque' + +ENTITY_NAME_FORMAT = '{0} {1}' + +SENSOR_EMAIL_FIELD = 'eml' SENSOR_NAME_KEY = r'userFullName(\w+)' SENSOR_UNIT_KEY = r'userUnit(\w+)' SENSOR_VALUE_KEY = r'k(\w+)' @@ -25,6 +34,11 @@ NAME_KEY = re.compile(SENSOR_NAME_KEY) UNIT_KEY = re.compile(SENSOR_UNIT_KEY) VALUE_KEY = re.compile(SENSOR_VALUE_KEY) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def decode(value): """Double-decode required.""" @@ -39,12 +53,12 @@ def convert_pid(value): # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Torque platform.""" - vehicle = config.get('name', DEFAULT_NAME) - email = config.get('email', None) + vehicle = config.get(CONF_NAME) + email = config.get(CONF_EMAIL) sensors = {} - hass.wsgi.register_view(TorqueReceiveDataView(hass, email, vehicle, - sensors, add_devices)) + hass.wsgi.register_view(TorqueReceiveDataView( + hass, email, vehicle, sensors, add_devices)) return True diff --git a/homeassistant/const.py b/homeassistant/const.py index ce0d829e76b..311c1a5f166 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -44,6 +44,7 @@ CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' CONF_DISCOVERY = 'discovery' CONF_DISPLAY_OPTIONS = 'display_options' CONF_ELEVATION = 'elevation' +CONF_EMAIL = 'email' CONF_ENTITY_ID = 'entity_id' CONF_ENTITY_NAMESPACE = 'entity_namespace' CONF_EVENT = 'event' From 29870b301ed15f0e1e930f4b685a65e76d4b6ab8 Mon Sep 17 00:00:00 2001 From: Heiko Rothe Date: Sun, 4 Sep 2016 18:37:10 +0200 Subject: [PATCH 106/208] Added scale and offset to the Temper component (#2853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/sensor/temper.py | 46 ++++++++++++++++++----- requirements_all.txt | 6 +-- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/temper.py b/homeassistant/components/sensor/temper.py index fe5ebb17982..b7fcdd1b015 100644 --- a/homeassistant/components/sensor/temper.py +++ b/homeassistant/components/sensor/temper.py @@ -5,39 +5,66 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.temper/ """ import logging +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, TEMP_FAHRENHEIT from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/rkabadi/temper-python/archive/' - '3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip' - '#temperusb==1.2.3'] +REQUIREMENTS = ['temperusb==1.5.1'] + +CONF_SCALE = 'scale' +CONF_OFFSET = 'offset' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): vol.Coerce(str), + vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), + vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float) +}) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Temper sensors.""" from temperusb.temper import TemperHandler temp_unit = hass.config.units.temperature_unit - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + name = config.get(CONF_NAME) + scaling = { + 'scale': config.get(CONF_SCALE), + 'offset': config.get(CONF_OFFSET) + } temper_devices = TemperHandler().get_devices() - add_devices_callback([TemperSensor(dev, temp_unit, name + '_' + str(idx)) - for idx, dev in enumerate(temper_devices)]) + devices = [] + + for idx, dev in enumerate(temper_devices): + if idx != 0: + name = name + '_' + str(idx) + devices.append(TemperSensor(dev, temp_unit, name, scaling)) + + add_devices(devices) class TemperSensor(Entity): """Representation of a Temper temperature sensor.""" - def __init__(self, temper_device, temp_unit, name): + def __init__(self, temper_device, temp_unit, name, scaling): """Initialize the sensor.""" self.temper_device = temper_device self.temp_unit = temp_unit + self.scale = scaling['scale'] + self.offset = scaling['offset'] self.current_value = None self._name = name + # set calibration data + self.temper_device.set_calibration_data( + scale=self.scale, + offset=self.offset + ) + @property def name(self): """Return the name of the temperature sensor.""" @@ -58,7 +85,8 @@ class TemperSensor(Entity): try: format_str = ('fahrenheit' if self.temp_unit == TEMP_FAHRENHEIT else 'celsius') - self.current_value = self.temper_device.get_temperature(format_str) + sensor_value = self.temper_device.get_temperature(format_str) + self.current_value = round(sensor_value, 1) except IOError: _LOGGER.error('Failed to get temperature due to insufficient ' 'permissions. Try running with "sudo"') diff --git a/requirements_all.txt b/requirements_all.txt index 98c912ab41e..6ea4bd774ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,9 +182,6 @@ https://github.com/nkgilley/python-join-api/archive/3e1e849f1af0b4080f551b62270c # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 -# homeassistant.components.sensor.temper -https://github.com/rkabadi/temper-python/archive/3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip#temperusb==1.2.3 - # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 @@ -463,6 +460,9 @@ tellcore-py==1.1.2 # homeassistant.components.tellduslive tellive-py==0.5.2 +# homeassistant.components.sensor.temper +temperusb==1.5.1 + # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission transmissionrpc==0.11 From ad528165953fca1fb5c1146f56b2545a44c36e10 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 4 Sep 2016 19:10:20 +0200 Subject: [PATCH 107/208] Use voluptuous for BT and Owntracks device trackers (#3187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../device_tracker/bluetooth_tracker.py | 25 +++++++++---------- .../components/device_tracker/owntracks.py | 11 +++++++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 298eddc4bc4..d5a6fe26861 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -2,15 +2,13 @@ import logging from datetime import timedelta +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( - YAML_DEVICES, - CONF_TRACK_NEW, - CONF_SCAN_INTERVAL, - DEFAULT_SCAN_INTERVAL, - load_config, -) -import homeassistant.util as util + YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, + load_config, PLATFORM_SCHEMA) import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -19,6 +17,10 @@ REQUIREMENTS = ['pybluez==0.22'] BT_PREFIX = 'BT_' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TRACK_NEW): cv.boolean +}) + def setup_scanner(hass, config, see): """Setup the Bluetooth Scanner.""" @@ -53,10 +55,8 @@ def setup_scanner(hass, config, see): else: devs_donot_track.append(device.mac[3:]) - # if track new devices is true discover new devices - # on startup. - track_new = util.convert(config.get(CONF_TRACK_NEW), bool, - len(devs_to_track) == 0) + # if track new devices is true discover new devices on startup. + track_new = config.get(CONF_TRACK_NEW, len(devs_to_track) == 0) if track_new: for dev in discover_devices(): if dev[0] not in devs_to_track and \ @@ -68,8 +68,7 @@ def setup_scanner(hass, config, see): _LOGGER.warning("No bluetooth devices to track!") return False - interval = util.convert(config.get(CONF_SCAN_INTERVAL), int, - DEFAULT_SCAN_INTERVAL) + interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) def update_bluetooth(now): """Lookup bluetooth device and update status.""" diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index abc503a370a..77c18ae73b1 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -9,6 +9,9 @@ import logging import threading from collections import defaultdict +import voluptuous as vol + +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 @@ -40,11 +43,17 @@ VALIDATE_WAYPOINTS = 'waypoints' WAYPOINT_LAT_KEY = 'lat' WAYPOINT_LON_KEY = 'lon' +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MAX_GPS_ACCURACY): cv.string, + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(cv.ensure_list, [cv.string]) +}) + def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT, True) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) def validate_payload(payload, data_type): From a569ee787d989a08b8b5d47e40c1d78c00eba5b4 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 4 Sep 2016 14:37:10 -0700 Subject: [PATCH 108/208] Correct binary_sensor.ecobee docs URL --- homeassistant/components/binary_sensor/ecobee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py index 09cbfd852e3..ab6a58593b3 100644 --- a/homeassistant/components/binary_sensor/ecobee.py +++ b/homeassistant/components/binary_sensor/ecobee.py @@ -2,7 +2,7 @@ Support for Ecobee sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ecobee/ +https://home-assistant.io/components/binary_sensor.ecobee/ """ from homeassistant.components import ecobee from homeassistant.components.binary_sensor import BinarySensorDevice From 98bdcd340547d41717b090a81e58f663fb65220e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 03:16:16 +0200 Subject: [PATCH 109/208] Use voluptuous for Hikvisioncam switch (#3184) * Migrate to voluptuous * Use vol.Optional --- .../components/switch/hikvisioncam.py | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py index 40874138e53..5a911ee3d74 100644 --- a/homeassistant/components/switch/hikvisioncam.py +++ b/homeassistant/components/switch/hikvisioncam.py @@ -6,31 +6,50 @@ https://home-assistant.io/components/switch.hikvision/ """ import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON) + CONF_NAME, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, STATE_OFF, + STATE_ON) from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['hikvision==0.4'] _LOGGING = logging.getLogger(__name__) -REQUIREMENTS = ['hikvision==0.4'] + +DEFAULT_NAME = 'Hikvision Camera Motion Detection' +DEFAULT_PASSWORD = '12345' +DEFAULT_PORT = 80 +DEFAULT_USERNAME = 'admin' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, +}) + + # pylint: disable=too-many-arguments # pylint: disable=too-many-instance-attributes - - -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Hikvision camera.""" import hikvision.api from hikvision.error import HikvisionError, MissingParamError - host = config.get(CONF_HOST, None) - port = config.get('port', "80") - name = config.get('name', "Hikvision Camera Motion Detection") - username = config.get(CONF_USERNAME, "admin") - password = config.get(CONF_PASSWORD, "12345") + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) try: hikvision_cam = hikvision.api.CreateDevice( - host, port=port, username=username, - password=password, is_https=False) + host, port=port, username=username, password=password, + is_https=False) except MissingParamError as param_err: _LOGGING.error("Missing required param: %s", param_err) return False @@ -38,9 +57,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): _LOGGING.error("Unable to connect: %s", conn_err) return False - add_devices_callback([ - HikvisionMotionSwitch(name, hikvision_cam) - ]) + add_devices([HikvisionMotionSwitch(name, hikvision_cam)]) class HikvisionMotionSwitch(ToggleEntity): @@ -85,6 +102,6 @@ class HikvisionMotionSwitch(ToggleEntity): def update(self): """Update Motion Detection state.""" enabled = self._hikvision_cam.is_motion_detection_enabled() - _LOGGING.info('enabled: %s', enabled) + _LOGGING.info("enabled: %s", enabled) self._state = STATE_ON if enabled else STATE_OFF From 59cd92cb4d7fa2bd560c1cf04a853f0ecea6ef7b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 03:17:40 +0200 Subject: [PATCH 110/208] Use voluptuous for Edimax (#3178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/switch/edimax.py | 37 ++++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index 8240be692ba..41746f9a0ef 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -6,39 +6,40 @@ https://home-assistant.io/components/switch.edimax/ """ import logging -from homeassistant.components.switch import DOMAIN, SwitchDevice +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv -# constants -DEFAULT_USERNAME = 'admin' -DEFAULT_PASSWORD = '1234' -DEVICE_DEFAULT_NAME = 'Edimax Smart Plug' REQUIREMENTS = ['https://github.com/rkabadi/pyedimax/archive/' '365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1'] _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Edimax Smart Plug' +DEFAULT_PASSWORD = '1234' +DEFAULT_USERNAME = 'admin' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Edimax Smart Plugs.""" from pyedimax.smartplug import SmartPlug - # pylint: disable=global-statement - # check for required values in configuration file - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_HOST]}, - _LOGGER): - return False - host = config.get(CONF_HOST) - auth = (config.get(CONF_USERNAME, DEFAULT_USERNAME), - config.get(CONF_PASSWORD, DEFAULT_PASSWORD)) - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + name = config.get(CONF_NAME) - add_devices_callback([SmartPlugSwitch(SmartPlug(host, auth), name)]) + add_devices([SmartPlugSwitch(SmartPlug(host, auth), name)]) class SmartPlugSwitch(SwitchDevice): From 892f6a706a696fdd7dc49a6a970206f3d49fc079 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 03:22:01 +0200 Subject: [PATCH 111/208] Use voluptuous for Bravia TV (#3165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/media_player/braviatv.py | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 3cd470dba8d..7d560beddda 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -8,20 +8,28 @@ import logging import os import json import re + +import voluptuous as vol + from homeassistant.loader import get_component from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) + SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, + PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ 'https://github.com/aparraga/braviarc/archive/0.3.4.zip' '#braviarc==0.3.4'] BRAVIA_CONFIG_FILE = 'bravia.conf' + CLIENTID_PREFIX = 'HomeAssistant' + +DEFAULT_NAME = 'Sony Bravia TV' + NICKNAME = 'Home Assistant' # Map ip to request id for configuring @@ -34,6 +42,11 @@ SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def _get_mac_address(ip_address): """Get the MAC address of the device.""" @@ -82,7 +95,7 @@ def _config_from_file(filename, config=None): # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Sony Bravia TV platform.""" host = config.get(CONF_HOST) @@ -98,22 +111,20 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): pin = host_config['pin'] mac = host_config['mac'] name = config.get(CONF_NAME) - add_devices_callback([BraviaTVDevice(host, mac, name, pin)]) + add_devices([BraviaTVDevice(host, mac, name, pin)]) return - setup_bravia(config, pin, hass, add_devices_callback) + setup_bravia(config, pin, hass, add_devices) # pylint: disable=too-many-branches -def setup_bravia(config, pin, hass, add_devices_callback): +def setup_bravia(config, pin, hass, add_devices): """Setup a Sony Bravia TV based on host parameter.""" host = config.get(CONF_HOST) name = config.get(CONF_NAME) - if name is None: - name = "Sony Bravia TV" if pin is None: - request_configuration(config, hass, add_devices_callback) + request_configuration(config, hass, add_devices) return else: mac = _get_mac_address(host) @@ -132,15 +143,13 @@ def setup_bravia(config, pin, hass, add_devices_callback): {host: {'pin': pin, 'host': host, 'mac': mac}}): _LOGGER.error('failed to save config file') - add_devices_callback([BraviaTVDevice(host, mac, name, pin)]) + add_devices([BraviaTVDevice(host, mac, name, pin)]) -def request_configuration(config, hass, add_devices_callback): +def request_configuration(config, hass, add_devices): """Request configuration steps from the user.""" host = config.get(CONF_HOST) name = config.get(CONF_NAME) - if name is None: - name = "Sony Bravia" configurator = get_component('configurator') @@ -158,9 +167,9 @@ def request_configuration(config, hass, add_devices_callback): braviarc = braviarc.BraviaRC(host) braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME) if braviarc.is_connected(): - setup_bravia(config, pin, hass, add_devices_callback) + setup_bravia(config, pin, hass, add_devices) else: - request_configuration(config, hass, add_devices_callback) + request_configuration(config, hass, add_devices) _CONFIGURING[host] = configurator.request_config( hass, name, bravia_configuration_callback, From 7bab4055a580a523c5a11307f77a27fd6e11b0da Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Mon, 5 Sep 2016 00:15:44 -0400 Subject: [PATCH 112/208] Added support to 'effect: random' to Osram Lightify lights (#3192) * Added support to 'effect: random' to Osram Lightify lights * removed extra line not required --- homeassistant/components/light/osramlightify.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index 41a226031d6..a54cf2bcc32 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/light.osramlightify/ """ import logging import socket +import random from datetime import timedelta from homeassistant import util @@ -14,9 +15,12 @@ from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_TRANSITION, + EFFECT_RANDOM, SUPPORT_BRIGHTNESS, + SUPPORT_EFFECT, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, @@ -33,7 +37,8 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) SUPPORT_OSRAMLIGHTIFY = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) + SUPPORT_EFFECT | SUPPORT_RGB_COLOR | + SUPPORT_TRANSITION) def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -150,6 +155,13 @@ class OsramLightifyLight(Light): (TEMP_MAX_HASS - TEMP_MIN_HASS)) + TEMP_MIN) self._light.set_temperature(kelvin, fade) + effect = kwargs.get(ATTR_EFFECT) + if effect == EFFECT_RANDOM: + self._light.set_rgb(random.randrange(0, 255), + random.randrange(0, 255), + random.randrange(0, 255), + fade) + self._light.set_luminance(brightness, fade) self.update_ha_state() From e460d8f637914f55b074ec6e453e93c46d50cfbf Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 5 Sep 2016 07:07:31 +0200 Subject: [PATCH 113/208] Use voluptuous for message_bird, sendgrid (#3136) --- .../components/notify/message_bird.py | 37 ++++++------------- homeassistant/components/notify/sendgrid.py | 25 ++++++++----- homeassistant/components/notify/smtp.py | 5 +-- homeassistant/components/notify/xmpp.py | 4 +- homeassistant/const.py | 2 + 5 files changed, 31 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/notify/message_bird.py b/homeassistant/components/notify/message_bird.py index 86bcfe79cd0..2e7f2f9bb07 100644 --- a/homeassistant/components/notify/message_bird.py +++ b/homeassistant/components/notify/message_bird.py @@ -6,26 +6,22 @@ https://home-assistant.io/components/notify.message_bird/ """ import logging -from homeassistant.components.notify import ( - ATTR_TARGET, DOMAIN, BaseNotificationService) -from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import voluptuous as vol -CONF_SENDER = 'sender' +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_API_KEY, CONF_SENDER _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['messagebird==1.2.0'] -def is_valid_sender(sender): - """Test if the sender config option is valid.""" - length = len(sender) - if length > 1: - if sender[0] == '+': - return sender[1:].isdigit() - elif length <= 11: - return sender.isalpha() - return False +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SENDER, default='HA'): + vol.Match(r"^(\+?[1-9]\d{1,14}|\w{1,11})$"), +}) # pylint: disable=unused-argument @@ -33,17 +29,6 @@ def get_service(hass, config): """Get the MessageBird notification service.""" import messagebird - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): - return None - - sender = config.get(CONF_SENDER, 'HA') - if not is_valid_sender(sender): - _LOGGER.error('Sender is invalid: It must be a phone number or ' - 'a string not longer than 11 characters.') - return None - client = messagebird.Client(config[CONF_API_KEY]) try: # validates the api key @@ -52,7 +37,7 @@ def get_service(hass, config): _LOGGER.error('The specified MessageBird API key is invalid.') return None - return MessageBirdNotificationService(sender, client) + return MessageBirdNotificationService(config.get(CONF_SENDER), client) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index b0805338844..ac249dc2c97 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -6,24 +6,29 @@ https://home-assistant.io/components/notify.sendgrid/ """ import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sendgrid==3.2.10'] _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SENDER): vol.Email, + vol.Required(CONF_RECIPIENT): cv.string, +}) + + def get_service(hass, config): """Get the SendGrid notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['api_key', 'sender', 'recipient']}, - _LOGGER): - return None - - api_key = config['api_key'] - sender = config['sender'] - recipient = config['recipient'] + api_key = config[CONF_API_KEY] + sender = config[CONF_SENDER] + recipient = config[CONF_RECIPIENT] return SendgridNotificationService(api_key, sender, recipient) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 694058a11ce..8b6ca76a235 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -16,15 +16,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PORT) +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PORT, + CONF_SENDER, CONF_RECIPIENT) _LOGGER = logging.getLogger(__name__) ATTR_IMAGES = 'images' # optional embedded image file attachments CONF_STARTTLS = 'starttls' -CONF_SENDER = 'sender' -CONF_RECIPIENT = 'recipient' CONF_DEBUG = 'debug' CONF_SERVER = 'server' diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 35157a9bd46..ed64a8b4e07 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.1', 'dnspython3==1.12.0', @@ -19,8 +19,6 @@ REQUIREMENTS = ['sleekxmpp==1.3.1', 'pyasn1-modules==0.0.8'] -CONF_SENDER = 'sender' -CONF_RECIPIENT = 'recipient' CONF_TLS = 'tls' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/const.py b/homeassistant/const.py index 311c1a5f166..a07316711d1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -73,9 +73,11 @@ CONF_PIN = 'pin' CONF_PLATFORM = 'platform' CONF_PORT = 'port' CONF_PREFIX = 'prefix' +CONF_RECIPIENT = 'recipient' CONF_RESOURCE = 'resource' CONF_RESOURCES = 'resources' CONF_SCAN_INTERVAL = 'scan_interval' +CONF_SENDER = 'sender' CONF_SENSOR_CLASS = 'sensor_class' CONF_SENSORS = 'sensors' CONF_SSL = 'ssl' From 5144547b709e0ce3ac9c720801f9d16e93439e7e Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Sep 2016 02:25:03 -0700 Subject: [PATCH 114/208] Try out the RTD theme --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 2644130f4c6..b9f5f225672 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -124,7 +124,7 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +# html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From 1170b2897a6df23e6665f61bc8bb9d13e4ba3fe6 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Sep 2016 03:31:48 -0700 Subject: [PATCH 115/208] Doc updates --- docs/Makefile | 5 +++ docs/source/_static/favicon.ico | Bin 0 -> 17957 bytes docs/source/_static/logo-apple.png | Bin 0 -> 15269 bytes docs/source/_static/logo.png | Bin 0 -> 15701 bytes docs/source/conf.py | 59 ++++++++++++++++++++++------- homeassistant/const.py | 37 +++++++++++++++++- setup.py | 32 +++++++--------- 7 files changed, 100 insertions(+), 33 deletions(-) create mode 100644 docs/source/_static/favicon.ico create mode 100644 docs/source/_static/logo-apple.png create mode 100644 docs/source/_static/logo.png diff --git a/docs/Makefile b/docs/Makefile index c2cf05dc0e4..e8b712ce8a1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -18,6 +18,7 @@ I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" + @echo " livehtml to make standalone HTML files via sphinx-autobuild" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @@ -54,6 +55,10 @@ html: @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." +.PHONY: livehtml +livehtml: + sphinx-autobuild --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6d12158c18b17464323bea3d769003e6dd915af3 GIT binary patch literal 17957 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 literal 0 HcmV?d00001 diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png new file mode 100644 index 0000000000000000000000000000000000000000..20117d00f22756f4e77b60042ac48186e31a3861 GIT binary patch literal 15269 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 literal 0 HcmV?d00001 diff --git a/docs/source/conf.py b/docs/source/conf.py index b9f5f225672..4ca74060aad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,6 +21,13 @@ # import sys # sys.path.insert(0, os.path.abspath('.')) +from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, + PROJECT_LONG_DESCRIPTION, PROJECT_URL, + PROJECT_COPYRIGHT, PROJECT_AUTHOR, + PROJECT_GITHUB_USERNAME, + PROJECT_GITHUB_REPOSITORY, PYPI_URL, + GITHUB_URL) + # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. @@ -53,18 +60,18 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = 'Home Assistant' -copyright = '2016, Home Assistant Team' -author = 'Home Assistant Team' +project = PROJECT_NAME +copyright = PROJECT_COPYRIGHT +author = PROJECT_AUTHOR # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.27' +version = __short_version__ # The full version, including alpha/beta/rc tags. -release = '0.27.0' +release = __version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -124,13 +131,31 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -# html_theme = 'alabaster' +html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = { + 'logo': 'logo.png', + 'logo_name': PROJECT_NAME, + 'description': PROJECT_LONG_DESCRIPTION, + 'github_user': PROJECT_GITHUB_USERNAME, + 'github_repo': PROJECT_GITHUB_REPOSITORY, + 'github_type': 'star', + 'github_banner': True, + 'travis_button': True, + 'touch_icon': 'logo-apple.png', + # 'fixed_sidebar': True, # Re-enable when we have more content + 'extra_nav_links': { + '🏡 Homepage': PROJECT_URL, + '📌 Community Forums': 'https://community.home-assistant.io', + '💬 Gitter': 'https://gitter.im/home-assistant/home-assistant', + '🚀 GitHub': GITHUB_URL, + '💾 Download Releases': PYPI_URL, + } +} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -147,13 +172,14 @@ todo_include_todos = False # The name of an image file (relative to this directory) to place at the top # of the sidebar. # -# html_logo = None +# html_logo = '_static/logo.png' # The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# the docs. +# This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # -# html_favicon = None +html_favicon = '_static/favicon.ico' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -170,16 +196,23 @@ html_static_path = ['_static'] # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # -# html_last_updated_fmt = None +html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # -# html_use_smartypants = True +html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # -# html_sidebars = {} +html_sidebars = { + '**': [ + 'about.html', + 'navigation.html', + 'relations.html', + 'searchbox.html', + ] +} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/homeassistant/const.py b/homeassistant/const.py index a07316711d1..c367792852a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,9 +1,42 @@ # coding: utf-8 """Constants used by Home Assistant components.""" - -__version__ = '0.28.0.dev0' +MAJOR_VERSION = 0 +MINOR_VERSION = 28 +PATCH_VERSION = '0.dev0' +__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) +__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_LICENSE = 'MIT License' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = '2016, {}'.format(PROJECT_AUTHOR) +PROJECT_URL = 'https://home-assistant.io/' +PROJECT_EMAIL = 'hello@home-assistant.io' +PROJECT_DESCRIPTION = ('Open-source home automation platform ' + 'running on Python 3.') +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_CLASSIFIERS = [ + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Topic :: Home Automation' +] + +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) +GITHUB_URL = 'https://github.com/{}/{}'.format(PROJECT_GITHUB_USERNAME, + PROJECT_GITHUB_REPOSITORY) + PLATFORM_FORMAT = '{}.{}' # Can be used to specify a catch all when registering state or event listeners. diff --git a/setup.py b/setup.py index caa5b177b5c..67366bd7d83 100755 --- a/setup.py +++ b/setup.py @@ -1,12 +1,15 @@ #!/usr/bin/env python3 import os from setuptools import setup, find_packages -from homeassistant.const import __version__ +from homeassistant.const import (__version__, PROJECT_PACKAGE_NAME, + PROJECT_LICENSE, PROJECT_URL, + PROJECT_EMAIL, PROJECT_DESCRIPTION, + PROJECT_CLASSIFIERS, GITHUB_URL, + PROJECT_AUTHOR) -PACKAGE_NAME = 'homeassistant' HERE = os.path.abspath(os.path.dirname(__file__)) -DOWNLOAD_URL = ('https://github.com/home-assistant/home-assistant/archive/' - '{}.zip'.format(__version__)) +DOWNLOAD_URL = ('{}/archive/' + '{}.zip'.format(GITHUB_URL, __version__)) PACKAGES = find_packages(exclude=['tests', 'tests.*']) @@ -21,14 +24,14 @@ REQUIRES = [ ] setup( - name=PACKAGE_NAME, + name=PROJECT_PACKAGE_NAME, version=__version__, - license='MIT License', - url='https://home-assistant.io/', + license=PROJECT_LICENSE, + url=PROJECT_URL, download_url=DOWNLOAD_URL, - author='Home Assistant', - author_email='hello@home-assistant.io', - description='Open-source home automation platform running on Python 3.', + author=PROJECT_AUTHOR, + author_email=PROJECT_EMAIL, + description=PROJECT_DESCRIPTION, packages=PACKAGES, include_package_data=True, zip_safe=False, @@ -41,12 +44,5 @@ setup( 'hass = homeassistant.__main__:main' ] }, - classifiers=[ - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', - 'Topic :: Home Automation' - ], + classifiers=PROJECT_CLASSIFIERS, ) From 48c16311789bc598e1a7e403e369f0b62e8d605c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 5 Sep 2016 13:27:10 +0200 Subject: [PATCH 116/208] Update voluptuous for existing notify platforms (#3133) * Update voluptuous for exists notify platforms * fix constants --- homeassistant/components/notify/aws_lambda.py | 27 ++++++++++--------- homeassistant/components/notify/aws_sns.py | 26 +++++++++--------- homeassistant/components/notify/aws_sqs.py | 25 ++++++++--------- homeassistant/components/notify/group.py | 7 +++-- .../components/notify/joaoapps_join.py | 9 +++---- homeassistant/components/notify/telegram.py | 11 ++++---- 6 files changed, 53 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 68f0de7a934..3e3003763ea 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -7,29 +7,30 @@ https://home-assistant.io/components/notify.aws_lambda/ import logging import json import base64 + import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" -CONF_CONTEXT = "context" +CONF_REGION = 'region_name' +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_PROFILE_NAME = 'profile_name' +CONF_CONTEXT = 'context' +ATTR_CREDENTIALS = 'credentials' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "aws_lambda", - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), - vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), - vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), - vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default="us-east-1"): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, vol.Optional(CONF_CONTEXT, default=dict()): vol.Coerce(dict) }) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index 31cac90105c..88233234eca 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -6,28 +6,30 @@ https://home-assistant.io/components/notify.aws_sns/ """ import logging import json + import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, PLATFORM_SCHEMA, + BaseNotificationService) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" +CONF_REGION = 'region_name' +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_PROFILE_NAME = 'profile_name' +ATTR_CREDENTIALS = 'credentials' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "aws_sns", - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), - vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), - vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), - vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default="us-east-1"): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, }) diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index a600878cda7..a1ddbcea3dd 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -6,28 +6,29 @@ https://home-assistant.io/components/notify.aws_sqs/ """ import logging import json + import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" +CONF_REGION = 'region_name' +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_PROFILE_NAME = 'profile_name' +ATTR_CREDENTIALS = 'credentials' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "aws_sqs", - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), - vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), - vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), - vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default="us-east-1"): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, }) diff --git a/homeassistant/components/notify/group.py b/homeassistant/components/notify/group.py index 522b231d8cf..0d480a9ddac 100644 --- a/homeassistant/components/notify/group.py +++ b/homeassistant/components/notify/group.py @@ -8,8 +8,9 @@ import collections import logging import voluptuous as vol -from homeassistant.const import (CONF_PLATFORM, CONF_NAME, ATTR_SERVICE) +from homeassistant.const import ATTR_SERVICE from homeassistant.components.notify import (DOMAIN, ATTR_MESSAGE, ATTR_DATA, + PLATFORM_SCHEMA, BaseNotificationService) import homeassistant.helpers.config_validation as cv @@ -17,9 +18,7 @@ _LOGGER = logging.getLogger(__name__) CONF_SERVICES = "services" -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "group", - vol.Required(CONF_NAME): vol.Coerce(str), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERVICES): vol.All(cv.ensure_list, [{ vol.Required(ATTR_SERVICE): cv.slug, vol.Optional(ATTR_DATA): dict, diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py index ca82b6bb934..1478c2330ed 100644 --- a/homeassistant/components/notify/joaoapps_join.py +++ b/homeassistant/components/notify/joaoapps_join.py @@ -7,8 +7,9 @@ https://home-assistant.io/components/notify.join/ import logging import voluptuous as vol from homeassistant.components.notify import ( - ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService) -from homeassistant.const import CONF_PLATFORM, CONF_NAME, CONF_API_KEY + ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, + BaseNotificationService) +from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ @@ -19,10 +20,8 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE_ID = 'device_id' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'joaoapps_join', +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_API_KEY): cv.string }) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 35d6a5a6977..6d609555829 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -7,14 +7,15 @@ https://home-assistant.io/components/notify.telegram/ import io import logging import urllib + import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_DATA, BaseNotificationService) -from homeassistant.const import (CONF_API_KEY, CONF_NAME, ATTR_LOCATION, - ATTR_LATITUDE, ATTR_LONGITUDE, CONF_PLATFORM) + ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import (CONF_API_KEY, ATTR_LOCATION, ATTR_LATITUDE, + ATTR_LONGITUDE) _LOGGER = logging.getLogger(__name__) @@ -26,9 +27,7 @@ ATTR_CAPTION = "caption" CONF_CHAT_ID = 'chat_id' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "telegram", - vol.Optional(CONF_NAME): cv.string, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_CHAT_ID): cv.string, }) From 09d52820ddd68b2b8fe6c5f838e42b1a4ec96ac2 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Mon, 5 Sep 2016 15:32:14 +0100 Subject: [PATCH 117/208] Simple trend sensor. (#3073) * First cut of trend sensor. * Tidy. --- .../components/binary_sensor/trend.py | 145 +++++++++++ tests/components/binary_sensor/test_trend.py | 229 ++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 homeassistant/components/binary_sensor/trend.py create mode 100644 tests/components/binary_sensor/test_trend.py diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py new file mode 100644 index 00000000000..940f80a757b --- /dev/null +++ b/homeassistant/components/binary_sensor/trend.py @@ -0,0 +1,145 @@ +""" +A sensor that monitors trands in other components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.template/ +""" +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SENSOR_CLASSES_SCHEMA) +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_ENTITY_ID, + CONF_SENSOR_CLASS, + STATE_UNKNOWN,) +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import track_state_change + +_LOGGER = logging.getLogger(__name__) +CONF_SENSORS = 'sensors' +CONF_ATTRIBUTE = 'attribute' +CONF_INVERT = 'invert' + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA + +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the template sensors.""" + sensors = [] + + for device, device_config in config[CONF_SENSORS].items(): + entity_id = device_config[ATTR_ENTITY_ID] + attribute = device_config.get(CONF_ATTRIBUTE) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + sensor_class = device_config[CONF_SENSOR_CLASS] + invert = device_config[CONF_INVERT] + + sensors.append( + SensorTrend( + hass, + device, + friendly_name, + entity_id, + attribute, + sensor_class, + invert) + ) + if not sensors: + _LOGGER.error("No sensors added") + return False + add_devices(sensors) + return True + + +class SensorTrend(BinarySensorDevice): + """Representation of a Template Sensor.""" + + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__(self, hass, device_id, friendly_name, + target_entity, attribute, sensor_class, invert): + """Initialize the sensor.""" + self._hass = hass + self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, + hass=hass) + self._name = friendly_name + self._target_entity = target_entity + self._attribute = attribute + self._sensor_class = sensor_class + self._invert = invert + self._state = None + self.from_state = None + self.to_state = None + + self.update() + + def template_sensor_state_listener(entity, old_state, new_state): + """Called when the target device changes state.""" + self.from_state = old_state + self.to_state = new_state + self.update_ha_state(True) + + track_state_change(hass, target_entity, + template_sensor_state_listener) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def sensor_class(self): + """Return the sensor class of the sensor.""" + return self._sensor_class + + @property + def should_poll(self): + """No polling needed.""" + return False + + def update(self): + """Get the latest data and update the states.""" + if self.from_state is None or self.to_state is None: + return + if (self.from_state.state == STATE_UNKNOWN or + self.to_state.state == STATE_UNKNOWN): + return + try: + if self._attribute: + from_value = float( + self.from_state.attributes.get(self._attribute)) + to_value = float( + self.to_state.attributes.get(self._attribute)) + else: + from_value = float(self.from_state.state) + to_value = float(self.to_state.state) + + self._state = to_value > from_value + if self._invert: + self._state = not self._state + + except (ValueError, TypeError) as ex: + self._state = None + _LOGGER.error(ex) diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py new file mode 100644 index 00000000000..beb8683e97f --- /dev/null +++ b/tests/components/binary_sensor/test_trend.py @@ -0,0 +1,229 @@ +"""The test for the Trend sensor platform.""" +import homeassistant.bootstrap as bootstrap + +from tests.common import get_test_home_assistant + + +class TestTrendBinarySensor: + """Test the Trend sensor.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_up(self): + """Test up trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + def test_down(self): + """Test down trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test__invert_up(self): + """Test up trend with custom message.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'invert': "Yes" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_invert_down(self): + """Test down trend with custom message.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'invert': "Yes" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + def test_attribute_up(self): + """Test attribute up trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'attribute': 'attr' + } + } + } + }) + self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + def test_attribute_down(self): + """Test attribute down trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'attribute': 'attr' + } + } + } + }) + + self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) + + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_non_numeric(self): + """Test up trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + + self.hass.states.set('sensor.test_state', 'Non') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'Numeric') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_missing_attribute(self): + """Test attribute down trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'attribute': 'missing' + } + } + } + }) + + self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) + + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_invalid_name_does_not_create(self): + """Test invalid name.""" + assert not bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test INVALID sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + assert self.hass.states.all() == [] + + def test_invalid_sensor_does_not_create(self): + """Test invalid sensor.""" + assert not bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test_trend_sensor': { + 'not_entity_id': + "sensor.test_state" + } + } + } + }) + assert self.hass.states.all() == [] + + def test_no_sensors_does_not_create(self): + """Test no sensors.""" + assert not bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend' + } + }) + assert self.hass.states.all() == [] From aed59aea7da06eaadc3f5e247405ace3dd805756 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 17:39:21 +0200 Subject: [PATCH 118/208] Migrate to voluptuous (#3193) --- .../components/media_player/directv.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 7a32f02dc56..0a53ffbbed6 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -1,14 +1,22 @@ -"""Support for the DirecTV recievers.""" +""" +Support for the DirecTV recievers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.directv/ +""" +import voluptuous as vol from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING) + CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING, CONF_PORT) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['directpy==0.1'] +DEFAULT_NAME = 'DirecTV Receiver' DEFAULT_PORT = 8080 SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ @@ -17,6 +25,12 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ KNOWN_HOSTS = [] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the DirecTV platform.""" @@ -34,8 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif CONF_HOST in config: hosts.append([ - config.get(CONF_NAME, 'DirecTV Receiver'), - config[CONF_HOST], DEFAULT_PORT + config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT) ]) dtvs = [] From 9c600012a16f7742015abf12cf699b351d3f645c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 17:40:57 +0200 Subject: [PATCH 119/208] Migrate to voluptuous (#3194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/media_player/firetv.py | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 02b456a207c..518982a7038 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -7,46 +7,55 @@ https://home-assistant.io/components/media_player.firetv/ import logging import requests +import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, PLATFORM_SCHEMA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNKNOWN) + STATE_UNKNOWN, CONF_HOST, CONF_PORT, CONF_NAME, CONF_DEVICE, CONF_DEVICES) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET -DOMAIN = 'firetv' -DEVICE_LIST_URL = 'http://{0}/devices/list' -DEVICE_STATE_URL = 'http://{0}/devices/state/{1}' -DEVICE_ACTION_URL = 'http://{0}/devices/action/{1}/{2}' +DEFAULT_DEVICE = 'default' +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'Amazon Fire TV' +DEFAULT_PORT = 5556 +DEVICE_ACTION_URL = 'http://{0}:{1}/devices/action/{2}/{3}' +DEVICE_LIST_URL = 'http://{0}:{1}/devices/list' +DEVICE_STATE_URL = 'http://{0}:{1}/devices/state/{2}' -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the FireTV platform.""" - host = config.get('host', 'localhost:5556') - device_id = config.get('device', 'default') + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + device_id = config.get(CONF_DEVICE) + try: - response = requests.get(DEVICE_LIST_URL.format(host)).json() - if device_id in response['devices'].keys(): - add_devices([ - FireTVDevice( - host, - device_id, - config.get('name', 'Amazon Fire TV') - ) - ]) - _LOGGER.info( - 'Device %s accessible and ready for control', device_id) + response = requests.get(DEVICE_LIST_URL.format(host, port)).json() + if device_id in response[CONF_DEVICES].keys(): + add_devices([FireTVDevice(host, port, device_id, name)]) + _LOGGER.info('Device %s accessible and ready for control', + device_id) else: - _LOGGER.warning( - 'Device %s is not registered with firetv-server', device_id) + _LOGGER.warning('Device %s is not registered with firetv-server', + device_id) except requests.exceptions.RequestException: _LOGGER.error('Could not connect to firetv-server at %s', host) @@ -62,9 +71,10 @@ class FireTV(object): be running via Python 2). """ - def __init__(self, host, device_id): + def __init__(self, host, port, device_id): """Initialize the FireTV server.""" self.host = host + self.port = port self.device_id = device_id @property @@ -73,10 +83,7 @@ class FireTV(object): try: response = requests.get( DEVICE_STATE_URL.format( - self.host, - self.device_id - ) - ).json() + self.host, self.port, self.device_id), timeout=10).json() return response.get('state', STATE_UNKNOWN) except requests.exceptions.RequestException: _LOGGER.error( @@ -86,13 +93,8 @@ class FireTV(object): def action(self, action_id): """Perform an action on the device.""" try: - requests.get( - DEVICE_ACTION_URL.format( - self.host, - self.device_id, - action_id - ) - ) + requests.get(DEVICE_ACTION_URL.format( + self.host, self.port, self.device_id, action_id), timeout=10) except requests.exceptions.RequestException: _LOGGER.error( 'Action request for %s was not accepted for device %s', @@ -103,9 +105,9 @@ class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" # pylint: disable=abstract-method - def __init__(self, host, device, name): + def __init__(self, host, port, device, name): """Initialize the FireTV device.""" - self._firetv = FireTV(host, device) + self._firetv = FireTV(host, port, device) self._name = name self._state = STATE_UNKNOWN From 6bbe3483d9b87f21505750b6f9b5a8737fa9d926 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 17:45:06 +0200 Subject: [PATCH 120/208] Migrate to voluptuous (#3197) --- .../components/media_player/gpmdp.py | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index 4fcdff872e2..430db46bca2 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -9,24 +9,41 @@ import json import os import socket +import voluptuous as vol + from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, - SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_SEEK, MediaPlayerDevice) + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_SEEK, MediaPlayerDevice, + PLATFORM_SCHEMA) from homeassistant.const import ( - STATE_PLAYING, STATE_PAUSED, STATE_OFF) + STATE_PLAYING, STATE_PAUSED, STATE_OFF, CONF_HOST, CONF_PORT, CONF_NAME) from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['websocket-client==0.37.0'] + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'GPM Desktop Player' +DEFAULT_PORT = 5672 + +GPMDP_CONFIG_FILE = 'gpmpd.conf' + SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SEEK | SUPPORT_VOLUME_SET -GPMDP_CONFIG_FILE = 'gpmpd.conf' -_CONFIGURING = {} PLAYBACK_DICT = {'0': STATE_PAUSED, # Stopped '1': STATE_PAUSED, '2': STATE_PLAYING} +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + def request_configuration(hass, config, url, add_devices_callback): """Request configuration steps from the user.""" @@ -78,7 +95,7 @@ def request_configuration(hass, config, url, add_devices_callback): break _CONFIGURING['gpmdp'] = configurator.request_config( - hass, "GPM Desktop Player", gpmdp_configuration_callback, + hass, DEFAULT_NAME, gpmdp_configuration_callback, description=( 'Enter the pin that is displayed in the ' 'Google Play Music Desktop Player.'), @@ -87,21 +104,22 @@ def request_configuration(hass, config, url, add_devices_callback): ) -def setup_gpmdp(hass, config, code, add_devices_callback): +def setup_gpmdp(hass, config, code, add_devices): """Setup gpmdp.""" - name = config.get("name", "GPM Desktop Player") - address = config.get("address") - url = "ws://" + address + ":5672" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + url = 'ws://{}:{}'.format(host, port) if not code: - request_configuration(hass, config, url, add_devices_callback) + request_configuration(hass, config, url, add_devices) return if 'gpmdp' in _CONFIGURING: configurator = get_component('configurator') configurator.request_done(_CONFIGURING.pop('gpmdp')) - add_devices_callback([GPMDP(name, url, code)]) + add_devices([GPMDP(name, url, code)]) def _load_config(filename): @@ -110,7 +128,7 @@ def _load_config(filename): return {} try: - with open(filename, "r") as fdesc: + with open(filename, 'r') as fdesc: inp = fdesc.read() # In case empty file @@ -126,10 +144,10 @@ def _load_config(filename): def _save_config(filename, config): """Save configuration.""" try: - with open(filename, "w") as fdesc: + with open(filename, 'w') as fdesc: fdesc.write(json.dumps(config, indent=4, sort_keys=True)) except (IOError, TypeError) as error: - _LOGGER.error("Saving config file failed: %s", error) + _LOGGER.error("Saving configuration file failed: %s", error) return False return True @@ -138,7 +156,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the GPMDP platform.""" codeconfig = _load_config(hass.config.path(GPMDP_CONFIG_FILE)) if len(codeconfig): - code = codeconfig.get("CODE") + code = codeconfig.get('CODE') elif discovery_info is not None: if 'gpmdp' in _CONFIGURING: return @@ -258,7 +276,7 @@ class GPMDP(MediaPlayerDevice): @property def media_seek_position(self): - """Time in seconds of current seek positon.""" + """Time in seconds of current seek position.""" return self._seek_position @property @@ -306,9 +324,9 @@ class GPMDP(MediaPlayerDevice): websocket = self.get_ws() if websocket is None: return - websocket.send(json.dumps({"namespace": "playback", - "method": "setCurrentTime", - "arguments": [position*1000]})) + websocket.send(json.dumps({'namespace': 'playback', + 'method': 'setCurrentTime', + 'arguments': [position*1000]})) self.update_ha_state() def volume_up(self): @@ -332,7 +350,7 @@ class GPMDP(MediaPlayerDevice): websocket = self.get_ws() if websocket is None: return - websocket.send(json.dumps({"namespace": "volume", - "method": "setVolume", - "arguments": [volume*100]})) + websocket.send(json.dumps({'namespace': 'volume', + 'method': 'setVolume', + 'arguments': [volume*100]})) self.update_ha_state() From 8afed2cafa5255289c4723102a0be9ce7a634d0a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 17:46:57 +0200 Subject: [PATCH 121/208] Migrate to voluptuous (#3198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/media_player/kodi.py | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index e28d84417d6..224f0d48827 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -7,24 +7,45 @@ https://home-assistant.io/components/media_player.kodi/ import logging import urllib +import voluptuous as vol + from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_TURN_OFF, MediaPlayerDevice) + SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) + STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, + CONF_PORT, CONF_USERNAME, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['jsonrpc-requests==0.3'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['jsonrpc-requests==0.3'] + +CONF_TURN_OFF_ACTION = 'turn_off_action' + +DEFAULT_NAME = 'Kodi' +DEFAULT_PORT = 8080 + +TURN_OFF_ACTION = [None, 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown'] SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ SUPPORT_PLAY_MEDIA | SUPPORT_STOP +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION), + vol.Optional(CONF_USERNAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Kodi platform.""" - url = '{}:{}'.format(config.get('host'), config.get('port', '8080')) + url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT)) jsonrpc_url = config.get('url') # deprecated if jsonrpc_url: @@ -32,12 +53,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ KodiDevice( - config.get('name', 'Kodi'), + config.get(CONF_NAME), url, - auth=( - config.get('user', ''), - config.get('password', '')), - turn_off_action=config.get('turn_off_action', 'none')), + auth=(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)), + turn_off_action=config.get(CONF_TURN_OFF_ACTION)), ]) @@ -176,19 +195,14 @@ class KodiDevice(MediaPlayerDevice): if self._item is not None: return self._item.get( 'title', - self._item.get( - 'label', - self._item.get( - 'file', - 'unknown'))) + self._item.get('label', self._item.get('file', 'unknown'))) @property def supported_media_commands(self): """Flag of media commands that are supported.""" supported_media_commands = SUPPORT_KODI - if self._turn_off_action in [ - 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown']: + if self._turn_off_action in TURN_OFF_ACTION: supported_media_commands |= SUPPORT_TURN_OFF return supported_media_commands From e324885ff66335f018834c474543052d652068f8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 17:47:53 +0200 Subject: [PATCH 122/208] Use extend of PLATFORM_SCHEMA (#3199) --- .../components/media_player/lg_netcast.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index fc0609a7c34..26b7341f747 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -9,32 +9,32 @@ import logging from requests import RequestException import voluptuous as vol + import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, PLATFORM_SCHEMA, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, MEDIA_TYPE_CHANNEL, MediaPlayerDevice) from homeassistant.const import ( - CONF_PLATFORM, CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, + CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) import homeassistant.util as util -_LOGGER = logging.getLogger(__name__) - REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' 'v0.2.0.zip#pylgnetcast==0.2.0'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'LG TV Remote' + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) - -DEFAULT_NAME = 'LG TV Remote' - -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "lg_netcast", +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_ACCESS_TOKEN, default=None): @@ -46,7 +46,9 @@ PLATFORM_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the LG TV platform.""" from pylgnetcast import LgNetCastClient - client = LgNetCastClient(config[CONF_HOST], config[CONF_ACCESS_TOKEN]) + client = LgNetCastClient(config.get(CONF_HOST), + config.get(CONF_ACCESS_TOKEN)) + add_devices([LgTVDevice(client, config[CONF_NAME])]) From 909b5ffa5b2554cf791b7971e861fe7fb4c018a8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 17:51:18 +0200 Subject: [PATCH 123/208] Migrate to voluptuous (#3202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/media_player/mpd.py | 43 ++++++++++++-------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index c04184d6bda..56af3cd88f9 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -7,28 +7,46 @@ https://home-assistant.io/components/media_player.mpd/ import logging import socket +import voluptuous as vol + from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_PLAYLIST, MediaPlayerDevice) -from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING +from homeassistant.const import ( + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, + CONF_HOST) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-mpd2==0.5.5'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-mpd2==0.5.5'] + +CONF_LOCATION = 'location' + +DEFAULT_LOCATION = 'MPD' +DEFAULT_PORT = 6600 SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_PLAY_MEDIA +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MPD platform.""" - daemon = config.get('server', None) - port = config.get('port', 6600) - location = config.get('location', 'MPD') - password = config.get('password', None) + daemon = config.get(CONF_HOST) + port = config.get(CONF_PORT) + location = config.get(CONF_LOCATION) + password = config.get(CONF_PASSWORD) import mpd @@ -43,18 +61,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): mpd_client.close() mpd_client.disconnect() except socket.error: - _LOGGER.error( - "Unable to connect to MPD. " - "Please check your settings") - + _LOGGER.error("Unable to connect to MPD") return False except mpd.CommandError as error: if "incorrect password" in str(error): - _LOGGER.error( - "MPD reported incorrect password. " - "Please check your password.") - + _LOGGER.error("MPD reported incorrect password") return False else: raise @@ -65,7 +77,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MpdDevice(MediaPlayerDevice): """Representation of a MPD server.""" - # MPD confuses pylint # pylint: disable=no-member, too-many-public-methods, abstract-method def __init__(self, server, port, location, password): """Initialize the MPD device.""" From 3bbd909b2094e932d82e2789f37f64f979c86c67 Mon Sep 17 00:00:00 2001 From: arsaboo Date: Mon, 5 Sep 2016 11:55:29 -0400 Subject: [PATCH 124/208] Updated to use the occupancy sensor_class (#3204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/binary_sensor/ecobee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py index ab6a58593b3..93583ff08b1 100644 --- a/homeassistant/components/binary_sensor/ecobee.py +++ b/homeassistant/components/binary_sensor/ecobee.py @@ -38,7 +38,7 @@ class EcobeeBinarySensor(BinarySensorDevice): self.sensor_name = sensor_name self.index = sensor_index self._state = None - self._sensor_class = 'motion' + self._sensor_class = 'occupancy' self.update() @property From 5059d8dde9e2fbffaa34bdbb74e3fd9eb89ebb33 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 18:01:50 +0200 Subject: [PATCH 125/208] Migrate to voluptuous (#3206) --- .../components/media_player/onkyo.py | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index d1b5282fa6e..53cd0b68df9 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -6,55 +6,69 @@ https://home-assistant.io/components/media_player.onkyo/ """ import logging +import voluptuous as vol + from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice) -from homeassistant.const import STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME + SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.const import (STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/danieljkemp/onkyo-eiscp/archive/' 'python3.zip#onkyo-eiscp==0.9.2'] + _LOGGER = logging.getLogger(__name__) +CONF_SOURCES = 'sources' + +DEFAULT_NAME = 'Onkyo Receiver' + +KNOWN_HOSTS = [] + SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE -KNOWN_HOSTS = [] -DEFAULT_SOURCES = {"tv": "TV", "bd": "Bluray", "game": "Game", "aux1": "Aux1", - "video1": "Video 1", "video2": "Video 2", - "video3": "Video 3", "video4": "Video 4", - "video5": "Video 5", "video6": "Video 6", - "video7": "Video 7"} -CONFIG_SOURCE_LIST = "sources" + +DEFAULT_SOURCES = {'tv': 'TV', 'bd': 'Bluray', 'game': 'Game', 'aux1': 'Aux1', + 'video1': 'Video 1', 'video2': 'Video 2', + 'video3': 'Video 3', 'video4': 'Video 4', + 'video5': 'Video 5', 'video6': 'Video 6', + 'video7': 'Video 7'} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): + {cv.string: cv.string}, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Onkyo platform.""" import eiscp from eiscp import eISCP + + host = config.get(CONF_HOST) hosts = [] - if CONF_HOST in config and config[CONF_HOST] not in KNOWN_HOSTS: + if CONF_HOST in config and host not in KNOWN_HOSTS: try: - hosts.append(OnkyoDevice(eiscp.eISCP(config[CONF_HOST]), - config.get(CONFIG_SOURCE_LIST, - DEFAULT_SOURCES), - name=config[CONF_NAME])) - KNOWN_HOSTS.append(config[CONF_HOST]) + hosts.append(OnkyoDevice(eiscp.eISCP(host), + config.get(CONF_SOURCES), + name=config.get(CONF_NAME))) + KNOWN_HOSTS.append(host) except OSError: - _LOGGER.error('Unable to connect to receiver at %s.', - config[CONF_HOST]) + _LOGGER.error('Unable to connect to receiver at %s.', host) else: for receiver in eISCP.discover(): if receiver.host not in KNOWN_HOSTS: - hosts.append(OnkyoDevice(receiver, - config.get(CONFIG_SOURCE_LIST, - DEFAULT_SOURCES))) + hosts.append(OnkyoDevice(receiver, config.get(CONF_SOURCES))) KNOWN_HOSTS.append(receiver.host) add_devices(hosts) # pylint: disable=too-many-instance-attributes class OnkyoDevice(MediaPlayerDevice): - """Representation of a Onkyo device.""" + """Representation of an Onkyo device.""" # pylint: disable=too-many-public-methods, abstract-method def __init__(self, receiver, sources, name=None): @@ -90,7 +104,7 @@ class OnkyoDevice(MediaPlayerDevice): self._current_source = '_'.join( [i for i in current_source_raw[1]]) self._muted = bool(mute_raw[1] == 'on') - self._volume = int(volume_raw[1], 16)/80.0 + self._volume = int(volume_raw[1], 16) / 80.0 @property def name(self): From 95ea0c02b968a3a9fe9cd5bbc5f820bfea250b97 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 18:03:25 +0200 Subject: [PATCH 126/208] Migrate to voluptuous (#3207) --- .../media_player/panasonic_viera.py | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index ddc547ff807..488e4e6b9d8 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -7,33 +7,42 @@ https://home-assistant.io/components/media_player.panasonic_viera/ import logging import socket -from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) -from homeassistant.helpers import validate_config +import voluptuous as vol -CONF_PORT = "port" +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['panasonic_viera==0.2'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['panasonic_viera==0.2'] +DEFAULT_NAME = 'Panasonic Viera TV' +DEFAULT_PORT = 55000 SUPPORT_VIERATV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_TURN_OFF +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Panasonic Viera TV platform.""" - from panasonic_viera import DEFAULT_PORT, RemoteControl + from panasonic_viera import RemoteControl - name = config.get(CONF_NAME, 'Panasonic Viera TV') - port = config.get(CONF_PORT, DEFAULT_PORT) + name = config.get(CONF_NAME) + port = config.get(CONF_PORT) if discovery_info: _LOGGER.debug('%s', discovery_info) @@ -46,13 +55,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([PanasonicVieraTVDevice(name, remote)]) return True - # Validate that all required config options are given - if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_HOST]}, _LOGGER): - return False - - host = config.get(CONF_HOST, None) - + host = config.get(CONF_HOST) remote = RemoteControl(host, port) + try: remote.get_mute() except (socket.timeout, TimeoutError, OSError): From 6be20883f066e535ff02288f8b86e40a6f0ce4f0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 18:04:46 +0200 Subject: [PATCH 127/208] Migrate to voluptuous (#3208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/media_player/pioneer.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/pioneer.py b/homeassistant/components/media_player/pioneer.py index 207e38ecf40..599edf08b37 100644 --- a/homeassistant/components/media_player/pioneer.py +++ b/homeassistant/components/media_player/pioneer.py @@ -7,36 +7,35 @@ https://home-assistant.io/components/media_player.pioneer/ import logging import telnetlib +import voluptuous as vol + from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_PAUSE, SUPPORT_SELECT_SOURCE, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - MediaPlayerDevice) + SUPPORT_PAUSE, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) from homeassistant.const import ( - CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN, - CONF_NAME) + CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_NAME) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Pioneer AVR' + SUPPORT_PIONEER = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE MAX_VOLUME = 185 MAX_SOURCE_NUMBERS = 60 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Pioneer platform.""" - if not config.get(CONF_HOST): - _LOGGER.error( - "Missing required configuration items in %s: %s", - DOMAIN, - CONF_HOST) - return False + pioneer = PioneerDevice(config.get(CONF_NAME), config.get(CONF_HOST)) - pioneer = PioneerDevice( - config.get(CONF_NAME, "Pioneer AVR"), - config.get(CONF_HOST) - ) if pioneer.update(): add_devices([pioneer]) return True @@ -53,7 +52,7 @@ class PioneerDevice(MediaPlayerDevice): """Initialize the Pioneer device.""" self._name = name self._host = host - self._pwstate = "PWR1" + self._pwstate = 'PWR1' self._volume = 0 self._muted = False self._selected_source = '' From 6b787ee01e88a55afa2c21195fdd7df9933a771d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 18:05:27 +0200 Subject: [PATCH 128/208] Migrate to voluptuous (#3209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/media_player/roku.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 7951530e3e8..e7a87d2d773 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/media_player.roku/ """ import logging +import voluptuous as vol + from homeassistant.components.media_player import ( MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice) - + SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ 'https://github.com/bah2830/python-roku/archive/3.1.2.zip' @@ -27,6 +29,10 @@ SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_SELECT_SOURCE +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, +}) + # pylint: disable=abstract-method def setup_platform(hass, config, add_devices, discovery_info=None): @@ -41,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts.append(discovery_info[0]) elif CONF_HOST in config: - hosts.append(config[CONF_HOST]) + hosts.append(config.get(CONF_HOST)) rokus = [] for host in hosts: From ea1e4ea215ea5b64d839bca93ce93f2e436a57e0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 19:22:26 +0200 Subject: [PATCH 129/208] Migrate to voluptuous (#3214) --- .../components/media_player/samsungtv.py | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 61768b91f96..5c096c86bb0 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -7,44 +7,51 @@ https://home-assistant.io/components/media_player.samsungtv/ import logging import socket -from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) -from homeassistant.helpers import validate_config +import voluptuous as vol -CONF_PORT = "port" -CONF_TIMEOUT = "timeout" +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['samsungctl==0.5.1'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['samsungctl==0.5.1'] +CONF_TIMEOUT = 'timeout' + +DEFAULT_NAME = 'Samsung TV Remote' +DEFAULT_PORT = 55000 +DEFAULT_TIMEOUT = 0 SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Samsung TV platform.""" - # Validate that all required config options are given - if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_HOST]}, _LOGGER): - return False + name = config.get(CONF_NAME) - # Default the entity_name to 'Samsung TV Remote' - name = config.get(CONF_NAME, 'Samsung TV Remote') - - # Generate a config for the Samsung lib + # Generate a configuration for the Samsung library remote_config = { - "name": "HomeAssistant", - "description": config.get(CONF_NAME, ''), - "id": "ha.component.samsung", - "port": config.get(CONF_PORT, 55000), - "host": config.get(CONF_HOST), - "timeout": config.get(CONF_TIMEOUT, 0), + 'name': 'HomeAssistant', + 'description': config.get(CONF_NAME), + 'id': 'ha.component.samsung', + 'port': config.get(CONF_PORT), + 'host': config.get(CONF_HOST), + 'timeout': config.get(CONF_TIMEOUT), } add_devices([SamsungTVDevice(name, remote_config)]) @@ -56,7 +63,7 @@ class SamsungTVDevice(MediaPlayerDevice): # pylint: disable=too-many-public-methods def __init__(self, name, config): - """Initialize the samsung device.""" + """Initialize the Samsung device.""" from samsungctl import Remote # Save a reference to the imported class self._remote_class = Remote @@ -124,19 +131,19 @@ class SamsungTVDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" - self.send_key("KEY_POWEROFF") + self.send_key('KEY_POWEROFF') def volume_up(self): """Volume up the media player.""" - self.send_key("KEY_VOLUP") + self.send_key('KEY_VOLUP') def volume_down(self): """Volume down media player.""" - self.send_key("KEY_VOLDOWN") + self.send_key('KEY_VOLDOWN') def mute_volume(self, mute): """Send mute command.""" - self.send_key("KEY_MUTE") + self.send_key('KEY_MUTE') def media_play_pause(self): """Simulate play pause media player.""" @@ -148,21 +155,21 @@ class SamsungTVDevice(MediaPlayerDevice): def media_play(self): """Send play command.""" self._playing = True - self.send_key("KEY_PLAY") + self.send_key('KEY_PLAY') def media_pause(self): """Send media pause command to media player.""" self._playing = False - self.send_key("KEY_PAUSE") + self.send_key('KEY_PAUSE') def media_next_track(self): """Send next track command.""" - self.send_key("KEY_FF") + self.send_key('KEY_FF') def media_previous_track(self): """Send the previous track command.""" - self.send_key("KEY_REWIND") + self.send_key('KEY_REWIND') def turn_on(self): """Turn the media player on.""" - self.send_key("KEY_POWERON") + self.send_key('KEY_POWERON') From 428db4a644c33aaefda895aa6551df84f3d84f71 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 19:27:06 +0200 Subject: [PATCH 130/208] Use voluptuous for SqueezeBox (#3212) * Migrate to voluptuous * Remove name --- .../components/media_player/squeezebox.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index d49eba609e1..2f8c214fe3a 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -8,48 +8,53 @@ import logging import telnetlib import urllib.parse +import voluptuous as vol + from homeassistant.components.media_player import ( - DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF, - STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) + STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 9090 + +KNOWN_DEVICES = [] + SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SEEK | SUPPORT_TURN_ON | SUPPORT_TURN_OFF -KNOWN_DEVICES = [] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the squeezebox platform.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + if discovery_info is not None: host = discovery_info[0] - port = 9090 + port = DEFAULT_PORT else: host = config.get(CONF_HOST) - port = int(config.get('port', 9090)) - - if not host: - _LOGGER.error( - "Missing required configuration items in %s: %s", - DOMAIN, - CONF_HOST) - return False + port = config.get(CONF_PORT) # Only add a media server once if host in KNOWN_DEVICES: return False KNOWN_DEVICES.append(host) - lms = LogitechMediaServer( - host, port, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD)) + lms = LogitechMediaServer(host, port, username, password) if not lms.init_success: return False @@ -77,18 +82,13 @@ class LogitechMediaServer(object): try: http_port = self.query('pref', 'httpport', '?') if not http_port: - _LOGGER.error( - "Unable to read data from server %s:%s", - self.host, - self.port) + _LOGGER.error("Unable to read data from server %s:%s", + self.host, self.port) return return http_port except ConnectionError as ex: - _LOGGER.error( - "Failed to connect to server %s:%s - %s", - self.host, - self.port, - ex) + _LOGGER.error("Failed to connect to server %s:%s - %s", + self.host, self.port, ex) return def create_players(self): From 4638696f8c0c2b0fe5f515d6c296f881b11ae54c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 19:33:35 +0200 Subject: [PATCH 131/208] Migrate to voluptuous and upgrade uber_rides to 0.2.5 (#3181) --- homeassistant/components/sensor/uber.py | 187 +++++++++++++----------- requirements_all.txt | 2 +- 2 files changed, 100 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index a27f8ca4def..7f250431984 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -7,50 +7,61 @@ https://home-assistant.io/components/sensor.uber/ import logging from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['uber_rides==0.2.5'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["uber_rides==0.2.4"] -ICON = "mdi:taxi" +CONF_END_LATITUDE = 'end_latitude' +CONF_END_LONGITUDE = 'end_longitude' +CONF_PRODUCT_IDS = 'product_ids' +CONF_SERVER_TOKEN = 'server_token' +CONF_START_LATITUDE = 'start_latitude' +CONF_START_LONGITUDE = 'start_longitude' + +ICON = 'mdi:taxi' -# Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVER_TOKEN): cv.string, + vol.Required(CONF_START_LATITUDE): cv.latitude, + vol.Required(CONF_START_LONGITUDE): cv.longitude, + vol.Optional(CONF_END_LATITUDE): cv.latitude, + vol.Optional(CONF_END_LONGITUDE): cv.longitude, + vol.Optional(CONF_PRODUCT_IDS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Uber sensor.""" - if None in (config.get("start_latitude"), config.get("start_longitude")): - _LOGGER.error( - "You must set start latitude and longitude to use the Uber sensor!" - ) - return False - - if config.get("server_token") is None: - _LOGGER.error("You must set a server_token to use the Uber sensor!") - return False - from uber_rides.session import Session - session = Session(server_token=config.get("server_token")) + session = Session(server_token=config.get(CONF_SERVER_TOKEN)) - wanted_product_ids = config.get("product_ids") + wanted_product_ids = config.get(CONF_PRODUCT_IDS) dev = [] - timeandpriceest = UberEstimate(session, config["start_latitude"], - config["start_longitude"], - config.get("end_latitude"), - config.get("end_longitude")) + timeandpriceest = UberEstimate(session, config[CONF_START_LATITUDE], + config[CONF_START_LONGITUDE], + config.get(CONF_END_LATITUDE), + config.get(CONF_END_LONGITUDE)) for product_id, product in timeandpriceest.products.items(): if (wanted_product_ids is not None) and \ (product_id not in wanted_product_ids): continue - dev.append(UberSensor("time", timeandpriceest, product_id, product)) - if (product.get("price_details") is not None) and \ - product["price_details"]["estimate"] is not "Metered": - dev.append(UberSensor("price", timeandpriceest, - product_id, product)) + dev.append(UberSensor('time', timeandpriceest, product_id, product)) + if (product.get('price_details') is not None) and \ + product['price_details']['estimate'] is not 'Metered': + dev.append(UberSensor( + 'price', timeandpriceest, product_id, product)) add_devices(dev) @@ -64,20 +75,20 @@ class UberSensor(Entity): self._product_id = product_id self._product = product self._sensortype = sensorType - self._name = "{} {}".format(self._product["display_name"], + self._name = '{} {}'.format(self._product['display_name'], self._sensortype) - if self._sensortype == "time": - self._unit_of_measurement = "min" - time_estimate = self._product.get("time_estimate_seconds", 0) + if self._sensortype == 'time': + self._unit_of_measurement = 'min' + time_estimate = self._product.get('time_estimate_seconds', 0) self._state = int(time_estimate / 60) - elif self._sensortype == "price": - if self._product.get("price_details") is not None: - price_details = self._product["price_details"] - self._unit_of_measurement = price_details.get("currency_code") - if price_details.get("low_estimate") is not None: - statekey = "minimum" + elif self._sensortype == 'price': + if self._product.get('price_details') is not None: + price_details = self._product['price_details'] + self._unit_of_measurement = price_details.get('currency_code') + if price_details.get('low_estimate') is not None: + statekey = 'minimum' else: - statekey = "low_estimate" + statekey = 'low_estimate' self._state = int(price_details.get(statekey, 0)) else: self._state = 0 @@ -86,8 +97,8 @@ class UberSensor(Entity): @property def name(self): """Return the name of the sensor.""" - if "uber" not in self._name.lower(): - self._name = "Uber{}".format(self._name) + if 'uber' not in self._name.lower(): + self._name = 'Uber{}'.format(self._name) return self._name @property @@ -105,35 +116,35 @@ class UberSensor(Entity): """Return the state attributes.""" time_estimate = self._product.get("time_estimate_seconds") params = { - "Product ID": self._product["product_id"], - "Product short description": self._product["short_description"], - "Product display name": self._product["display_name"], - "Product description": self._product["description"], - "Pickup time estimate (in seconds)": time_estimate, - "Trip duration (in seconds)": self._product.get("duration"), - "Vehicle Capacity": self._product["capacity"] + 'Product ID': self._product['product_id'], + 'Product short description': self._product['short_description'], + 'Product display name': self._product['display_name'], + 'Product description': self._product['description'], + 'Pickup time estimate (in seconds)': time_estimate, + 'Trip duration (in seconds)': self._product.get('duration'), + 'Vehicle Capacity': self._product['capacity'] } - if self._product.get("price_details") is not None: - price_details = self._product["price_details"] - dunit = price_details.get("distance_unit") - distance_key = "Trip distance (in {}s)".format(dunit) - distance_val = self._product.get("distance") - params["Cost per minute"] = price_details.get("cost_per_minute") - params["Distance units"] = price_details.get("distance_unit") - params["Cancellation fee"] = price_details.get("cancellation_fee") - cpd = price_details.get("cost_per_distance") - params["Cost per distance"] = cpd - params["Base price"] = price_details.get("base") - params["Minimum price"] = price_details.get("minimum") - params["Price estimate"] = price_details.get("estimate") - params["Price currency code"] = price_details.get("currency_code") - params["High price estimate"] = price_details.get("high_estimate") - params["Low price estimate"] = price_details.get("low_estimate") - params["Surge multiplier"] = price_details.get("surge_multiplier") + if self._product.get('price_details') is not None: + price_details = self._product['price_details'] + dunit = price_details.get('distance_unit') + distance_key = 'Trip distance (in {}s)'.format(dunit) + distance_val = self._product.get('distance') + params['Cost per minute'] = price_details.get('cost_per_minute') + params['Distance units'] = price_details.get('distance_unit') + params['Cancellation fee'] = price_details.get('cancellation_fee') + cpd = price_details.get('cost_per_distance') + params['Cost per distance'] = cpd + params['Base price'] = price_details.get('base') + params['Minimum price'] = price_details.get('minimum') + params['Price estimate'] = price_details.get('estimate') + params['Price currency code'] = price_details.get('currency_code') + params['High price estimate'] = price_details.get('high_estimate') + params['Low price estimate'] = price_details.get('low_estimate') + params['Surge multiplier'] = price_details.get('surge_multiplier') else: - distance_key = "Trip distance (in miles)" - distance_val = self._product.get("distance") + distance_key = 'Trip distance (in miles)' + distance_val = self._product.get('distance') params[distance_key] = distance_val @@ -149,14 +160,14 @@ class UberSensor(Entity): """Get the latest data from the Uber API and update the states.""" self.data.update() self._product = self.data.products[self._product_id] - if self._sensortype == "time": - time_estimate = self._product.get("time_estimate_seconds", 0) + if self._sensortype == 'time': + time_estimate = self._product.get('time_estimate_seconds', 0) self._state = int(time_estimate / 60) - elif self._sensortype == "price": - price_details = self._product.get("price_details") + elif self._sensortype == 'price': + price_details = self._product.get('price_details') if price_details is not None: - min_price = price_details.get("minimum") - self._state = int(price_details.get("low_estimate", min_price)) + min_price = price_details.get('minimum') + self._state = int(price_details.get('low_estimate', min_price)) else: self._state = 0 @@ -188,39 +199,39 @@ class UberEstimate(object): products_response = client.get_products( self.start_latitude, self.start_longitude) - products = products_response.json.get("products") + products = products_response.json.get('products') for product in products: - self.products[product["product_id"]] = product + self.products[product['product_id']] = product if self.end_latitude is not None and self.end_longitude is not None: price_response = client.get_price_estimates( self.start_latitude, self.start_longitude, self.end_latitude, self.end_longitude) - prices = price_response.json.get("prices", []) + prices = price_response.json.get('prices', []) for price in prices: - product = self.products[price["product_id"]] - product["duration"] = price.get("duration", "0") - product["distance"] = price.get("distance", "0") - price_details = product.get("price_details") - if product.get("price_details") is None: + product = self.products[price['product_id']] + product['duration'] = price.get('duration', '0') + product['distance'] = price.get('distance', '0') + price_details = product.get('price_details') + if product.get('price_details') is None: price_details = {} - price_details["estimate"] = price.get("estimate", "0") - price_details["high_estimate"] = price.get("high_estimate", - "0") - price_details["low_estimate"] = price.get("low_estimate", "0") - price_details["currency_code"] = price.get("currency_code") - surge_multiplier = price.get("surge_multiplier", "0") - price_details["surge_multiplier"] = surge_multiplier - product["price_details"] = price_details + price_details['estimate'] = price.get('estimate', '0') + price_details['high_estimate'] = price.get('high_estimate', + '0') + price_details['low_estimate'] = price.get('low_estimate', '0') + price_details['currency_code'] = price.get('currency_code') + surge_multiplier = price.get('surge_multiplier', '0') + price_details['surge_multiplier'] = surge_multiplier + product['price_details'] = price_details estimate_response = client.get_pickup_time_estimates( self.start_latitude, self.start_longitude) - estimates = estimate_response.json.get("times") + estimates = estimate_response.json.get('times') for estimate in estimates: - self.products[estimate["product_id"]][ - "time_estimate_seconds"] = estimate.get("estimate", "0") + self.products[estimate['product_id']][ + 'time_estimate_seconds'] = estimate.get('estimate', '0') diff --git a/requirements_all.txt b/requirements_all.txt index 6ea4bd774ad..8304eb43ae4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -471,7 +471,7 @@ transmissionrpc==0.11 twilio==5.4.0 # homeassistant.components.sensor.uber -uber_rides==0.2.4 +uber_rides==0.2.5 # homeassistant.components.device_tracker.unifi unifi==1.2.5 From e0a6d7941c85a152b16aca9aba5221290bddedf2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 19:34:35 +0200 Subject: [PATCH 132/208] Migrate to voluptuous (#3200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/media_player/mpchc.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/media_player/mpchc.py b/homeassistant/components/media_player/mpchc.py index 8f551f8ae8f..8563b551a09 100644 --- a/homeassistant/components/media_player/mpchc.py +++ b/homeassistant/components/media_player/mpchc.py @@ -6,29 +6,39 @@ https://home-assistant.io/components/media_player.mpchc/ """ import logging import re + import requests +import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_NEXT_TRACK, - SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_STEP, MediaPlayerDevice) + SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_STEP, MediaPlayerDevice, + PLATFORM_SCHEMA) from homeassistant.const import ( - STATE_OFF, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) + STATE_OFF, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, CONF_NAME, CONF_HOST, + CONF_PORT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'MPC-HC' +DEFAULT_PORT = 13579 + SUPPORT_MPCHC = SUPPORT_VOLUME_MUTE | SUPPORT_PAUSE | SUPPORT_STOP | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_STEP +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MPC-HC platform.""" - name = config.get("name", "MPC-HC") - url = '{}:{}'.format(config.get('host'), config.get('port', '13579')) - - if config.get('host') is None: - _LOGGER.error("Missing NPC-HC host address in config") - return False + name = config.get(CONF_NAME) + url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT)) add_devices([MpcHcDevice(name, url)]) @@ -49,7 +59,7 @@ class MpcHcDevice(MediaPlayerDevice): self._player_variables = dict() try: - response = requests.get("{}/variables.html".format(self._url), + response = requests.get('{}/variables.html'.format(self._url), data=None, timeout=3) mpchc_variables = re.findall(r'

(.+?)

', From 17a2cac7e1af7a4e1e1d2d247a2ee8b3db7847b0 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Mon, 5 Sep 2016 19:37:36 +0200 Subject: [PATCH 133/208] Use Voluptuous for Luci and Netgear device trackers (#3123) * Use Voluptuous for Luci and NEtgear device trackers * str_schema shortcut * Undo str_schema --- .../components/device_tracker/luci.py | 26 +++++++------ .../components/device_tracker/netgear.py | 39 ++++++++++--------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index 3b0c7c0bbe5..b97993f9afa 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -11,10 +11,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -22,14 +23,15 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and return a Luci scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = LuciDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -93,7 +95,7 @@ class LuciDeviceScanner(object): return False with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info('Checking ARP') url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) result = _req_json_rpc(url, 'net.arptable', @@ -117,19 +119,19 @@ def _req_json_rpc(url, method, *args, **kwargs): try: res = requests.post(url, data=data, timeout=5, **kwargs) except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") + _LOGGER.exception('Connection to the router timed out') return if res.status_code == 200: try: result = res.json() except ValueError: # If json decoder could not parse the response - _LOGGER.exception("Failed to parse response from luci") + _LOGGER.exception('Failed to parse response from luci') return try: return result['result'] except KeyError: - _LOGGER.exception("No result in response from luci") + _LOGGER.exception('No result in response from luci') return elif res.status_code == 401: # Authentication error @@ -138,7 +140,7 @@ def _req_json_rpc(url, method, *args, **kwargs): "please check your username and password") return else: - _LOGGER.error("Invalid response from luci: %s", res) + _LOGGER.error('Invalid response from luci: %s', res) def _get_token(host, username, password): diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index b20e5aae60e..ff6fe2f1e41 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -8,9 +8,12 @@ import logging import threading from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, \ - CONF_PORT +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -19,6 +22,17 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pynetgear==0.3.3'] +DEFAULT_HOST = 'routerlogin.net' +DEFAULT_USER = 'admin' +DEFAULT_PORT = 5000 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USER): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port +}) + def get_scanner(hass, config): """Validate the configuration and returns a Netgear scanner.""" @@ -28,10 +42,6 @@ def get_scanner(hass, config): password = info.get(CONF_PASSWORD) port = info.get(CONF_PORT) - if password is not None and host is None: - _LOGGER.warning('Found username or password but no host') - return None - scanner = NetgearDeviceScanner(host, username, password, port) return scanner if scanner.success_init else None @@ -47,16 +57,9 @@ class NetgearDeviceScanner(object): self.last_results = [] self.lock = threading.Lock() - if host is None: - self._api = pynetgear.Netgear() - elif username is None: - self._api = pynetgear.Netgear(password, host) - elif port is None: - self._api = pynetgear.Netgear(password, host, username) - else: - self._api = pynetgear.Netgear(password, host, username, port) + self._api = pynetgear.Netgear(password, host, username, port) - _LOGGER.info("Logging in") + _LOGGER.info('Logging in') results = self._api.get_attached_devices() @@ -65,7 +68,7 @@ class NetgearDeviceScanner(object): if self.success_init: self.last_results = results else: - _LOGGER.error("Failed to Login") + _LOGGER.error('Failed to Login') def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -91,7 +94,7 @@ class NetgearDeviceScanner(object): return with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info('Scanning') results = self._api.get_attached_devices() From 73036f472568a7223724815ff90c82bfd2d21029 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 5 Sep 2016 22:39:29 +0200 Subject: [PATCH 134/208] change update handling with variable for breack CCU2 (#3215) --- homeassistant/components/homematic.py | 52 +++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index ed0d51b3278..b0dcad55cfe 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -109,6 +109,7 @@ CONF_REMOTE_IP = 'remote_ip' CONF_REMOTE_PORT = 'remote_port' CONF_RESOLVENAMES = 'resolvenames' CONF_DELAY = 'delay' +CONF_VARIABLES = 'variables' DEVICE_SCHEMA = vol.Schema({ @@ -130,6 +131,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_USERNAME, default="Admin"): cv.string, vol.Optional(CONF_PASSWORD, default=""): cv.string, vol.Optional(CONF_DELAY, default=0.5): vol.Coerce(float), + vol.Optional(CONF_VARIABLES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -182,6 +184,7 @@ def setup(hass, config): username = config[DOMAIN].get(CONF_USERNAME) password = config[DOMAIN].get(CONF_PASSWORD) HOMEMATIC_LINK_DELAY = config[DOMAIN].get(CONF_DELAY) + use_variables = config[DOMAIN].get(CONF_VARIABLES) if remote_ip is None or local_ip is None: _LOGGER.error("Missing remote CCU/Homegear or local address") @@ -219,13 +222,16 @@ def setup(hass, config): ## # init HM variable - variables = HOMEMATIC.getAllSystemVariables() + variables = HOMEMATIC.getAllSystemVariables() if use_variables else {} + hm_var_store = {} if variables is not None: for key, value in variables.items(): - entities.append(HMVariable(key, value)) + varia = HMVariable(key, value) + hm_var_store.update({key: varia}) + entities.append(varia) # add homematic entites - entities.append(HMHub()) + entities.append(HMHub(hm_var_store, use_variables)) component.add_entities(entities) ## @@ -496,9 +502,13 @@ def _hm_service_virtualkey(call): class HMHub(Entity): """The Homematic hub. I.e. CCU2/HomeGear.""" - def __init__(self): + def __init__(self, variables_store, use_variables=False): """Initialize Homematic hub.""" self._state = STATE_UNKNOWN + self._store = variables_store + self._use_variables = use_variables + + self.update() @property def name(self): @@ -525,14 +535,30 @@ class HMHub(Entity): """Return true if device is available.""" return True if HOMEMATIC is not None else False - @Throttle(MIN_TIME_BETWEEN_UPDATE_HUB) def update(self): + """Update Hub data and all HM variables.""" + self._update_hub_state() + self._update_variables_state() + + @Throttle(MIN_TIME_BETWEEN_UPDATE_HUB) + def _update_hub_state(self): """Retrieve latest state.""" if HOMEMATIC is None: return state = HOMEMATIC.getServiceMessages() self._state = STATE_UNKNOWN if state is None else len(state) + @Throttle(MIN_TIME_BETWEEN_UPDATE_VAR) + def _update_variables_state(self): + """Retrive all variable data and update hmvariable states.""" + if HOMEMATIC is None or not self._use_variables: + return + variables = HOMEMATIC.getAllSystemVariables() + if variables is not None: + for key, value in variables.items(): + if key in self._store: + self._store.get(key).hm_update(value) + class HMVariable(Entity): """The Homematic system variable.""" @@ -557,12 +583,16 @@ class HMVariable(Entity): """Return the icon to use in the frontend, if any.""" return "mdi:code-string" - @Throttle(MIN_TIME_BETWEEN_UPDATE_VAR) - def update(self): - """Retrieve latest state.""" - if HOMEMATIC is None: - return - self._state = HOMEMATIC.getSystemVariable(self._name) + @property + def should_poll(self): + """Return false. Homematic Hub object update variable.""" + return False + + def hm_update(self, value): + """Update variable over Hub object.""" + if value != self._state: + self._state = value + self.update_ha_state() def hm_set(self, value): """Set variable on homematic controller.""" From 5ec6eaf7d06d36ec58a5c1ed5f90a710269f68d0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 5 Sep 2016 22:53:23 +0200 Subject: [PATCH 135/208] Update ordering (#3216) --- .../components/media_player/snapcast.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 6baba63afe6..2be3c36816c 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -6,26 +6,29 @@ https://home-assistant.io/components/media_player.snapcast/ """ import logging import socket + import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, PLATFORM_SCHEMA, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, - CONF_HOST, CONF_PORT) + STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['snapcast==1.2.2'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'snapcast' SUPPORT_SNAPCAST = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_SELECT_SOURCE -DOMAIN = 'snapcast' -REQUIREMENTS = ['snapcast==1.2.2'] -_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port + vol.Optional(CONF_PORT): cv.port, }) @@ -35,12 +38,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import snapcast.control host = config.get(CONF_HOST) port = config.get(CONF_PORT, snapcast.control.CONTROL_PORT) + try: server = snapcast.control.Snapserver(host, port) except socket.gaierror: _LOGGER.error('Could not connect to Snapcast server at %s:%d', host, port) - return + return False + add_devices([SnapcastDevice(client) for client in server.clients]) From a5faa851e896e15f6f9c9859b12eeb4dbb05d8fd Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Sep 2016 18:06:19 -0700 Subject: [PATCH 136/208] Docs update --- docs/Makefile | 2 +- docs/source/_ext/edit_on_github.py | 47 ++++++++++ docs/source/_templates/links.html | 8 ++ docs/source/_templates/sourcelink.html | 13 +++ docs/source/api/bootstrap.rst | 7 ++ docs/source/api/device_tracker.rst | 10 +++ docs/source/api/helpers.rst | 118 +++++++++++++++++++++++++ docs/source/api/homeassistant.rst | 78 ++++++++++++++++ docs/source/api/util.rst | 78 ++++++++++++++++ docs/source/conf.py | 71 +++++++++++---- homeassistant/const.py | 7 +- 11 files changed, 420 insertions(+), 19 deletions(-) create mode 100644 docs/source/_ext/edit_on_github.py create mode 100644 docs/source/_templates/links.html create mode 100644 docs/source/_templates/sourcelink.html create mode 100644 docs/source/api/bootstrap.rst create mode 100644 docs/source/api/device_tracker.rst create mode 100644 docs/source/api/helpers.rst create mode 100644 docs/source/api/homeassistant.rst create mode 100644 docs/source/api/util.rst diff --git a/docs/Makefile b/docs/Makefile index e8b712ce8a1..69893c43847 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -57,7 +57,7 @@ html: .PHONY: livehtml livehtml: - sphinx-autobuild --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + sphinx-autobuild -z ../homeassistant/ --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html .PHONY: dirhtml dirhtml: diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py new file mode 100644 index 00000000000..cb6a45f058a --- /dev/null +++ b/docs/source/_ext/edit_on_github.py @@ -0,0 +1,47 @@ +""" +Sphinx extension to add ReadTheDocs-style "Edit on GitHub" links to the +sidebar. + +Loosely based on https://github.com/astropy/astropy/pull/347 +""" + +import os +import warnings + + +__licence__ = 'BSD (3 clause)' + + +def get_github_url(app, view, path): + return ( + 'https://github.com/{project}/{view}/{branch}/{src_path}{path}'.format( + project=app.config.edit_on_github_project, + view=view, + branch=app.config.edit_on_github_branch, + src_path=app.config.edit_on_github_src_path, + path=path)) + + +def html_page_context(app, pagename, templatename, context, doctree): + if templatename != 'page.html': + return + + if not app.config.edit_on_github_project: + warnings.warn("edit_on_github_project not specified") + return + if not doctree: + warnings.warn("doctree is None") + return + path = os.path.relpath(doctree.get('source'), app.builder.srcdir) + show_url = get_github_url(app, 'blob', path) + edit_url = get_github_url(app, 'edit', path) + + context['show_on_github_url'] = show_url + context['edit_on_github_url'] = edit_url + + +def setup(app): + app.add_config_value('edit_on_github_project', '', True) + app.add_config_value('edit_on_github_branch', 'master', True) + app.add_config_value('edit_on_github_src_path', '', True) # 'eg' "docs/" + app.connect('html-page-context', html_page_context) diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html new file mode 100644 index 00000000000..57a2e09f99e --- /dev/null +++ b/docs/source/_templates/links.html @@ -0,0 +1,8 @@ + +
diff --git a/docs/source/_templates/sourcelink.html b/docs/source/_templates/sourcelink.html new file mode 100644 index 00000000000..8cf2c4f92ae --- /dev/null +++ b/docs/source/_templates/sourcelink.html @@ -0,0 +1,13 @@ +{%- if show_source and has_source and sourcename %} +

{{ _('This Page') }}

+ +{%- endif %} diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst new file mode 100644 index 00000000000..363f7969961 --- /dev/null +++ b/docs/source/api/bootstrap.rst @@ -0,0 +1,7 @@ +.. _bootstrap_module: + +:mod:`homeassistant.bootstrap` +------------------------- + +.. automodule:: homeassistant.bootstrap + :members: diff --git a/docs/source/api/device_tracker.rst b/docs/source/api/device_tracker.rst new file mode 100644 index 00000000000..e3d65174815 --- /dev/null +++ b/docs/source/api/device_tracker.rst @@ -0,0 +1,10 @@ +.. _components_device_tracker_module: + +:mod:`homeassistant.components.device_tracker` +---------------------------------------------- + +.. automodule:: homeassistant.components.device_tracker + :members: + +.. autoclass:: Device + :members: diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst new file mode 100644 index 00000000000..af186fb1341 --- /dev/null +++ b/docs/source/api/helpers.rst @@ -0,0 +1,118 @@ +homeassistant.helpers package +============================= + +Submodules +---------- + +homeassistant.helpers.condition module +-------------------------------------- + +.. automodule:: homeassistant.helpers.condition + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.config_validation module +---------------------------------------------- + +.. automodule:: homeassistant.helpers.config_validation + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.discovery module +-------------------------------------- + +.. automodule:: homeassistant.helpers.discovery + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity module +----------------------------------- + +.. automodule:: homeassistant.helpers.entity + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity_component module +--------------------------------------------- + +.. automodule:: homeassistant.helpers.entity_component + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.event module +---------------------------------- + +.. automodule:: homeassistant.helpers.event + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.event_decorators module +--------------------------------------------- + +.. automodule:: homeassistant.helpers.event_decorators + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.location module +------------------------------------- + +.. automodule:: homeassistant.helpers.location + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.script module +----------------------------------- + +.. automodule:: homeassistant.helpers.script + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.service module +------------------------------------ + +.. automodule:: homeassistant.helpers.service + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.state module +---------------------------------- + +.. automodule:: homeassistant.helpers.state + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.template module +------------------------------------- + +.. automodule:: homeassistant.helpers.template + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.typing module +----------------------------------- + +.. automodule:: homeassistant.helpers.typing + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant.helpers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/homeassistant.rst b/docs/source/api/homeassistant.rst new file mode 100644 index 00000000000..f5ff069451d --- /dev/null +++ b/docs/source/api/homeassistant.rst @@ -0,0 +1,78 @@ +homeassistant package +===================== + +Subpackages +----------- + +.. toctree:: + + helpers + util + +Submodules +---------- + +bootstrap module +------------------------------ + +.. automodule:: homeassistant.bootstrap + :members: + :undoc-members: + :show-inheritance: + +config module +--------------------------- + +.. automodule:: homeassistant.config + :members: + :undoc-members: + :show-inheritance: + +const module +-------------------------- + +.. automodule:: homeassistant.const + :members: + :undoc-members: + :show-inheritance: + +core module +------------------------- + +.. automodule:: homeassistant.core + :members: + :undoc-members: + :show-inheritance: + +exceptions module +------------------------------- + +.. automodule:: homeassistant.exceptions + :members: + :undoc-members: + :show-inheritance: + +loader module +--------------------------- + +.. automodule:: homeassistant.loader + :members: + :undoc-members: + :show-inheritance: + +remote module +--------------------------- + +.. automodule:: homeassistant.remote + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst new file mode 100644 index 00000000000..7d6a22dbc0b --- /dev/null +++ b/docs/source/api/util.rst @@ -0,0 +1,78 @@ +homeassistant.util package +========================== + +Submodules +---------- + +homeassistant.util.color module +------------------------------- + +.. automodule:: homeassistant.util.color + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.distance module +---------------------------------- + +.. automodule:: homeassistant.util.distance + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.dt module +---------------------------- + +.. automodule:: homeassistant.util.dt + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.location module +---------------------------------- + +.. automodule:: homeassistant.util.location + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.package module +--------------------------------- + +.. automodule:: homeassistant.util.package + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.temperature module +------------------------------------- + +.. automodule:: homeassistant.util.temperature + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.unit_system module +------------------------------------- + +.. automodule:: homeassistant.util.unit_system + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.yaml module +------------------------------ + +.. automodule:: homeassistant.util.yaml + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant.util + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index 4ca74060aad..66bce1e895a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,16 +17,19 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import sys, os +from os.path import relpath, dirname +import inspect + +sys.path.insert(0, os.path.abspath('_ext')) +sys.path.insert(0, os.path.abspath('../homeassistant')) from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, PROJECT_LONG_DESCRIPTION, PROJECT_URL, PROJECT_COPYRIGHT, PROJECT_AUTHOR, PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY, PYPI_URL, - GITHUB_URL) + GITHUB_PATH, GITHUB_URL) # -- General configuration ------------------------------------------------ @@ -39,8 +42,9 @@ from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, # ones. extensions = [ 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx_autodoc_typehints', + 'sphinx.ext.linkcode', + 'sphinx_autodoc_annotation', + 'edit_on_github' ] # Add any paths that contain templates here, relative to this directory. @@ -73,6 +77,48 @@ version = __short_version__ # The full version, including alpha/beta/rc tags. release = __version__ +code_branch = 'dev' if 'dev' in __version__ else 'master' + +# Edit on Github config +edit_on_github_project = GITHUB_PATH +edit_on_github_branch = code_branch +edit_on_github_src_path = 'docs/source/' + +def linkcode_resolve(domain, info): + """ + Determine the URL corresponding to Python object + """ + if domain != 'py': + return None + modname = info['module'] + fullname = info['fullname'] + submod = sys.modules.get(modname) + if submod is None: + return None + obj = submod + for part in fullname.split('.'): + try: + obj = getattr(obj, part) + except: + return None + try: + fn = inspect.getsourcefile(obj) + except: + fn = None + if not fn: + return None + try: + source, lineno = inspect.findsource(obj) + except: + lineno = None + if lineno: + linespec = "#L%d" % (lineno + 1) + else: + linespec = "" + fn = relpath(fn, start='../') + + return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # @@ -148,13 +194,6 @@ html_theme_options = { 'travis_button': True, 'touch_icon': 'logo-apple.png', # 'fixed_sidebar': True, # Re-enable when we have more content - 'extra_nav_links': { - '🏡 Homepage': PROJECT_URL, - '📌 Community Forums': 'https://community.home-assistant.io', - '💬 Gitter': 'https://gitter.im/home-assistant/home-assistant', - '🚀 GitHub': GITHUB_URL, - '💾 Download Releases': PYPI_URL, - } } # Add any paths that contain custom themes here, relative to this directory. @@ -208,9 +247,11 @@ html_use_smartypants = True html_sidebars = { '**': [ 'about.html', - 'navigation.html', - 'relations.html', + 'links.html', 'searchbox.html', + 'sourcelink.html', + 'navigation.html', + 'relations.html' ] } diff --git a/homeassistant/const.py b/homeassistant/const.py index c367792852a..7b61daa1c8e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -11,7 +11,7 @@ PROJECT_NAME = 'Home Assistant' PROJECT_PACKAGE_NAME = 'homeassistant' PROJECT_LICENSE = 'MIT License' PROJECT_AUTHOR = 'The Home Assistant Authors' -PROJECT_COPYRIGHT = '2016, {}'.format(PROJECT_AUTHOR) +PROJECT_COPYRIGHT = ' 2016, {}'.format(PROJECT_AUTHOR) PROJECT_URL = 'https://home-assistant.io/' PROJECT_EMAIL = 'hello@home-assistant.io' PROJECT_DESCRIPTION = ('Open-source home automation platform ' @@ -34,8 +34,9 @@ PROJECT_GITHUB_USERNAME = 'home-assistant' PROJECT_GITHUB_REPOSITORY = 'home-assistant' PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) -GITHUB_URL = 'https://github.com/{}/{}'.format(PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY) +GITHUB_PATH = '{}/{}'.format(PROJECT_GITHUB_USERNAME, + PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) PLATFORM_FORMAT = '{}.{}' From d903661577496fa2a44b75aacc7c9c6488a7de66 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Sep 2016 18:10:04 -0700 Subject: [PATCH 137/208] Flake8/pylint --- docs/source/_ext/edit_on_github.py | 10 ++++------ docs/source/conf.py | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py index cb6a45f058a..eef249a3f01 100644 --- a/docs/source/_ext/edit_on_github.py +++ b/docs/source/_ext/edit_on_github.py @@ -13,13 +13,11 @@ __licence__ = 'BSD (3 clause)' def get_github_url(app, view, path): + github_fmt = 'https://github.com/{}/{}/{}/{}{}' return ( - 'https://github.com/{project}/{view}/{branch}/{src_path}{path}'.format( - project=app.config.edit_on_github_project, - view=view, - branch=app.config.edit_on_github_branch, - src_path=app.config.edit_on_github_src_path, - path=path)) + github_fmt.format(app.config.edit_on_github_project, view, + app.config.edit_on_github_branch, + app.config.edit_on_github_src_path, path)) def html_page_context(app, pagename, templatename, context, doctree): diff --git a/docs/source/conf.py b/docs/source/conf.py index 66bce1e895a..18b14795caa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,20 +17,21 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -import sys, os -from os.path import relpath, dirname +import sys +import os +from os.path import relpath import inspect +from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, + PROJECT_LONG_DESCRIPTION, + PROJECT_COPYRIGHT, PROJECT_AUTHOR, + PROJECT_GITHUB_USERNAME, + PROJECT_GITHUB_REPOSITORY, + GITHUB_PATH, GITHUB_URL) + sys.path.insert(0, os.path.abspath('_ext')) sys.path.insert(0, os.path.abspath('../homeassistant')) -from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, - PROJECT_LONG_DESCRIPTION, PROJECT_URL, - PROJECT_COPYRIGHT, PROJECT_AUTHOR, - PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY, PYPI_URL, - GITHUB_PATH, GITHUB_URL) - # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. @@ -84,6 +85,7 @@ edit_on_github_project = GITHUB_PATH edit_on_github_branch = code_branch edit_on_github_src_path = 'docs/source/' + def linkcode_resolve(domain, info): """ Determine the URL corresponding to Python object From 6e6b2ae3f4339a9627339ff0e6711a7df176f982 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Sep 2016 18:12:44 -0700 Subject: [PATCH 138/208] Add new docs requirements --- requirements_docs.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements_docs.txt b/requirements_docs.txt index 7ed9c3b77b4..df88ba8fb58 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,2 +1,3 @@ Sphinx==1.4.6 sphinx-autodoc-typehints==1.1.0 +sphinx-autodoc-annotation==1.0.post1 From f595c8715c43b0f6a8f323cd6ebf3e8c7a4e9f7f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 16:45:33 +0200 Subject: [PATCH 139/208] Update email validation (#3228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/notify/smtp.py | 36 ++++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 8b6ca76a235..84aae3f2c8f 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -12,12 +12,12 @@ from email.mime.image import MIMEImage import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PORT, - CONF_SENDER, CONF_RECIPIENT) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SENDER, CONF_RECIPIENT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -27,15 +27,21 @@ CONF_STARTTLS = 'starttls' CONF_DEBUG = 'debug' CONF_SERVER = 'server' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 25 +DEFAULT_DEBUG = False +DEFAULT_STARTTLS = False + +# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_RECIPIENT): vol.Email, - vol.Optional(CONF_SERVER, default='localhost'): cv.string, - vol.Optional(CONF_PORT, default=25): cv.port, - vol.Optional(CONF_SENDER): vol.Email, - vol.Optional(CONF_STARTTLS, default=False): cv.boolean, + vol.Required(CONF_RECIPIENT): vol.Email(), + vol.Optional(CONF_SERVER, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SENDER): vol.Email(), + vol.Optional(CONF_STARTTLS, default=DEFAULT_STARTTLS): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_DEBUG, default=False): cv.boolean, + vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean, }) @@ -95,16 +101,14 @@ class MailNotificationService(BaseNotificationService): except smtplib.socket.gaierror: _LOGGER.exception( "SMTP server not found (%s:%s). " - "Please check the IP address or hostname of your SMTP server.", + "Please check the IP address or hostname of your SMTP server", self._server, self._port) - return False except (smtplib.SMTPAuthenticationError, ConnectionRefusedError): _LOGGER.exception( "Login not possible. " - "Please check your setting and/or your credentials.") - + "Please check your setting and/or your credentials") return False finally: @@ -154,13 +158,13 @@ class MailNotificationService(BaseNotificationService): def _build_text_msg(message): """Build plaintext email.""" - _LOGGER.debug('Building plain text email.') + _LOGGER.debug('Building plain text email') return MIMEText(message) def _build_multipart_msg(message, images): """Build Multipart message with in-line images.""" - _LOGGER.debug('Building multipart email with embedded attachment(s).') + _LOGGER.debug('Building multipart email with embedded attachment(s)') msg = MIMEMultipart('related') msg_alt = MIMEMultipart('alternative') msg.attach(msg_alt) @@ -177,7 +181,7 @@ def _build_multipart_msg(message, images): msg.attach(attachment) attachment.add_header('Content-ID', '<{}>'.format(cid)) except FileNotFoundError: - _LOGGER.warning('Attachment %s not found. Skipping.', + _LOGGER.warning('Attachment %s not found. Skipping', atch_name) body_html = MIMEText(''.join(body_text), 'html') From c06fe51122fa5f785518674385bdeb1bad00aa26 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 16:48:24 +0200 Subject: [PATCH 140/208] Fix email validation (fixes #3138) (#3227) --- homeassistant/components/notify/sendgrid.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index ac249dc2c97..37253cd5c97 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -8,27 +8,28 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT +from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['sendgrid==3.2.10'] + _LOGGER = logging.getLogger(__name__) - +# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SENDER): vol.Email, - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_SENDER): vol.Email(), + vol.Required(CONF_RECIPIENT): vol.Email(), }) def get_service(hass, config): """Get the SendGrid notification service.""" - api_key = config[CONF_API_KEY] - sender = config[CONF_SENDER] - recipient = config[CONF_RECIPIENT] + api_key = config.get(CONF_API_KEY) + sender = config.get(CONF_SENDER) + recipient = config.get(CONF_RECIPIENT) return SendgridNotificationService(api_key, sender, recipient) From 26eba4cb1aa0c1e73e0b4dabeb5dc7506f07f128 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 16:51:23 +0200 Subject: [PATCH 141/208] Upgrade slacker to 0.9.25 (#3224) --- homeassistant/components/notify/slack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 10564609390..780a27b9795 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['slacker==0.9.24'] +REQUIREMENTS = ['slacker==0.9.25'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8304eb43ae4..c98da7d6b1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ scsgate==0.1.0 sendgrid==3.2.10 # homeassistant.components.notify.slack -slacker==0.9.24 +slacker==0.9.25 # homeassistant.components.notify.xmpp sleekxmpp==1.3.1 From 9530c7366b502b46449d54681d085b6767ac497c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 16:51:51 +0200 Subject: [PATCH 142/208] Upgrade psutil to 4.3.1 (#3223) --- homeassistant/components/sensor/systemmonitor.py | 7 +++---- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 893ec8154c4..125a2871f28 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -8,14 +8,13 @@ import logging import voluptuous as vol -import homeassistant.util.dt as dt_util - -from homeassistant.const import (CONF_RESOURCES, STATE_OFF, STATE_ON) from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_RESOURCES, STATE_OFF, STATE_ON) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==4.3.0'] +REQUIREMENTS = ['psutil==4.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c98da7d6b1f..7acce4b448e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -270,7 +270,7 @@ pmsensor==0.3 proliphix==0.3.1 # homeassistant.components.sensor.systemmonitor -psutil==4.3.0 +psutil==4.3.1 # homeassistant.components.wink # homeassistant.components.binary_sensor.wink From 88d62bd935836cadbe75f01135f87759bc2b78a2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 16:53:21 +0200 Subject: [PATCH 143/208] Upgrade gps3 to 0.33.3 (#3222) --- homeassistant/components/sensor/gpsd.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py index a9f8245b738..0fb24c96283 100644 --- a/homeassistant/components/sensor/gpsd.py +++ b/homeassistant/components/sensor/gpsd.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['gps3==0.33.2'] +REQUIREMENTS = ['gps3==0.33.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7acce4b448e..a379ef93ac2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -103,7 +103,7 @@ gntp==1.0.3 googlemaps==2.4.4 # homeassistant.components.sensor.gpsd -gps3==0.33.2 +gps3==0.33.3 # homeassistant.components.binary_sensor.ffmpeg # homeassistant.components.camera.ffmpeg From 85baebb23b2688c7fd100cde9dea717d4f11221a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 16:55:23 +0200 Subject: [PATCH 144/208] Upgrade Werkzeug to 0.11.11 (#3220) --- homeassistant/components/http.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index dba1ab0f86e..37deb41eef4 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -25,7 +25,7 @@ import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv DOMAIN = 'http' -REQUIREMENTS = ('cherrypy==7.1.0', 'static3==0.7.0', 'Werkzeug==0.11.10') +REQUIREMENTS = ('cherrypy==7.1.0', 'static3==0.7.0', 'Werkzeug==0.11.11') CONF_API_PASSWORD = 'api_password' CONF_SERVER_HOST = 'server_host' diff --git a/requirements_all.txt b/requirements_all.txt index a379ef93ac2..65322320764 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ SoCo==0.11.1 TwitterAPI==2.4.2 # homeassistant.components.http -Werkzeug==0.11.10 +Werkzeug==0.11.11 # homeassistant.components.apcupsd apcaccess==0.0.4 From 478c82c34c64d88a886a718c7341dd40137d2252 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 18:12:24 +0200 Subject: [PATCH 145/208] Upgrade sendgrid to 3.4.0 (#3226) --- homeassistant/components/notify/sendgrid.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 37253cd5c97..42921e2be2c 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==3.2.10'] +REQUIREMENTS = ['sendgrid==3.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 65322320764..bd0f7f942ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ schiene==0.17 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==3.2.10 +sendgrid==3.4.0 # homeassistant.components.notify.slack slacker==0.9.25 From 9ade87013eb1eb58a424cd491f5771d31c44a5c0 Mon Sep 17 00:00:00 2001 From: Bart274 Date: Tue, 6 Sep 2016 19:51:36 +0200 Subject: [PATCH 146/208] Bluetooth: keep looking for new devices (#3201) * keep looking for new devices * Update bluetooth_tracker.py * change default value for tracking new devices * remove commented code --- .../device_tracker/bluetooth_le_tracker.py | 3 ++- .../components/device_tracker/bluetooth_tracker.py | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index a9b95cf6a6b..9ee30dd0ce2 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import ( DEFAULT_SCAN_INTERVAL, PLATFORM_SCHEMA, load_config, + DEFAULT_TRACK_NEW ) import homeassistant.util as util import homeassistant.util.dt as dt_util @@ -88,7 +89,7 @@ def setup_scanner(hass, config, see): # if track new devices is true discover new devices # on every scan. track_new = util.convert(config.get(CONF_TRACK_NEW), bool, - len(devs_to_track) == 0) + DEFAULT_TRACK_NEW) if not devs_to_track and not track_new: _LOGGER.warning("No Bluetooth LE devices to track!") return False diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index d5a6fe26861..86e115c65c4 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -8,7 +8,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - load_config, PLATFORM_SCHEMA) + load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW) import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def setup_scanner(hass, config, see): devs_donot_track.append(device.mac[3:]) # if track new devices is true discover new devices on startup. - track_new = config.get(CONF_TRACK_NEW, len(devs_to_track) == 0) + track_new = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) if track_new: for dev in discover_devices(): if dev[0] not in devs_to_track and \ @@ -64,15 +64,16 @@ def setup_scanner(hass, config, see): devs_to_track.append(dev[0]) see_device(dev) - if not devs_to_track: - _LOGGER.warning("No bluetooth devices to track!") - return False - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) def update_bluetooth(now): """Lookup bluetooth device and update status.""" try: + if track_new: + for dev in discover_devices(): + if dev[0] not in devs_to_track and \ + dev[0] not in devs_donot_track: + devs_to_track.append(dev[0]) for mac in devs_to_track: _LOGGER.debug("Scanning " + mac) result = bluetooth.lookup_name(mac, timeout=5) From c1139a9fdacc463b7f5ddacc5f27d0ddaf7dfc44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=A6dstrup?= Date: Tue, 6 Sep 2016 19:52:22 +0200 Subject: [PATCH 147/208] dlink switch added device state attributes and support for legacy firmware (#3211) --- homeassistant/components/switch/dlink.py | 36 ++++++++++++++++++++++-- requirements_all.txt | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b65c521bad5..377826695a3 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -12,20 +12,27 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) import homeassistant.helpers.config_validation as cv +from homeassistant.const import TEMP_CELSIUS REQUIREMENTS = ['https://github.com/LinuxChristian/pyW215/archive/' - 'v0.1.1.zip#pyW215==0.1.1'] + 'v0.3.4.zip#pyW215==0.3.4'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'D-link Smart Plug W215' DEFAULT_PASSWORD = '' DEFAULT_USERNAME = 'admin' +CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' + +ATTR_CURRENT_CONSUMPTION = 'Current Consumption' +ATTR_TOTAL_CONSUMPTION = 'Total Consumption' +ATTR_TEMPERATURE = 'Temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -38,16 +45,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): host = config.get(CONF_HOST) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) + use_legacy_protocol = config.get(CONF_USE_LEGACY_PROTOCOL) name = config.get(CONF_NAME) - add_devices([SmartPlugSwitch(SmartPlug(host, password, username), name)]) + add_devices([SmartPlugSwitch(hass, SmartPlug(host, + password, + username, + use_legacy_protocol), + name)]) class SmartPlugSwitch(SwitchDevice): """Representation of a D-link Smart Plug switch.""" - def __init__(self, smartplug, name): + def __init__(self, hass, smartplug, name): """Initialize the switch.""" + self.units = hass.config.units self.smartplug = smartplug self._name = name @@ -56,6 +69,23 @@ class SmartPlugSwitch(SwitchDevice): """Return the name of the Smart Plug, if any.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + ui_temp = self.units.temperature(int(self.smartplug.temperature), + TEMP_CELSIUS) + temperature = "{} {}".format(ui_temp, self.units.temperature_unit) + current_consumption = "{} W".format(self.smartplug.current_consumption) + total_consumption = "{} W".format(self.smartplug.total_consumption) + + attrs = { + ATTR_CURRENT_CONSUMPTION: current_consumption, + ATTR_TOTAL_CONSUMPTION: total_consumption, + ATTR_TEMPERATURE: temperature + } + + return attrs + @property def current_power_watt(self): """Return the current power usage in Watt.""" diff --git a/requirements_all.txt b/requirements_all.txt index bd0f7f942ed..65a66692a3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -126,7 +126,7 @@ hikvision==0.4 https://github.com/Danielhiversen/flux_led/archive/0.6.zip#flux_led==0.6 # homeassistant.components.switch.dlink -https://github.com/LinuxChristian/pyW215/archive/v0.1.1.zip#pyW215==0.1.1 +https://github.com/LinuxChristian/pyW215/archive/v0.3.4.zip#pyW215==0.3.4 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv From d06a3c91459364deb9e9638839c10938c4c7cf3c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 6 Sep 2016 21:33:11 +0200 Subject: [PATCH 148/208] Use voluptuous for free mobile (#3236) --- .../components/notify/free_mobile.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/notify/free_mobile.py b/homeassistant/components/notify/free_mobile.py index e12cc5893b8..e5209e06582 100644 --- a/homeassistant/components/notify/free_mobile.py +++ b/homeassistant/components/notify/free_mobile.py @@ -6,22 +6,25 @@ https://home-assistant.io/components/notify.free_mobile/ """ import logging -from homeassistant.components.notify import DOMAIN, BaseNotificationService +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['freesms==0.1.0'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, +}) + + def get_service(hass, config): """Get the Free Mobile SMS notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_USERNAME, - CONF_ACCESS_TOKEN]}, - _LOGGER): - return None - return FreeSMSNotificationService(config[CONF_USERNAME], config[CONF_ACCESS_TOKEN]) From 79fa9963da2f43e09da2cbb8e8f19e9c489bc546 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 6 Sep 2016 22:24:04 +0200 Subject: [PATCH 149/208] Use voluptuous for nma (#3241) --- homeassistant/components/notify/nma.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py index ef75abb2fe4..ffa4ae229c7 100644 --- a/homeassistant/components/notify/nma.py +++ b/homeassistant/components/notify/nma.py @@ -8,23 +8,24 @@ import logging import xml.etree.ElementTree as ET import requests +import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://www.notifymyandroid.com/publicapi/' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + def get_service(hass, config): """Get the NMA notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): - return None - response = requests.get(_RESOURCE + 'verify', params={"apikey": config[CONF_API_KEY]}) tree = ET.fromstring(response.content) From fa8ed4de41fa6aa077d3f3685634222eecee3ede Mon Sep 17 00:00:00 2001 From: Ardetus Date: Tue, 6 Sep 2016 23:50:02 +0300 Subject: [PATCH 150/208] =?UTF-8?q?Improve=201-Wire=20device=20family=20de?= =?UTF-8?q?tection=20and=20error=20checking.=20Use=20volupt=E2=80=A6=20(#3?= =?UTF-8?q?233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve 1-Wire device family detection and error checking. Use voluptuous * Fix detection of gpio connected devices --- homeassistant/components/sensor/onewire.py | 65 +++++++++++++--------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index a2a3f0811f2..e7a78393b93 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -1,16 +1,28 @@ """ -Support for DS18B20 One Wire Sensors. +Support for 1-Wire temperature sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.onewire/ """ -import logging import os import time +import logging from glob import glob - -from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS +import voluptuous as vol from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.components.sensor import PLATFORM_SCHEMA + +CONF_MOUNT_DIR = 'mount_dir' +CONF_NAMES = 'names' +DEFAULT_MOUNT_DIR = '/sys/bus/w1/devices/' +DEVICE_FAMILIES = ('10', '22', '28', '3B', '42') + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAMES): {cv.string: cv.string}, + vol.Optional(CONF_MOUNT_DIR, default=DEFAULT_MOUNT_DIR): cv.string, +}) _LOGGER = logging.getLogger(__name__) @@ -18,22 +30,22 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the one wire Sensors.""" - base_dir = config.get('mount_dir', '/sys/bus/w1/devices/') - device_folders = glob(os.path.join(base_dir, '[10,22,28,3B,42]*')) + base_dir = config.get(CONF_MOUNT_DIR) sensor_ids = [] device_files = [] - for device_folder in device_folders: - sensor_ids.append(os.path.split(device_folder)[1]) - if base_dir.startswith('/sys/bus/w1/devices'): - device_files.append(os.path.join(device_folder, 'w1_slave')) - else: - device_files.append(os.path.join(device_folder, 'temperature')) + for device_family in DEVICE_FAMILIES: + for device_folder in glob(os.path.join(base_dir, device_family + + '[.-]*')): + sensor_ids.append(os.path.split(device_folder)[1]) + if base_dir == DEFAULT_MOUNT_DIR: + device_files.append(os.path.join(device_folder, 'w1_slave')) + else: + device_files.append(os.path.join(device_folder, 'temperature')) if device_files == []: - _LOGGER.error('No onewire sensor found.') - _LOGGER.error('Check if dtoverlay=w1-gpio,gpiopin=4.') - _LOGGER.error('is in your /boot/config.txt and') - _LOGGER.error('the correct gpiopin number is set.') + _LOGGER.error('No onewire sensor found. Check if ' + 'dtoverlay=w1-gpio is in your /boot/config.txt. ' + 'Check the mount_dir parameter if it\'s defined.') return devs = [] @@ -92,7 +104,7 @@ class OneWire(Entity): def update(self): """Get the latest data from the device.""" temp = -99 - if self._device_file.startswith('/sys/bus/w1/devices'): + if self._device_file.startswith(DEFAULT_MOUNT_DIR): lines = self._read_temp_raw() while lines[0].strip()[-3:] != 'YES': time.sleep(0.2) @@ -102,15 +114,18 @@ class OneWire(Entity): temp_string = lines[1][equals_pos+2:] temp = round(float(temp_string) / 1000.0, 1) else: - ds_device_file = open(self._device_file, 'r') - temp_read = ds_device_file.readlines() - ds_device_file.close() - if len(temp_read) == 1: - try: + try: + ds_device_file = open(self._device_file, 'r') + temp_read = ds_device_file.readlines() + ds_device_file.close() + if len(temp_read) == 1: temp = round(float(temp_read[0]), 1) - except ValueError: - _LOGGER.warning('Invalid temperature value read from ' + - self._device_file) + except ValueError: + _LOGGER.warning('Invalid temperature value read from ' + + self._device_file) + except FileNotFoundError: + _LOGGER.warning('Cannot read from sensor: ' + + self._device_file) if temp < -55 or temp > 125: return From d8db881e9ab47e5827dcdaababa7e51561fa5f9a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 23:41:26 +0200 Subject: [PATCH 151/208] Replace rollershutter and garage door with cover, add fan (#3242) --- homeassistant/components/demo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 5695bc5005a..a2eb40e21e8 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -19,13 +19,13 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ 'binary_sensor', 'camera', 'climate', + 'cover', 'device_tracker', - 'garage_door', + 'fan', 'light', 'lock', 'media_player', 'notify', - 'rollershutter', 'sensor', 'switch', ] From e00f9339d188deae2d2ce84eae49eb952f3dcc89 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 6 Sep 2016 23:48:32 +0200 Subject: [PATCH 152/208] Use voluptuous for Alarm.com (#3229) --- .../alarm_control_panel/alarmdotcom.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 542cb5e3d02..a986d911115 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -6,34 +6,40 @@ https://home-assistant.io/components/alarm_control_panel.alarmdotcom/ """ import logging +import voluptuous as vol + import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) - -_LOGGER = logging.getLogger(__name__) - + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN, CONF_CODE, + CONF_NAME) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/Xorso/pyalarmdotcom' '/archive/0.1.1.zip' '#pyalarmdotcom==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + DEFAULT_NAME = 'Alarm.com' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup an Alarm.com control panel.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - if username is None or password is None: - _LOGGER.error('Must specify username and password!') - return False - - add_devices([AlarmDotCom(hass, - config.get('name', DEFAULT_NAME), - config.get('code'), - username, - password)]) + add_devices([AlarmDotCom(hass, name, code, username, password)]) # pylint: disable=too-many-arguments, too-many-instance-attributes From 22870d424a5a8086a5459be450e768d008f4dc4b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 7 Sep 2016 00:16:21 +0200 Subject: [PATCH 153/208] Use voluptuous for gntp (#3237) --- homeassistant/components/notify/gntp.py | 37 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py index 64033f03125..fa7db0d6e6e 100644 --- a/homeassistant/components/notify/gntp.py +++ b/homeassistant/components/notify/gntp.py @@ -7,8 +7,12 @@ https://home-assistant.io/components/notify.gntp/ import logging import os +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_PASSWORD, CONF_PORT +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['gntp==1.0.3'] @@ -18,20 +22,37 @@ _GNTP_LOGGER = logging.getLogger('gntp') _GNTP_LOGGER.setLevel(logging.ERROR) +CONF_APP_NAME = 'app_name' +CONF_APP_ICON = 'app_icon' +CONF_HOSTNAME = 'hostname' + +DEFAULT_APP_NAME = 'HomeAssistant' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 23053 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_APP_NAME, default=DEFAULT_APP_NAME): cv.string, + vol.Optional(CONF_APP_ICON): vol.Url, + vol.Optional(CONF_HOSTNAME, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + def get_service(hass, config): """Get the GNTP notification service.""" - if config.get('app_icon') is None: + if config.get(CONF_APP_ICON) is None: icon_file = os.path.join(os.path.dirname(__file__), "..", "frontend", "www_static", "icons", "favicon-192x192.png") app_icon = open(icon_file, 'rb').read() else: - app_icon = config.get('app_icon') + app_icon = config.get(CONF_APP_ICON) - return GNTPNotificationService(config.get('app_name', 'HomeAssistant'), - config.get('app_icon', app_icon), - config.get('hostname', 'localhost'), - config.get('password'), - config.get('port', 23053)) + return GNTPNotificationService(config.get(CONF_APP_NAME), + app_icon, + config.get(CONF_HOSTNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PORT)) # pylint: disable=too-few-public-methods From 9eacde00057eb50c0b42e3097d35a58bae5b5f6f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 7 Sep 2016 02:00:33 +0200 Subject: [PATCH 154/208] Use voluptuous for pushbullet, pushetta and pushover (#3240) --- homeassistant/components/notify/pushbullet.py | 15 +++++++---- homeassistant/components/notify/pushetta.py | 27 +++++++++++-------- homeassistant/components/notify/pushover.py | 6 +++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 7c924223ae1..d5402548508 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -6,24 +6,29 @@ https://home-assistant.io/components/notify.pushbullet/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, + BaseNotificationService) from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pushbullet.py==0.10.0'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + # pylint: disable=unused-argument def get_service(hass, config): """Get the PushBullet notification service.""" from pushbullet import PushBullet from pushbullet import InvalidKeyError - if CONF_API_KEY not in config: - _LOGGER.error("Unable to find config key '%s'", CONF_API_KEY) - return None - try: pushbullet = PushBullet(config[CONF_API_KEY]) except InvalidKeyError: diff --git a/homeassistant/components/notify/pushetta.py b/homeassistant/components/notify/pushetta.py index 441379b2285..ab304dc6514 100644 --- a/homeassistant/components/notify/pushetta.py +++ b/homeassistant/components/notify/pushetta.py @@ -6,37 +6,42 @@ https://home-assistant.io/components/notify.pushetta/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) - REQUIREMENTS = ['pushetta==1.0.15'] +CONF_CHANNEL_NAME = 'channel_name' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_CHANNEL_NAME): cv.string, +}) + + def get_service(hass, config): """Get the Pushetta notification service.""" from pushetta import Pushetta, exceptions - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY, 'channel_name']}, - _LOGGER): - return None - try: pushetta = Pushetta(config[CONF_API_KEY]) - pushetta.pushMessage(config['channel_name'], "Home Assistant started") + pushetta.pushMessage(config[CONF_CHANNEL_NAME], + "Home Assistant started") except exceptions.TokenValidationError: _LOGGER.error("Please check your access token") return None except exceptions.ChannelNotFoundError: - _LOGGER.error("Channel '%s' not found", config['channel_name']) + _LOGGER.error("Channel '%s' not found", config[CONF_CHANNEL_NAME]) return None return PushettaNotificationService(config[CONF_API_KEY], - config['channel_name']) + config[CONF_CHANNEL_NAME]) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index de82bb4e819..c0a067fe918 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -18,8 +18,10 @@ REQUIREMENTS = ['python-pushover==0.2'] _LOGGER = logging.getLogger(__name__) +CONF_USER_KEY = 'user_key' + PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ - vol.Required('user_key'): cv.string, + vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string, }) @@ -30,7 +32,7 @@ def get_service(hass, config): from pushover import InitError try: - return PushoverNotificationService(config['user_key'], + return PushoverNotificationService(config[CONF_USER_KEY], config[CONF_API_KEY]) except InitError: _LOGGER.error( From 9d4ccb1f49e63e6baa1c9c1f91e02c0fd136530e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 7 Sep 2016 02:03:43 +0200 Subject: [PATCH 155/208] Migrate to voluptuous (#3230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../alarm_control_panel/simplisafe.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index a248df5fc21..82927246ec6 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -6,32 +6,39 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/ """ import logging +import voluptuous as vol + import homeassistant.components.alarm_control_panel as alarm - +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, + CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, CONF_CODE, CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) +import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/w1ll1am23/simplisafe-python/archive/' '586fede0e85fd69e56e516aaa8e97eb644ca8866.zip#' 'simplisafe-python==0.0.1'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'SimpliSafe' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the SimpliSafe platform.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - if username is None or password is None: - _LOGGER.error('Must specify username and password!') - return False - - add_devices([SimpliSafeAlarm( - config.get('name', "SimpliSafe"), - username, - password, - config.get('code'))]) + add_devices([SimpliSafeAlarm(name, username, password, code)]) # pylint: disable=abstract-method From f55095df837b2fbf69b69301e439f9117cc6cc13 Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Tue, 6 Sep 2016 18:04:20 -0700 Subject: [PATCH 156/208] Fix mFi sensors in uninitialized state (#3246) If mFi sensors are identified but not fully assigned they can have no tag value, and mficlient throws a ValueError to signal this. This patch handles that case by considering such devices to always be STATE_OFF. --- homeassistant/components/sensor/mfi.py | 19 +++++++++++++++---- tests/components/sensor/test_mfi.py | 10 ++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index 1ba4cf9d5e0..0f06426a05b 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -91,7 +91,13 @@ class MfiSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self._port.model == 'Input Digital': + try: + tag = self._port.tag + except ValueError: + tag = None + if tag is None: + return STATE_OFF + elif self._port.model == 'Input Digital': return self._port.value > 0 and STATE_ON or STATE_OFF else: digits = DIGITS.get(self._port.tag, 0) @@ -100,13 +106,18 @@ class MfiSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._port.tag == 'temperature': + try: + tag = self._port.tag + except ValueError: + return 'State' + + if tag == 'temperature': return TEMP_CELSIUS - elif self._port.tag == 'active_pwr': + elif tag == 'active_pwr': return 'Watts' elif self._port.model == 'Input Digital': return 'State' - return self._port.tag + return tag def update(self): """Get the latest data.""" diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index f55451ff329..c1e6ac899ec 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -147,6 +147,11 @@ class TestMfiSensor(unittest.TestCase): self.port.tag = 'balloons' self.assertEqual('balloons', self.sensor.unit_of_measurement) + def test_uom_uninitialized(self): + """Test that the UOM defaults if not initialized.""" + type(self.port).tag = mock.PropertyMock(side_effect=ValueError) + self.assertEqual('State', self.sensor.unit_of_measurement) + def test_state_digital(self): """Test the digital input.""" self.port.model = 'Input Digital' @@ -166,6 +171,11 @@ class TestMfiSensor(unittest.TestCase): with mock.patch.dict(mfi.DIGITS, {}): self.assertEqual(1.0, self.sensor.state) + def test_state_uninitialized(self): + """Test the state of uninitialized sensors.""" + type(self.port).tag = mock.PropertyMock(side_effect=ValueError) + self.assertEqual(mfi.STATE_OFF, self.sensor.state) + def test_update(self): """Test the update.""" self.sensor.update() From abff2f2b36dbedd58c055095255fdd3bca9df09a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 7 Sep 2016 03:16:03 +0200 Subject: [PATCH 157/208] Use voluptuous for PulseAudio Loopback (#3160) * Migrate to voluptuous * Fix conf var --- .../components/switch/pulseaudio_loopback.py | 119 +++++++++--------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/switch/pulseaudio_loopback.py b/homeassistant/components/switch/pulseaudio_loopback.py index b9175bee9b7..c9ee19aa0e3 100644 --- a/homeassistant/components/switch/pulseaudio_loopback.py +++ b/homeassistant/components/switch/pulseaudio_loopback.py @@ -9,69 +9,75 @@ import re import socket from datetime import timedelta +import voluptuous as vol + import homeassistant.util as util -from homeassistant.components.switch import SwitchDevice -from homeassistant.util import convert +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _PULSEAUDIO_SERVERS = {} -DEFAULT_NAME = "paloopback" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 4712 -DEFAULT_BUFFER_SIZE = 1024 -DEFAULT_TCP_TIMEOUT = 3 -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +CONF_BUFFER_SIZE = 'buffer_size' +CONF_SINK_NAME = 'sink_name' +CONF_SOURCE_NAME = 'source_name' +CONF_TCP_TIMEOUT = 'tcp_timeout' -LOAD_CMD = "load-module module-loopback sink={0} source={1}" -UNLOAD_CMD = "unload-module {0}" -MOD_REGEX = r"index: ([0-9]+)\s+name: " \ - r"\s+argument: (?=<.*sink={0}.*>)(?=<.*source={1}.*>)" +DEFAULT_BUFFER_SIZE = 1024 +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'paloopback' +DEFAULT_PORT = 4712 +DEFAULT_TCP_TIMEOUT = 3 IGNORED_SWITCH_WARN = "Switch is already in the desired state. Ignoring." +LOAD_CMD = "load-module module-loopback sink={0} source={1}" + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +MOD_REGEX = r"index: ([0-9]+)\s+name: " \ + r"\s+argument: (?=<.*sink={0}.*>)(?=<.*source={1}.*>)" + +UNLOAD_CMD = "unload-module {0}" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SINK_NAME): cv.string, + vol.Required(CONF_SOURCE_NAME): cv.string, + vol.Optional(CONF_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE): + cv.positive_int, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TCP_TIMEOUT, default=DEFAULT_TCP_TIMEOUT): + cv.positive_int, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Read in all of our configuration, and initialize the loopback switch.""" - if config.get('sink_name') is None: - _LOGGER.error("Missing required variable: sink_name") - return False - - if config.get('source_name') is None: - _LOGGER.error("Missing required variable: source_name") - return False - - name = convert(config.get('name'), str, DEFAULT_NAME) - sink_name = config.get('sink_name') - source_name = config.get('source_name') - host = convert(config.get('host'), str, DEFAULT_HOST) - port = convert(config.get('port'), int, DEFAULT_PORT) - buffer_size = convert(config.get('buffer_size'), int, DEFAULT_BUFFER_SIZE) - tcp_timeout = convert(config.get('tcp_timeout'), int, DEFAULT_TCP_TIMEOUT) + name = config.get(CONF_NAME) + sink_name = config.get(CONF_SINK_NAME) + source_name = config.get(CONF_SOURCE_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + buffer_size = config.get(CONF_BUFFER_SIZE) + tcp_timeout = config.get(CONF_TCP_TIMEOUT) server_id = str.format("{0}:{1}", host, port) if server_id in _PULSEAUDIO_SERVERS: server = _PULSEAUDIO_SERVERS[server_id] - else: server = PAServer(host, port, buffer_size, tcp_timeout) - _PULSEAUDIO_SERVERS[server_id] = server - add_devices_callback([PALoopbackSwitch( - hass, - name, - server, - sink_name, - source_name - )]) + add_devices([PALoopbackSwitch(hass, name, server, sink_name, source_name)]) class PAServer(): - """Represents a pulseaudio server.""" + """Representation of a Pulseaudio server.""" _current_module_state = "" @@ -88,11 +94,11 @@ class PAServer(): sock.settimeout(self._tcp_timeout) try: sock.connect((self._pa_host, self._pa_port)) - _LOGGER.info("Calling pulseaudio:" + cmd) + _LOGGER.info("Calling pulseaudio: %s", cmd) sock.send((cmd + "\n").encode("utf-8")) if response_expected: return_data = self._get_full_response(sock) - _LOGGER.debug("Data received from pulseaudio: " + return_data) + _LOGGER.debug("Data received from pulseaudio: %s", return_data) else: return_data = "" finally: @@ -103,11 +109,11 @@ class PAServer(): """Helper method to get the full response back from pulseaudio.""" result = "" rcv_buffer = sock.recv(self._buffer_size) - result += rcv_buffer.decode("utf-8") + result += rcv_buffer.decode('utf-8') while len(rcv_buffer) == self._buffer_size: rcv_buffer = sock.recv(self._buffer_size) - result += rcv_buffer.decode("utf-8") + result += rcv_buffer.decode('utf-8') return result @@ -118,10 +124,7 @@ class PAServer(): def turn_on(self, sink_name, source_name): """Send a command to pulseaudio to turn on the loopback.""" - self._send_command(str.format(LOAD_CMD, - sink_name, - source_name), - False) + self._send_command(str.format(LOAD_CMD, sink_name, source_name), False) def turn_off(self, module_idx): """Send a command to pulseaudio to turn off the loopback.""" @@ -129,8 +132,7 @@ class PAServer(): def get_module_idx(self, sink_name, source_name): """For a sink/source, return it's module id in our cache, if found.""" - result = re.search(str.format(MOD_REGEX, - re.escape(sink_name), + result = re.search(str.format(MOD_REGEX, re.escape(sink_name), re.escape(source_name)), self._current_module_state) if result and result.group(1).isdigit(): @@ -141,11 +143,10 @@ class PAServer(): # pylint: disable=too-many-arguments class PALoopbackSwitch(SwitchDevice): - """Represents the presence or absence of a pa loopback module.""" + """Representation the presence or absence of a PA loopback module.""" - def __init__(self, hass, name, pa_server, - sink_name, source_name): - """Initialize the switch.""" + def __init__(self, hass, name, pa_server, sink_name, source_name): + """Initialize the Pulseaudio switch.""" self._module_idx = -1 self._hass = hass self._name = name @@ -168,8 +169,8 @@ class PALoopbackSwitch(SwitchDevice): if not self.is_on: self._pa_svr.turn_on(self._sink_name, self._source_name) self._pa_svr.update_module_state(no_throttle=True) - self._module_idx = self._pa_svr.get_module_idx(self._sink_name, - self._source_name) + self._module_idx = self._pa_svr.get_module_idx( + self._sink_name, self._source_name) self.update_ha_state() else: _LOGGER.warning(IGNORED_SWITCH_WARN) @@ -179,8 +180,8 @@ class PALoopbackSwitch(SwitchDevice): if self.is_on: self._pa_svr.turn_off(self._module_idx) self._pa_svr.update_module_state(no_throttle=True) - self._module_idx = self._pa_svr.get_module_idx(self._sink_name, - self._source_name) + self._module_idx = self._pa_svr.get_module_idx( + self._sink_name, self._source_name) self.update_ha_state() else: _LOGGER.warning(IGNORED_SWITCH_WARN) @@ -188,5 +189,5 @@ class PALoopbackSwitch(SwitchDevice): def update(self): """Refresh state in case an alternate process modified this data.""" self._pa_svr.update_module_state() - self._module_idx = self._pa_svr.get_module_idx(self._sink_name, - self._source_name) + self._module_idx = self._pa_svr.get_module_idx( + self._sink_name, self._source_name) From 7aafa309c9a8f06c27490d77114e1e7bd6fc01c9 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 7 Sep 2016 03:18:34 +0200 Subject: [PATCH 158/208] Use voluptuous for Verisure (#3169) * Migrate to voluptuous * Update type and add missing config variable --- .../alarm_control_panel/verisure.py | 6 +-- homeassistant/components/lock/verisure.py | 5 ++- homeassistant/components/sensor/verisure.py | 19 +++++----- homeassistant/components/switch/verisure.py | 5 ++- homeassistant/components/verisure.py | 38 ++++++++++++++----- 5 files changed, 47 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index ee1ccfc1bd0..248d575baf7 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -8,7 +8,7 @@ import logging import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.verisure import HUB as hub - +from homeassistant.components.verisure import (CONF_ALARM, CONF_CODE_DIGITS) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure platform.""" alarms = [] - if int(hub.config.get('alarm', '1')): + if int(hub.config.get(CONF_ALARM, 1)): hub.update_alarms() alarms.extend([ VerisureAlarm(value.id) @@ -36,7 +36,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): """Initalize the Verisure alarm panel.""" self._id = device_id self._state = STATE_UNKNOWN - self._digits = int(hub.config.get('code_digits', '4')) + self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None @property diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index fe7a9eeaf5a..d758f4dc91d 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/verisure/ import logging from homeassistant.components.verisure import HUB as hub +from homeassistant.components.verisure import (CONF_LOCKS, CONF_CODE_DIGITS) from homeassistant.components.lock import LockDevice from homeassistant.const import ( ATTR_CODE, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) @@ -17,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure platform.""" locks = [] - if int(hub.config.get('locks', '1')): + if int(hub.config.get(CONF_LOCKS, 1)): hub.update_locks() locks.extend([ VerisureDoorlock(device_id) @@ -34,7 +35,7 @@ class VerisureDoorlock(LockDevice): """Initialize the lock.""" self._id = device_id self._state = STATE_UNKNOWN - self._digits = int(hub.config.get('code_digits', '4')) + self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None @property diff --git a/homeassistant/components/sensor/verisure.py b/homeassistant/components/sensor/verisure.py index 4252c9d8b33..932da40bc9f 100644 --- a/homeassistant/components/sensor/verisure.py +++ b/homeassistant/components/sensor/verisure.py @@ -2,11 +2,13 @@ Interfaces with Verisure sensors. For more details about this platform, please refer to the documentation at -documentation at https://home-assistant.io/components/verisure/ +https://home-assistant.io/components/sensor.verisure/ """ import logging from homeassistant.components.verisure import HUB as hub +from homeassistant.components.verisure import ( + CONF_THERMOMETERS, CONF_HYDROMETERS, CONF_MOUSE) from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity @@ -17,7 +19,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure platform.""" sensors = [] - if int(hub.config.get('thermometers', '1')): + if int(hub.config.get(CONF_THERMOMETERS, 1)): hub.update_climate() sensors.extend([ VerisureThermometer(value.id) @@ -25,7 +27,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if hasattr(value, 'temperature') and value.temperature ]) - if int(hub.config.get('hygrometers', '1')): + if int(hub.config.get(CONF_HYDROMETERS, 1)): hub.update_climate() sensors.extend([ VerisureHygrometer(value.id) @@ -33,7 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if hasattr(value, 'humidity') and value.humidity ]) - if int(hub.config.get('mouse', '1')): + if int(hub.config.get(CONF_MOUSE, 1)): hub.update_mousedetection() sensors.extend([ VerisureMouseDetection(value.deviceLabel) @@ -56,8 +58,7 @@ class VerisureThermometer(Entity): def name(self): """Return the name of the device.""" return '{} {}'.format( - hub.climate_status[self._id].location, - "Temperature") + hub.climate_status[self._id].location, 'Temperature') @property def state(self): @@ -91,8 +92,7 @@ class VerisureHygrometer(Entity): def name(self): """Return the name of the sensor.""" return '{} {}'.format( - hub.climate_status[self._id].location, - "Humidity") + hub.climate_status[self._id].location, 'Humidity') @property def state(self): @@ -126,8 +126,7 @@ class VerisureMouseDetection(Entity): def name(self): """Return the name of the sensor.""" return '{} {}'.format( - hub.mouse_status[self._id].location, - "Mouse") + hub.mouse_status[self._id].location, 'Mouse') @property def state(self): diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index 1bd0a46fb78..d7974335811 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -2,11 +2,12 @@ Support for Verisure Smartplugs. For more details about this platform, please refer to the documentation at -documentation at https://home-assistant.io/components/verisure/ +https://home-assistant.io/components/switch.verisure/ """ import logging from homeassistant.components.verisure import HUB as hub +from homeassistant.components.verisure import CONF_SMARTPLUGS from homeassistant.components.switch import SwitchDevice _LOGGER = logging.getLogger(__name__) @@ -14,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure switch platform.""" - if not int(hub.config.get('smartplugs', '1')): + if not int(hub.config.get(CONF_SMARTPLUGS, 1)): return False hub.update_smartplugs() diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 1231a4128fa..8634184fe57 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -9,26 +9,46 @@ import threading import time from datetime import timedelta -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config, discovery -from homeassistant.util import Throttle +import voluptuous as vol -DOMAIN = "verisure" +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import discovery +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['vsure==0.10.2'] _LOGGER = logging.getLogger(__name__) +CONF_ALARM = 'alarm' +CONF_CODE_DIGITS = 'code_digits' +CONF_HYDROMETERS = 'hygrometers' +CONF_LOCKS = 'locks' +CONF_MOUSE = 'mouse' +CONF_SMARTPLUGS = 'smartplugs' +CONF_THERMOMETERS = 'thermometers' + +DOMAIN = 'verisure' + HUB = None +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_ALARM, default=True): cv.boolean, + vol.Optional(CONF_CODE_DIGITS, default=4): cv.positive_int, + vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, + vol.Optional(CONF_LOCKS, default=True): cv.boolean, + vol.Optional(CONF_MOUSE, default=True): cv.boolean, + vol.Optional(CONF_SMARTPLUGS, default=True): cv.boolean, + vol.Optional(CONF_THERMOMETERS, default=True): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Setup the Verisure component.""" - if not validate_config(config, - {DOMAIN: [CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return False - import verisure global HUB HUB = VerisureHub(config[DOMAIN], verisure) From d53d8f5ea94e95836ce3f3bdd373ee8ef71293dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Sandstr=C3=B6m?= Date: Wed, 7 Sep 2016 03:21:38 +0200 Subject: [PATCH 159/208] thread safe modbus (#3188) --- homeassistant/components/modbus.py | 84 +++++++++++++++++++---- homeassistant/components/sensor/modbus.py | 7 +- homeassistant/components/switch/modbus.py | 21 +++--- 3 files changed, 81 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 1d6ad0e3abc..4aab9ddc756 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/modbus/ """ import logging +import threading from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -37,7 +38,7 @@ ATTR_ADDRESS = "address" ATTR_UNIT = "unit" ATTR_VALUE = "value" -NETWORK = None +HUB = None TYPE = None @@ -50,34 +51,36 @@ def setup(hass, config): # Connect to Modbus network # pylint: disable=global-statement, import-error - global NETWORK if TYPE == "serial": from pymodbus.client.sync import ModbusSerialClient as ModbusClient - NETWORK = ModbusClient(method=config[DOMAIN][METHOD], - port=config[DOMAIN][SERIAL_PORT], - baudrate=config[DOMAIN][BAUDRATE], - stopbits=config[DOMAIN][STOPBITS], - bytesize=config[DOMAIN][BYTESIZE], - parity=config[DOMAIN][PARITY]) + client = ModbusClient(method=config[DOMAIN][METHOD], + port=config[DOMAIN][SERIAL_PORT], + baudrate=config[DOMAIN][BAUDRATE], + stopbits=config[DOMAIN][STOPBITS], + bytesize=config[DOMAIN][BYTESIZE], + parity=config[DOMAIN][PARITY]) elif TYPE == "tcp": from pymodbus.client.sync import ModbusTcpClient as ModbusClient - NETWORK = ModbusClient(host=config[DOMAIN][HOST], - port=config[DOMAIN][IP_PORT]) + client = ModbusClient(host=config[DOMAIN][HOST], + port=config[DOMAIN][IP_PORT]) elif TYPE == "udp": from pymodbus.client.sync import ModbusUdpClient as ModbusClient - NETWORK = ModbusClient(host=config[DOMAIN][HOST], - port=config[DOMAIN][IP_PORT]) + client = ModbusClient(host=config[DOMAIN][HOST], + port=config[DOMAIN][IP_PORT]) else: return False + global HUB + HUB = ModbusHub(client) + def stop_modbus(event): """Stop Modbus service.""" - NETWORK.close() + HUB.close() def start_modbus(event): """Start Modbus service.""" - NETWORK.connect() + HUB.connect() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) # Register services for modbus @@ -88,8 +91,59 @@ def setup(hass, config): unit = int(float(service.data.get(ATTR_UNIT))) address = int(float(service.data.get(ATTR_ADDRESS))) value = int(float(service.data.get(ATTR_VALUE))) - NETWORK.write_register(address, value, unit=unit) + HUB.write_register(unit, address, value) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) return True + + +class ModbusHub(object): + """Thread safe wrapper class for pymodbus.""" + + def __init__(self, modbus_client): + """Initialize the modbus hub.""" + self._client = modbus_client + self._lock = threading.Lock() + + def close(self): + """Disconnect client.""" + with self._lock: + self._client.close() + + def connect(self): + """Connect client.""" + with self._lock: + self._client.connect() + + def read_coils(self, unit, address, count): + """Read coils.""" + with self._lock: + return self._client.read_coils( + address, + count, + unit=unit) + + def read_holding_registers(self, unit, address, count): + """Read holding registers.""" + with self._lock: + return self._client.read_holding_registers( + address, + count, + unit=unit) + + def write_coil(self, unit, address, value): + """Write coil.""" + with self._lock: + self._client.write_coil( + address, + value, + unit=unit) + + def write_register(self, unit, address, value): + """Write register.""" + with self._lock: + self._client.write_register( + address, + value, + unit=unit) diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index d6c85993162..063c1dc8600 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -114,12 +114,11 @@ class ModbusSensor(Entity): def update(self): """Update the state of the sensor.""" if self._coil: - result = modbus.NETWORK.read_coils(self.register, 1) + result = modbus.HUB.read_coils(self.slave, self.register, 1) self._value = result.bits[0] else: - result = modbus.NETWORK.read_holding_registers( - unit=self.slave, address=self.register, - count=1) + result = modbus.HUB.read_holding_registers( + self.slave, self.register, 1) val = 0 for i, res in enumerate(result.registers): val += res * (2**(i*16)) diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index 971947a6ed3..2ae0c74991d 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -90,12 +90,10 @@ class ModbusSwitch(ToggleEntity): self.update() if self._coil: - modbus.NETWORK.write_coil(self.register, True) + modbus.HUB.write_coil(self.slave, self.register, True) else: val = self.register_value | (0x0001 << self.bit) - modbus.NETWORK.write_register(unit=self.slave, - address=self.register, - value=val) + modbus.HUB.write_register(self.slave, self.register, val) def turn_off(self, **kwargs): """Set switch off.""" @@ -103,23 +101,22 @@ class ModbusSwitch(ToggleEntity): self.update() if self._coil: - modbus.NETWORK.write_coil(self.register, False) + modbus.HUB.write_coil(self.slave, self.register, False) else: val = self.register_value & ~(0x0001 << self.bit) - modbus.NETWORK.write_register(unit=self.slave, - address=self.register, - value=val) + modbus.HUB.write_register(self.slave, self.register, val) def update(self): """Update the state of the switch.""" if self._coil: - result = modbus.NETWORK.read_coils(self.register, 1) + result = modbus.HUB.read_coils(self.slave, self.register, 1) self.register_value = result.bits[0] self._is_on = self.register_value else: - result = modbus.NETWORK.read_holding_registers( - unit=self.slave, address=self.register, - count=1) + result = modbus.HUB.read_holding_registers( + self.slave, + self.register, + 1) val = 0 for i, res in enumerate(result.registers): val += res * (2**(i*16)) From fb719f530a0a4ce9fd5c94b2d404eb17bd053cf2 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Tue, 6 Sep 2016 21:23:08 -0400 Subject: [PATCH 160/208] Upgraded fitbit to version 0.2.3 which fixed oauthlib.oauth2.rfc6749.errors.TokenExpiredError: (token_expired) (#3244) --- homeassistant/components/sensor/fitbit.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index eb87527a546..b99a4f320c9 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -16,7 +16,7 @@ from homeassistant.loader import get_component from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["fitbit==0.2.2"] +REQUIREMENTS = ["fitbit==0.2.3"] DEPENDENCIES = ["http"] ICON = "mdi:walk" diff --git a/requirements_all.txt b/requirements_all.txt index 65a66692a3b..547e36b9f27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -82,7 +82,7 @@ evohomeclient==0.2.5 feedparser==5.2.1 # homeassistant.components.sensor.fitbit -fitbit==0.2.2 +fitbit==0.2.3 # homeassistant.components.sensor.fixer fixerio==0.1.1 From 165871d48a4cd892872ead220c2a31e3f038abd6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 7 Sep 2016 03:24:11 +0200 Subject: [PATCH 161/208] update ffmpeg version to 0.10 add get image to camera (#3235) --- .../components/binary_sensor/ffmpeg.py | 2 +- homeassistant/components/camera/ffmpeg.py | 24 ++++++++----------- requirements_all.txt | 2 +- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/binary_sensor/ffmpeg.py b/homeassistant/components/binary_sensor/ffmpeg.py index e02a560ec54..9c37ff7744c 100644 --- a/homeassistant/components/binary_sensor/ffmpeg.py +++ b/homeassistant/components/binary_sensor/ffmpeg.py @@ -16,7 +16,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_NAME, ATTR_ENTITY_ID) -REQUIREMENTS = ["ha-ffmpeg==0.9"] +REQUIREMENTS = ["ha-ffmpeg==0.10"] SERVICE_RESTART = 'ffmpeg_restart' diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 23d6874cd81..af21537e3c3 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -5,16 +5,14 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.ffmpeg/ """ import logging -from contextlib import closing import voluptuous as vol from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) -from homeassistant.components.camera.mjpeg import extract_image_from_mjpeg import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_NAME -REQUIREMENTS = ['ha-ffmpeg==0.9'] +REQUIREMENTS = ['ha-ffmpeg==0.10'] _LOGGER = logging.getLogger(__name__) @@ -49,22 +47,20 @@ class FFmpegCamera(Camera): self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) self._ffmpeg_bin = config.get(CONF_FFMPEG_BIN) - def _ffmpeg_stream(self): - """Return a FFmpeg process object.""" - from haffmpeg import CameraMjpeg - - ffmpeg = CameraMjpeg(self._ffmpeg_bin) - ffmpeg.open_camera(self._input, extra_cmd=self._extra_arguments) - return ffmpeg - def camera_image(self): """Return a still image response from the camera.""" - with closing(self._ffmpeg_stream()) as stream: - return extract_image_from_mjpeg(stream) + from haffmpeg import ImageSingle, IMAGE_JPEG + ffmpeg = ImageSingle(self._ffmpeg_bin) + + return ffmpeg.get_image(self._input, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments) def mjpeg_stream(self, response): """Generate an HTTP MJPEG stream from the camera.""" - stream = self._ffmpeg_stream() + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._ffmpeg_bin) + stream.open_camera(self._input, extra_cmd=self._extra_arguments) return response( stream, mimetype='multipart/x-mixed-replace;boundary=ffserver', diff --git a/requirements_all.txt b/requirements_all.txt index 547e36b9f27..68be8a11d7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -107,7 +107,7 @@ gps3==0.33.3 # homeassistant.components.binary_sensor.ffmpeg # homeassistant.components.camera.ffmpeg -ha-ffmpeg==0.9 +ha-ffmpeg==0.10 # homeassistant.components.mqtt.server hbmqtt==0.7.1 From 6a837f3aad157d72cceac4324ff5983e6f174091 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 7 Sep 2016 03:28:55 +0200 Subject: [PATCH 162/208] Migrate to voluptuous (#3234) --- .../components/binary_sensor/zigbee.py | 21 ++++++--- homeassistant/components/light/zigbee.py | 21 ++++++--- homeassistant/components/sensor/zigbee.py | 31 +++++++++---- homeassistant/components/switch/zigbee.py | 23 +++++++--- homeassistant/components/zigbee.py | 44 +++++++++++++------ 5 files changed, 100 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py index 7e4139d4680..2eb508304d4 100644 --- a/homeassistant/components/binary_sensor/zigbee.py +++ b/homeassistant/components/binary_sensor/zigbee.py @@ -4,18 +4,27 @@ Contains functionality to use a ZigBee device as a binary sensor. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.zigbee/ """ +import voluptuous as vol + from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.zigbee import ( - ZigBeeDigitalIn, ZigBeeDigitalInConfig) + ZigBeeDigitalIn, ZigBeeDigitalInConfig, PLATFORM_SCHEMA) -DEPENDENCIES = ["zigbee"] +CONF_ON_STATE = 'on_state' + +DEFAULT_ON_STATE = 'high' +DEPENDENCIES = ['zigbee'] + +STATES = ['high', 'low'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ON_STATE): vol.In(STATES), +}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ZigBee binary sensor platform.""" - add_entities([ - ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config)) - ]) + add_devices([ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))]) class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice): diff --git a/homeassistant/components/light/zigbee.py b/homeassistant/components/light/zigbee.py index 1ab6a0b265a..f4406abf7bd 100644 --- a/homeassistant/components/light/zigbee.py +++ b/homeassistant/components/light/zigbee.py @@ -4,18 +4,27 @@ Functionality to use a ZigBee device as a light. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.zigbee/ """ +import voluptuous as vol + from homeassistant.components.light import Light from homeassistant.components.zigbee import ( - ZigBeeDigitalOut, ZigBeeDigitalOutConfig) + ZigBeeDigitalOut, ZigBeeDigitalOutConfig, PLATFORM_SCHEMA) -DEPENDENCIES = ["zigbee"] +CONF_ON_STATE = 'on_state' + +DEFAULT_ON_STATE = 'high' +DEPENDENCIES = ['zigbee'] + +STATES = ['high', 'low'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ON_STATE, default=DEFAULT_ON_STATE): vol.In(STATES), +}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Create and add an entity based on the configuration.""" - add_entities([ - ZigBeeLight(hass, ZigBeeDigitalOutConfig(config)) - ]) + add_devices([ZigBeeLight(hass, ZigBeeDigitalOutConfig(config))]) class ZigBeeLight(ZigBeeDigitalOut, Light): diff --git a/homeassistant/components/sensor/zigbee.py b/homeassistant/components/sensor/zigbee.py index 2692bcf9715..6b455230aa6 100644 --- a/homeassistant/components/sensor/zigbee.py +++ b/homeassistant/components/sensor/zigbee.py @@ -7,32 +7,45 @@ https://home-assistant.io/components/sensor.zigbee/ import logging from binascii import hexlify +import voluptuous as vol + from homeassistant.components import zigbee +from homeassistant.components.zigbee import PLATFORM_SCHEMA from homeassistant.const import TEMP_CELSIUS from homeassistant.core import JobPriority from homeassistant.helpers.entity import Entity -DEPENDENCIES = ["zigbee"] _LOGGER = logging.getLogger(__name__) +CONF_TYPE = 'type' +CONF_MAX_VOLTS = 'max_volts' -def setup_platform(hass, config, add_entities, discovery_info=None): - """Setup the Z-Wave platform. +DEFAULT_VOLTS = 1.2 +DEPENDENCIES = ['zigbee'] + +TYPES = ['analog', 'temperature'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TYPE): vol.In(TYPES), + vol.Optional(CONF_MAX_VOLTS, default=DEFAULT_VOLTS): vol.Coerce(float), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ZigBee platform. Uses the 'type' config value to work out which type of ZigBee sensor we're dealing with and instantiates the relevant classes to handle it. """ - typ = config.get("type", "").lower() - if not typ: - _LOGGER.exception( - "Must include 'type' when configuring a ZigBee sensor.") - return + typ = config.get(CONF_TYPE) + try: sensor_class, config_class = TYPE_CLASSES[typ] except KeyError: _LOGGER.exception("Unknown ZigBee sensor type: %s", typ) return - add_entities([sensor_class(hass, config_class(config))]) + + add_devices([sensor_class(hass, config_class(config))]) class ZigBeeTemperatureSensor(Entity): diff --git a/homeassistant/components/switch/zigbee.py b/homeassistant/components/switch/zigbee.py index 4588be139a2..7a58b0867c1 100644 --- a/homeassistant/components/switch/zigbee.py +++ b/homeassistant/components/switch/zigbee.py @@ -4,18 +4,29 @@ Contains functionality to use a ZigBee device as a switch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.zigbee/ """ +import voluptuous as vol + from homeassistant.components.switch import SwitchDevice from homeassistant.components.zigbee import ( - ZigBeeDigitalOut, ZigBeeDigitalOutConfig) + ZigBeeDigitalOut, ZigBeeDigitalOutConfig, PLATFORM_SCHEMA) -DEPENDENCIES = ["zigbee"] +DEPENDENCIES = ['zigbee'] + +CONF_ON_STATE = 'on_state' + +DEFAULT_ON_STATE = 'high' +DEPENDENCIES = ['zigbee'] + +STATES = ['high', 'low'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ON_STATE): vol.In(STATES), +}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ZigBee switch platform.""" - add_entities([ - ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config)) - ]) + add_devices([ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config))]) class ZigBeeSwitch(ZigBeeDigitalOut, SwitchDevice): diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py index 84770390ad9..4b4da350199 100644 --- a/homeassistant/components/zigbee.py +++ b/homeassistant/components/zigbee.py @@ -9,19 +9,26 @@ import pickle from binascii import hexlify, unhexlify from base64 import b64encode, b64decode -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_PIN) from homeassistant.core import JobPriority from homeassistant.helpers.entity import Entity +from homeassistant.helpers import config_validation as cv -DOMAIN = "zigbee" -REQUIREMENTS = ("xbee-helper==0.0.7",) +REQUIREMENTS = ['xbee-helper==0.0.7'] -EVENT_ZIGBEE_FRAME_RECEIVED = "zigbee_frame_received" +_LOGGER = logging.getLogger(__name__) -CONF_DEVICE = "device" -CONF_BAUD = "baud" +DOMAIN = 'zigbee' -DEFAULT_DEVICE = "/dev/ttyUSB0" +EVENT_ZIGBEE_FRAME_RECEIVED = 'zigbee_frame_received' + +CONF_ADDRESS = 'address' +CONF_BAUD = 'baud' + +DEFAULT_DEVICE = '/dev/ttyUSB0' DEFAULT_BAUD = 9600 DEFAULT_ADC_MAX_VOLTS = 1.2 @@ -35,11 +42,22 @@ CONVERT_ADC = None ZIGBEE_EXCEPTION = None ZIGBEE_TX_FAILURE = None -ATTR_FRAME = "frame" +ATTR_FRAME = 'frame' DEVICE = None -_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BAUD, default=DEFAULT_BAUD): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PIN): cv.positive_int, + vol.Optional(CONF_ADDRESS): cv.string, +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): @@ -101,9 +119,9 @@ def close_serial_port(*args): def frame_is_relevant(entity, frame): """Test whether the frame is relevant to the entity.""" - if frame.get("source_addr_long") != entity.config.address: + if frame.get('source_addr_long') != entity.config.address: return False - if "samples" not in frame: + if 'samples' not in frame: return False return True @@ -279,7 +297,7 @@ class ZigBeeDigitalIn(Entity): """ if not frame_is_relevant(self, frame): return - sample = frame["samples"].pop() + sample = frame['samples'].pop() pin_name = DIGITAL_PINS[self._config.pin] if pin_name not in sample: # Doesn't contain information about our pin @@ -402,7 +420,7 @@ class ZigBeeAnalogIn(Entity): """ if not frame_is_relevant(self, frame): return - sample = frame["samples"].pop() + sample = frame['samples'].pop() pin_name = ANALOG_PINS[self._config.pin] if pin_name not in sample: # Doesn't contain information about our pin From d7b757fb97a1381b250c7cd773f41922d65d60b0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 7 Sep 2016 03:31:56 +0200 Subject: [PATCH 163/208] fix bugfix with unique_id (#3217) --- homeassistant/components/media_player/sonos.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 62b2aeabf51..5fc0166aefa 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -199,6 +199,7 @@ class SonosDevice(MediaPlayerDevice): self.hass = hass self.volume_increment = 5 self._player = player + self._name = None self.update() self.soco_snapshot = Snapshot(self._player) @@ -216,11 +217,6 @@ class SonosDevice(MediaPlayerDevice): """Return the name of the device.""" return self._name - @property - def unique_id(self): - """Return a unique ID.""" - return "{}.{}".format(self.__class__, self._player.uid) - @property def state(self): """Return the state of the device.""" From e88e6d10305c61123954f0e4a7e5e9d0c64d435f Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Wed, 7 Sep 2016 03:34:28 +0200 Subject: [PATCH 164/208] Zwave climate fix and wink cover. (#3205) * Fixes setpoint get was done outside loop * zxt_120 * Wink not migrated to cover * Clarifying debug * too long line * Only add 1 device entity --- homeassistant/components/climate/zwave.py | 13 +++++++++++++ homeassistant/components/wink.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 530e3ea028f..8886762ee4a 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -63,6 +63,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] value = node.values[discovery_info[ATTR_VALUE_ID]] value.set_change_verified(False) + if value.index != 1: # Only add 1 device + return add_devices([ZWaveClimate(value, temp_unit)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", discovery_info, zwave.NETWORK) @@ -158,6 +160,17 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): continue if self._zxt_120: continue + self._target_temperature = int(value.data) + _LOGGER.debug("Get setpoint value: SET_TEMP_TO_INDEX=%s and" + " self._current_operation=%s", + SET_TEMP_TO_INDEX.get(self._current_operation), + self._current_operation) + break + _LOGGER.debug("Get setpoint value not matching any " + "SET_TEMP_TO_INDEX=%s and " + "self._current_operation=%s. Using value.data=%s", + SET_TEMP_TO_INDEX.get(self._current_operation), + self._current_operation, int(value.data)) self._target_temperature = int(value.data) @property diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 96f40c8d1f7..6d6e09b1918 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -41,8 +41,8 @@ def setup(hass, config): ('binary_sensor', pywink.get_sensors), ('sensor', lambda: pywink.get_sensors or pywink.get_eggtrays), ('lock', pywink.get_locks), - ('rollershutter', pywink.get_shades), - ('garage_door', pywink.get_garage_doors)): + ('cover', pywink.get_shades), + ('cover', pywink.get_garage_doors)): if func_exists(): discovery.load_platform(hass, component_name, DOMAIN, {}, config) From 47864fc7d74e1000aa22ad283e3aa5cb60f31a6e Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Wed, 7 Sep 2016 03:35:10 +0200 Subject: [PATCH 165/208] Owntracks voluptuous fix (#3191) --- homeassistant/components/device_tracker/owntracks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 77c18ae73b1..4f6e6647f1c 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -16,6 +16,7 @@ import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME from homeassistant.util import convert, slugify from homeassistant.components import zone as zone_comp +from homeassistant.components.device_tracker import PLATFORM_SCHEMA DEPENDENCIES = ['mqtt'] @@ -43,8 +44,8 @@ VALIDATE_WAYPOINTS = 'waypoints' WAYPOINT_LAT_KEY = 'lat' WAYPOINT_LON_KEY = 'lon' -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MAX_GPS_ACCURACY): cv.string, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(cv.ensure_list, [cv.string]) }) From 3668afe3065d60c0d05baeeba399a46894882dbe Mon Sep 17 00:00:00 2001 From: Dave Banks Date: Wed, 7 Sep 2016 10:22:51 +0100 Subject: [PATCH 166/208] Zwave set temperature fix (#3221) * If device was off set target temp would not work. * Changed to use a workaround just for Horstmann HRT4-ZW Zwave Thermostat * Wrong Horseman id * style changes --- homeassistant/components/climate/zwave.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 8886762ee4a..3a1152c7a96 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -23,6 +23,10 @@ REMOTEC = 0x5254 REMOTEC_ZXT_120 = 0x8377 REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) +HORSTMANN = 0x0059 +HORSTMANN_HRT4_ZW = 0x3 +HORSTMANN_HRT4_ZW_THERMOSTAT = (HORSTMANN, HORSTMANN_HRT4_ZW) + COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31 COMMAND_CLASS_THERMOSTAT_MODE = 0x40 COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43 @@ -30,9 +34,11 @@ COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44 COMMAND_CLASS_CONFIGURATION = 0x70 WORKAROUND_ZXT_120 = 'zxt_120' +WORKAROUND_HRT4_ZW = 'hrt4_zw' DEVICE_MAPPINGS = { - REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 + REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120, + HORSTMANN_HRT4_ZW_THERMOSTAT: WORKAROUND_HRT4_ZW } SET_TEMP_TO_INDEX = { @@ -92,6 +98,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self._unit = temp_unit _LOGGER.debug("temp_unit is %s", self._unit) self._zxt_120 = None + self._hrt4_zw = None self.update_properties() # register listener dispatcher.connect( @@ -101,12 +108,15 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): value.node.product_id.strip()): specific_sensor_key = (int(value.node.manufacturer_id, 16), int(value.node.product_id, 16)) - if specific_sensor_key in DEVICE_MAPPINGS: if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZXT_120: _LOGGER.debug("Remotec ZXT-120 Zwave Thermostat" " workaround") self._zxt_120 = 1 + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_HRT4_ZW: + _LOGGER.debug("Horstmann HRT4-ZW Zwave Thermostat" + " workaround") + self._hrt4_zw = 1 def value_changed(self, value): """Called when a value has changed on the network.""" @@ -233,8 +243,11 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if self.current_operation is not None: + if self._hrt4_zw and self.current_operation == 'Off': + # HRT4-ZW can change setpoint when off. + value.data = int(temperature) if SET_TEMP_TO_INDEX.get(self._current_operation) \ - != value.index: + != value.index: continue _LOGGER.debug("SET_TEMP_TO_INDEX=%s and" " self._current_operation=%s", From 91028cbc1371c1cf32f2ca2d17fec245dacc708d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 7 Sep 2016 15:57:59 +0200 Subject: [PATCH 167/208] Change PR to suggestion on gitter (#3243) --- homeassistant/components/notify/telegram.py | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 6d609555829..cc8b284b974 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -24,6 +24,10 @@ REQUIREMENTS = ['python-telegram-bot==5.0.0'] ATTR_PHOTO = "photo" ATTR_DOCUMENT = "document" ATTR_CAPTION = "caption" +ATTR_URL = 'url' +ATTR_FILE = 'file' +ATTR_USERNAME = 'username' +ATTR_PASSWORD = 'password' CONF_CHAT_ID = 'chat_id' @@ -105,8 +109,6 @@ class TelegramNotificationService(BaseNotificationService): elif data is not None and ATTR_DOCUMENT in data: return self.send_document(data.get(ATTR_DOCUMENT)) - text = '' - if title: text = '{} {}'.format(title, message) else: @@ -126,11 +128,16 @@ class TelegramNotificationService(BaseNotificationService): def send_photo(self, data): """Send a photo.""" import telegram - caption = data.pop(ATTR_CAPTION, None) + caption = data.get(ATTR_CAPTION) # send photo try: - photo = load_data(**data) + photo = load_data( + url=data.get(ATTR_URL), + file=data.get(ATTR_FILE), + username=data.get(ATTR_USERNAME), + password=data.get(ATTR_PASSWORD), + ) self.bot.sendPhoto(chat_id=self._chat_id, photo=photo, caption=caption) except telegram.error.TelegramError: @@ -140,11 +147,16 @@ class TelegramNotificationService(BaseNotificationService): def send_document(self, data): """Send a document.""" import telegram - caption = data.pop(ATTR_CAPTION, None) + caption = data.get(ATTR_CAPTION) # send photo try: - document = load_data(**data) + document = load_data( + url=data.get(ATTR_URL), + file=data.get(ATTR_FILE), + username=data.get(ATTR_USERNAME), + password=data.get(ATTR_PASSWORD), + ) self.bot.sendDocument(chat_id=self._chat_id, document=document, caption=caption) except telegram.error.TelegramError: From 35b388edcedb35915ba6e20308520a2224e2c8eb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 7 Sep 2016 06:59:16 -0700 Subject: [PATCH 168/208] Reload groups (#3203) * Allow reloading groups without restart * Test to make sure automation listeners are removed. * Remove unused imports for group tests * Simplify group config validation * Add prepare_reload function to entity component * Migrate group to use entity_component.prepare_reload * Migrate automation to use entity_component.prepare_reload * Clean up group.get_entity_ids * Use cv.boolean for group config validation --- homeassistant/bootstrap.py | 4 +- .../components/automation/__init__.py | 15 +-- homeassistant/components/group.py | 116 +++++++++++------- homeassistant/components/services.yaml | 5 + homeassistant/helpers/entity_component.py | 25 +++- homeassistant/helpers/template.py | 4 +- tests/components/automation/test_init.py | 6 + tests/components/test_group.py | 31 +++++ 8 files changed, 143 insertions(+), 63 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3e8ed6ad77f..5e291e90717 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -14,7 +14,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error import homeassistant.components as core_components -from homeassistant.components import group, persistent_notification +from homeassistant.components import persistent_notification import homeassistant.config as conf_util import homeassistant.core as core import homeassistant.loader as loader @@ -118,7 +118,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: # Assumption: if a component does not depend on groups # it communicates with devices - if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []): + if 'group' not in getattr(component, 'DEPENDENCIES', []): hass.pool.add_worker() hass.bus.fire( diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 40715bca502..863d94033a8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -10,8 +10,7 @@ import os import voluptuous as vol -from homeassistant.bootstrap import ( - prepare_setup_platform, prepare_setup_component) +from homeassistant.bootstrap import prepare_setup_platform from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, @@ -183,19 +182,9 @@ def setup(hass, config): def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" - try: - path = conf_util.find_config_file(hass.config.config_dir) - conf = conf_util.load_yaml_config_file(path) - except HomeAssistantError as err: - _LOGGER.error(err) - return - - conf = prepare_setup_component(hass, conf, DOMAIN) - + conf = component.prepare_reload() if conf is None: return - - component.reset() _process_config(hass, conf, component) hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler, diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 4444b97ebe2..c4cd177925d 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -4,17 +4,19 @@ Provides functionality to group entities. For more details about this component, please refer to the documentation at https://home-assistant.io/components/group/ """ +import logging +import os import threading -from collections import OrderedDict import voluptuous as vol -import homeassistant.core as ha +from homeassistant import config as conf_util, core as ha from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE) from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import track_state_change import homeassistant.helpers.config_validation as cv @@ -29,36 +31,27 @@ ATTR_AUTO = 'auto' ATTR_ORDER = 'order' ATTR_VIEW = 'view' +SERVICE_RELOAD = 'reload' +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + +_LOGGER = logging.getLogger(__name__) + def _conf_preprocess(value): """Preprocess alternative configuration formats.""" - if isinstance(value, (str, list)): + if not isinstance(value, dict): value = {CONF_ENTITIES: value} return value -_SINGLE_GROUP_CONFIG = vol.Schema(vol.All(_conf_preprocess, { - vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), - CONF_VIEW: bool, - CONF_NAME: str, - CONF_ICON: cv.icon, -})) - - -def _group_dict(value): - """Validate a dictionary of group definitions.""" - config = OrderedDict() - for key, group in value.items(): - try: - config[key] = _SINGLE_GROUP_CONFIG(group) - except vol.MultipleInvalid as ex: - raise vol.Invalid('Group {} is invalid: {}'.format(key, ex)) - - return config - CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(dict, _group_dict) + DOMAIN: {cv.match_all: vol.Schema(vol.All(_conf_preprocess, { + vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), + CONF_VIEW: cv.boolean, + CONF_NAME: cv.string, + CONF_ICON: cv.icon, + }))} }, extra=vol.ALLOW_EXTRA) # List of ON/OFF state tuples for groupable states @@ -88,6 +81,11 @@ def is_on(hass, entity_id): return False +def reload(hass): + """Reload the automation from config.""" + hass.services.call(DOMAIN, SERVICE_RELOAD) + + def expand_entity_ids(hass, entity_ids): """Return entity_ids with group entity ids replaced by their members.""" found_ids = [] @@ -121,35 +119,59 @@ def expand_entity_ids(hass, entity_ids): def get_entity_ids(hass, entity_id, domain_filter=None): """Get members of this group.""" - entity_id = entity_id.lower() + group = hass.states.get(entity_id) - try: - entity_ids = hass.states.get(entity_id).attributes[ATTR_ENTITY_ID] - - if domain_filter: - domain_filter = domain_filter.lower() - - return [ent_id for ent_id in entity_ids - if ent_id.startswith(domain_filter)] - else: - return entity_ids - - except (AttributeError, KeyError): - # AttributeError if state did not exist - # KeyError if key did not exist in attributes + if not group or ATTR_ENTITY_ID not in group.attributes: return [] + entity_ids = group.attributes[ATTR_ENTITY_ID] + + if not domain_filter: + return entity_ids + + domain_filter = domain_filter.lower() + '.' + + return [ent_id for ent_id in entity_ids + if ent_id.startswith(domain_filter)] + def setup(hass, config): """Setup all groups found definded in the configuration.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + success = _process_config(hass, config, component) + + if not success: + return False + + descriptions = conf_util.load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def reload_service_handler(service_call): + """Remove all groups and load new ones from config.""" + conf = component.prepare_reload() + if conf is None: + return + _process_config(hass, conf, component) + + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_service_handler, + descriptions[DOMAIN][SERVICE_RELOAD], + schema=RELOAD_SERVICE_SCHEMA) + + return True + + +def _process_config(hass, config, component): + """Process group configuration.""" for object_id, conf in config.get(DOMAIN, {}).items(): name = conf.get(CONF_NAME, object_id) entity_ids = conf.get(CONF_ENTITIES) or [] icon = conf.get(CONF_ICON) view = conf.get(CONF_VIEW) - Group(hass, name, entity_ids, icon=icon, view=view, - object_id=object_id) + group = Group(hass, name, entity_ids, icon=icon, view=view, + object_id=object_id) + component.add_entities((group,)) return True @@ -242,17 +264,21 @@ class Group(Entity): def stop(self): """Unregister the group from Home Assistant.""" - self.hass.states.remove(self.entity_id) - - if self._unsub_state_changed: - self._unsub_state_changed() - self._unsub_state_changed = None + self.remove() def update(self): """Query all members and determine current group state.""" self._state = STATE_UNKNOWN self._update_group_state() + def remove(self): + """Remove group from HASS.""" + super().remove() + + if self._unsub_state_changed: + self._unsub_state_changed() + self._unsub_state_changed = None + def _state_changed_listener(self, entity_id, old_state, new_state): """Respond to a member state changing.""" self._update_group_state(new_state) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index ac6d9829fc5..4f79a2ee627 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -39,6 +39,11 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 +group: + reload: + description: "Reload group configuration." + fields: + persistent_notification: create: description: Show a notification in the frontend diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index e853d20df89..3146d703d19 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -1,11 +1,14 @@ """Helpers for components that manage entities.""" from threading import Lock -from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.components import group +from homeassistant import config as conf_util +from homeassistant.bootstrap import (prepare_setup_platform, + prepare_setup_component) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, DEVICE_DEFAULT_NAME) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import get_component from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import track_utc_time_change @@ -135,6 +138,7 @@ class EntityComponent(object): def update_group(self): """Set up and/or update component group.""" if self.group is None and self.group_name is not None: + group = get_component('group') self.group = group.Group(self.hass, self.group_name, user_defined=False) @@ -157,6 +161,23 @@ class EntityComponent(object): self.group.stop() self.group = None + def prepare_reload(self): + """Prepare reloading this entity component.""" + try: + path = conf_util.find_config_file(self.hass.config.config_dir) + conf = conf_util.load_yaml_config_file(path) + except HomeAssistantError as err: + self.logger.error(err) + return None + + conf = prepare_setup_component(self.hass, conf, self.domain) + + if conf is None: + return None + + self.reset() + return conf + class EntityPlatform(object): """Keep track of entities for a single platform.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fab081cc5c5..056a4e60183 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,11 +6,11 @@ import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment -from homeassistant.components import group from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import State from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper +from homeassistant.loader import get_component from homeassistant.util import convert, dt as dt_util, location as loc_util _LOGGER = logging.getLogger(__name__) @@ -169,6 +169,8 @@ class LocationMethods(object): else: gr_entity_id = str(entities) + group = get_component('group') + states = [self._hass.states.get(entity_id) for entity_id in group.expand_entity_ids(self._hass, [gr_entity_id])] diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index f244bb3a23b..3d69cca2d32 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -450,6 +450,9 @@ class TestAutomation(unittest.TestCase): }) assert self.hass.states.get('automation.hello') is not None assert self.hass.states.get('automation.bye') is None + listeners = self.hass.bus.listeners + assert listeners.get('test_event') == 1 + assert listeners.get('test_event2') is None self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -462,6 +465,9 @@ class TestAutomation(unittest.TestCase): assert self.hass.states.get('automation.hello') is None assert self.hass.states.get('automation.bye') is not None + listeners = self.hass.bus.listeners + assert listeners.get('test_event') is None + assert listeners.get('test_event2') == 1 self.hass.bus.fire('test_event') self.hass.pool.block_till_done() diff --git a/tests/components/test_group.py b/tests/components/test_group.py index d815489ae21..e82190a3f29 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -1,6 +1,7 @@ """The tests for the Group components.""" # pylint: disable=protected-access,too-many-public-methods import unittest +from unittest.mock import patch from homeassistant.bootstrap import _setup_component from homeassistant.const import ( @@ -308,3 +309,33 @@ class TestComponentsGroup(unittest.TestCase): self.assertEqual(STATE_NOT_HOME, self.hass.states.get( group.ENTITY_ID_FORMAT.format('peeps')).state) + + def test_reloading_groups(self): + """Test reloading the group config.""" + _setup_component(self.hass, 'group', {'group': { + 'second_group': { + 'entities': 'light.Bowl', + 'icon': 'mdi:work', + 'view': True, + }, + 'test_group': 'hello.world,sensor.happy', + 'empty_group': {'name': 'Empty Group', 'entities': None}, + } + }) + + assert sorted(self.hass.states.entity_ids()) == \ + ['group.empty_group', 'group.second_group', 'group.test_group'] + assert self.hass.bus.listeners['state_changed'] == 3 + + with patch('homeassistant.config.load_yaml_config_file', return_value={ + 'group': { + 'hello': { + 'entities': 'light.Bowl', + 'icon': 'mdi:work', + 'view': True, + }}}): + group.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.entity_ids() == ['group.hello'] + assert self.hass.bus.listeners['state_changed'] == 1 From 5995f2438ea877fe970b1f56da73a1636fc1c8d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 7 Sep 2016 06:59:59 -0700 Subject: [PATCH 169/208] fix remove listener (#3196) --- homeassistant/components/api.py | 41 +++++++++++++------------ homeassistant/components/switch/flux.py | 14 +++++---- homeassistant/core.py | 24 +++++++++------ homeassistant/remote.py | 11 ++++--- tests/test_bootstrap.py | 2 +- tests/test_core.py | 6 ++-- 6 files changed, 54 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index f0073bad838..be455995743 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -98,31 +98,32 @@ class APIEventStream(HomeAssistantView): def stream(): """Stream events to response.""" - self.hass.bus.listen(MATCH_ALL, forward_events) + unsub_stream = self.hass.bus.listen(MATCH_ALL, forward_events) - _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) + try: + _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) - # Fire off one message right away to have browsers fire open event - to_write.put(STREAM_PING_PAYLOAD) + # Fire off one message so browsers fire open event right away + to_write.put(STREAM_PING_PAYLOAD) - while True: - try: - payload = to_write.get(timeout=STREAM_PING_INTERVAL) + while True: + try: + payload = to_write.get(timeout=STREAM_PING_INTERVAL) - if payload is stop_obj: + if payload is stop_obj: + break + + msg = "data: {}\n\n".format(payload) + _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), + msg.strip()) + yield msg.encode("UTF-8") + except queue.Empty: + to_write.put(STREAM_PING_PAYLOAD) + except GeneratorExit: break - - msg = "data: {}\n\n".format(payload) - _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), - msg.strip()) - yield msg.encode("UTF-8") - except queue.Empty: - to_write.put(STREAM_PING_PAYLOAD) - except GeneratorExit: - break - - _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) - self.hass.bus.remove_listener(MATCH_ALL, forward_events) + finally: + _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) + unsub_stream() return self.Response(stream(), mimetype='text/event-stream') diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 61a40315620..a0c982952e2 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.light import is_on, turn_on from homeassistant.components.sun import next_setting, next_rising from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.const import CONF_NAME, CONF_PLATFORM, EVENT_TIME_CHANGED +from homeassistant.const import CONF_NAME, CONF_PLATFORM from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.color import color_temperature_to_rgb as temp_to_rgb from homeassistant.util.color import color_RGB_to_xy @@ -124,7 +124,7 @@ class FluxSwitch(SwitchDevice): self._stop_colortemp = stop_colortemp self._brightness = brightness self._mode = mode - self.tracker = None + self.unsub_tracker = None @property def name(self): @@ -139,15 +139,17 @@ class FluxSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn on flux.""" self._state = True - self.tracker = track_utc_time_change(self.hass, - self.flux_update, - second=[0, 30]) + self.unsub_tracker = track_utc_time_change(self.hass, self.flux_update, + second=[0, 30]) self.update_ha_state() def turn_off(self, **kwargs): """Turn off flux.""" + if self.unsub_tracker is not None: + self.unsub_tracker() + self.unsub_tracker = None + self._state = False - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, self.tracker) self.update_ha_state() # pylint: disable=too-many-locals diff --git a/homeassistant/core.py b/homeassistant/core.py index dad7313bb82..6ecb27d875c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -299,7 +299,7 @@ class EventBus(object): def remove_listener(): """Remove the listener.""" - self.remove_listener(event_type, listener) + self._remove_listener(event_type, listener) return remove_listener @@ -309,7 +309,7 @@ class EventBus(object): To listen to all events specify the constant ``MATCH_ALL`` as event_type. - Returns registered listener that can be used with remove_listener. + Returns function to unsubscribe the listener. """ @ft.wraps(listener) def onetime_listener(event): @@ -323,15 +323,21 @@ class EventBus(object): # This will make sure the second time it does nothing. setattr(onetime_listener, 'run', True) - self.remove_listener(event_type, onetime_listener) + remove_listener() listener(event) - self.listen(event_type, onetime_listener) + remove_listener = self.listen(event_type, onetime_listener) - return onetime_listener + return remove_listener def remove_listener(self, event_type, listener): + """Remove a listener of a specific event_type. (DEPRECATED 0.28).""" + _LOGGER.warning('bus.remove_listener has been deprecated. Please use ' + 'the function returned from calling listen.') + self._remove_listener(event_type, listener) + + def _remove_listener(self, event_type, listener): """Remove a listener of a specific event_type.""" with self._lock: try: @@ -344,7 +350,8 @@ class EventBus(object): except (KeyError, ValueError): # KeyError is key event_type listener did not exist # ValueError if listener did not exist within event_type - pass + _LOGGER.warning('Unable to remove unknown listener %s', + listener) class State(object): @@ -688,14 +695,13 @@ class ServiceRegistry(object): if call.data[ATTR_SERVICE_CALL_ID] == call_id: executed_event.set() - self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) + unsub = self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) self._bus.fire(EVENT_CALL_SERVICE, event_data) if blocking: success = executed_event.wait(SERVICE_CALL_LIMIT) - self._bus.remove_listener( - EVENT_SERVICE_EXECUTED, service_executed) + unsub() return success def _event_to_service_call(self, event): diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 8e62cdd044a..4564878a5ad 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -211,6 +211,7 @@ class EventForwarder(object): self._targets = {} self._lock = threading.Lock() + self._unsub_listener = None def connect(self, api): """Attach to a Home Assistant instance and forward events. @@ -218,9 +219,9 @@ class EventForwarder(object): Will overwrite old target if one exists with same host/port. """ with self._lock: - if len(self._targets) == 0: - # First target we get, setup listener for events - self.hass.bus.listen(ha.MATCH_ALL, self._event_listener) + if self._unsub_listener is None: + self._unsub_listener = self.hass.bus.listen( + ha.MATCH_ALL, self._event_listener) key = (api.host, api.port) @@ -235,8 +236,8 @@ class EventForwarder(object): if len(self._targets) == 0: # Remove event listener if no forwarding targets present - self.hass.bus.remove_listener(ha.MATCH_ALL, - self._event_listener) + self._unsub_listener() + self._unsub_listener = None return did_remove diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 0ed70ecef77..8ad9d1cc409 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -280,7 +280,7 @@ class TestBootstrap: loader.set_component( 'switch.platform_a', - MockPlatform('comp_b', platform_schema=platform_schema)) + MockPlatform(platform_schema=platform_schema)) assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { diff --git a/tests/test_core.py b/tests/test_core.py index 0a67d933119..76c82252d30 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -166,14 +166,14 @@ class TestEventBus(unittest.TestCase): self.assertEqual(old_count + 1, len(self.bus.listeners)) # Try deleting a non registered listener, nothing should happen - self.bus.remove_listener('test', lambda x: len) + self.bus._remove_listener('test', lambda x: len) # Remove listener - self.bus.remove_listener('test', listener) + self.bus._remove_listener('test', listener) self.assertEqual(old_count, len(self.bus.listeners)) # Try deleting listener while category doesn't exist either - self.bus.remove_listener('test', listener) + self.bus._remove_listener('test', listener) def test_unsubscribe_listener(self): """Test unsubscribe listener from returned function.""" From 32c234ffccec768690810d954f039972ce5f89b2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 7 Sep 2016 16:32:35 +0200 Subject: [PATCH 170/208] Add linux battery sensor (#3238) --- .coveragerc | 1 + .../components/sensor/linux_battery.py | 125 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 129 insertions(+) create mode 100644 homeassistant/components/sensor/linux_battery.py diff --git a/.coveragerc b/.coveragerc index 8afca0e55a9..99186832e7d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -225,6 +225,7 @@ omit = homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/lastfm.py + homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/mhz19.py homeassistant/components/sensor/mqtt_room.py diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py new file mode 100644 index 00000000000..c1d145953e3 --- /dev/null +++ b/homeassistant/components/sensor/linux_battery.py @@ -0,0 +1,125 @@ +""" +Details about the built-in battery. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.linux_battery/ +""" +import logging +import os + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['batinfo==0.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_NAME = 'name' +ATTR_PATH = 'path' +ATTR_ALARM = 'alarm' +ATTR_CAPACITY = 'capacity' +ATTR_CAPACITY_LEVEL = 'capacity_level' +ATTR_CYCLE_COUNT = 'cycle_count' +ATTR_ENERGY_FULL = 'energy_full' +ATTR_ENERGY_FULL_DESIGN = 'energy_full_design' +ATTR_ENERGY_NOW = 'energy_now' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_MODEL_NAME = 'model_name' +ATTR_POWER_NOW = 'power_now' +ATTR_SERIAL_NUMBER = 'serial_number' +ATTR_STATUS = 'status' +ATTR_VOLTAGE_MIN_DESIGN = 'voltage_min_design' +ATTR_VOLTAGE_NOW = 'voltage_now' + +CONF_BATTERY = 'battery' + +DEFAULT_BATTERY = 1 +DEFAULT_NAME = 'Battery' +DEFAULT_PATH = '/sys/class/power_supply' + +ICON = 'mdi:battery' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_BATTERY, default=DEFAULT_BATTERY): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Linux Battery sensor.""" + name = config.get(CONF_NAME) + battery_id = config.get(CONF_BATTERY) + + try: + os.listdir(os.path.join(DEFAULT_PATH, 'BAT{}'.format(battery_id))) + except FileNotFoundError: + _LOGGER.error("No battery found") + return False + + add_devices([LinuxBatterySensor(name, battery_id)]) + + +# pylint: disable=too-few-public-methods +class LinuxBatterySensor(Entity): + """Representation of a Linux Battery sensor.""" + + def __init__(self, name, battery_id): + """Initialize the battery sensor.""" + import batinfo + self._battery = batinfo.Batteries() + + self._name = name + self._battery_stat = None + self._battery_id = battery_id - 1 + self._unit_of_measurement = '%' + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._battery_stat.capacity + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_NAME: self._battery_stat.name, + ATTR_PATH: self._battery_stat.path, + ATTR_ALARM: self._battery_stat.alarm, + ATTR_CAPACITY_LEVEL: self._battery_stat.capacity_level, + ATTR_CYCLE_COUNT: self._battery_stat.cycle_count, + ATTR_ENERGY_FULL: self._battery_stat.energy_full, + ATTR_ENERGY_FULL_DESIGN: self._battery_stat.energy_full_design, + ATTR_ENERGY_NOW: self._battery_stat.energy_now, + ATTR_MANUFACTURER: self._battery_stat.manufacturer, + ATTR_MODEL_NAME: self._battery_stat.model_name, + ATTR_POWER_NOW: self._battery_stat.power_now, + ATTR_SERIAL_NUMBER: self._battery_stat.serial_number, + ATTR_STATUS: self._battery_stat.status, + ATTR_VOLTAGE_MIN_DESIGN: self._battery_stat.voltage_min_design, + ATTR_VOLTAGE_NOW: self._battery_stat.voltage_now, + } + + def update(self): + """Get the latest data and updates the states.""" + self._battery.update() + self._battery_stat = self._battery.stat[self._battery_id] diff --git a/requirements_all.txt b/requirements_all.txt index 68be8a11d7b..f38ef89ef84 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -34,6 +34,9 @@ apcaccess==0.0.4 # homeassistant.components.sun astral==1.2 +# homeassistant.components.sensor.linux_battery +batinfo==0.3 + # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From e632a47772d1f48b3b46d62b3a9ea7b89613fcff Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 7 Sep 2016 17:19:19 +0200 Subject: [PATCH 171/208] protect service data for changes in calls (#3249) * protect service data for changes in calls * change handling * move MappingProxyType to service call --- homeassistant/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index 6ecb27d875c..03f9658325f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -581,6 +581,7 @@ class Service(object): try: if self.schema: call.data = self.schema(call.data) + call.data = MappingProxyType(call.data) self.func(call) except vol.MultipleInvalid as ex: From 4d41c5cd0ff73dc288d4a840780f46c53277f9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 7 Sep 2016 19:17:16 +0200 Subject: [PATCH 172/208] Fix issue #3250 (#3253) --- homeassistant/components/media_player/denon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index 121fb6ae8b8..45e4cd2514e 100644 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -72,7 +72,7 @@ class DenonDevice(MediaPlayerDevice): """Get the latest details from the device.""" try: telnet = telnetlib.Telnet(self._host) - except ConnectionRefusedError: + except (ConnectionRefusedError, OSError): return False self._pwstate = self.telnet_request(telnet, 'PW?') From 1af5d4c8b8e20f62424e32310fc3302526bdfda0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 7 Sep 2016 20:21:42 +0200 Subject: [PATCH 173/208] Minor Ecobee changes (#3131) * Update configuration check, ordering, and constants * Make API key optional --- homeassistant/components/climate/ecobee.py | 13 +++++---- homeassistant/components/ecobee.py | 31 +++++++++++----------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 2417a8562ce..08bb93d5458 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/climate.ecobee/ """ import logging from os import path + import voluptuous as vol from homeassistant.components import ecobee @@ -16,13 +17,15 @@ from homeassistant.const import ( from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['ecobee'] -_LOGGER = logging.getLogger(__name__) -ECOBEE_CONFIG_FILE = 'ecobee.conf' _CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +ATTR_FAN_MIN_ON_TIME = 'fan_min_on_time' + +DEPENDENCIES = ['ecobee'] + +SERVICE_SET_FAN_MIN_ON_TIME = 'ecobee_set_fan_min_on_time' -ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" -SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 702c7fd6304..24d47365a54 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/ecobee/ import logging import os from datetime import timedelta + import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -15,14 +16,23 @@ from homeassistant.const import CONF_API_KEY from homeassistant.loader import get_component from homeassistant.util import Throttle -DOMAIN = "ecobee" -NETWORK = None -CONF_HOLD_TEMP = 'hold_temp' - REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' '4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6'] +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +CONF_HOLD_TEMP = 'hold_temp' + +DOMAIN = 'ecobee' + +ECOBEE_CONFIG_FILE = 'ecobee.conf' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) + +NETWORK = None + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_API_KEY): cv.string, @@ -30,14 +40,6 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) - -ECOBEE_CONFIG_FILE = 'ecobee.conf' -_CONFIGURING = {} - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) - def request_configuration(network, hass, config): """Request configuration steps from the user.""" @@ -97,7 +99,7 @@ class EcobeeData(object): def update(self): """Get the latest data from pyecobee.""" self.ecobee.update() - _LOGGER.info("ecobee data updated successfully.") + _LOGGER.info("Ecobee data updated successfully") def setup(hass, config): @@ -116,9 +118,6 @@ def setup(hass, config): # Create ecobee.conf if it doesn't exist if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): - if config[DOMAIN].get(CONF_API_KEY) is None: - _LOGGER.error("No ecobee api_key found in config.") - return jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) From b3d2db45dee83f818a2b7b767916ba7f648c203b Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 8 Sep 2016 08:43:05 +0200 Subject: [PATCH 174/208] issue #3250 --- homeassistant/components/media_player/denon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index 45e4cd2514e..78df50dde76 100644 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -72,7 +72,7 @@ class DenonDevice(MediaPlayerDevice): """Get the latest details from the device.""" try: telnet = telnetlib.Telnet(self._host) - except (ConnectionRefusedError, OSError): + except OSError: return False self._pwstate = self.telnet_request(telnet, 'PW?') From 24aa3b3c97291789e12622618e625d830947428b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 8 Sep 2016 16:11:00 +0200 Subject: [PATCH 175/208] Add voluptuous to ecobee (#3257) --- homeassistant/components/notify/ecobee.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py index 861d5439e4c..4ac4a9ca8db 100644 --- a/homeassistant/components/notify/ecobee.py +++ b/homeassistant/components/notify/ecobee.py @@ -5,16 +5,28 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.ecobee/ """ import logging + +import voluptuous as vol + from homeassistant.components import ecobee -from homeassistant.components.notify import BaseNotificationService +from homeassistant.components.notify import ( + BaseNotificationService, PLATFORM_SCHEMA) # NOQA +import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['ecobee'] _LOGGER = logging.getLogger(__name__) +CONF_INDEX = 'index' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_INDEX, default=0): cv.positive_int, +}) + + def get_service(hass, config): """Get the Ecobee notification service.""" - index = int(config['index']) if 'index' in config else 0 + index = config.get(CONF_INDEX) return EcobeeNotificationService(index) From 94e3986d54e45e0c5615076c3e52f673dea6ddf8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 8 Sep 2016 16:26:54 +0200 Subject: [PATCH 176/208] Use constants and update ordering (#3261) --- .../components/binary_sensor/template.py | 36 +++++++++---------- homeassistant/components/sensor/template.py | 11 +++--- homeassistant/components/switch/template.py | 20 +++++------ 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index e87594e625c..e0b748bbbbe 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -5,22 +5,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.template/ """ import logging + import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + SENSOR_CLASSES_SCHEMA) +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, MATCH_ALL, CONF_VALUE_TEMPLATE, + CONF_SENSOR_CLASS, CONF_SENSORS) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import track_state_change import homeassistant.helpers.config_validation as cv -from homeassistant.components.binary_sensor import (BinarySensorDevice, - ENTITY_ID_FORMAT, - PLATFORM_SCHEMA, - SENSOR_CLASSES_SCHEMA) - -from homeassistant.const import (ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, MATCH_ALL, - CONF_VALUE_TEMPLATE, CONF_SENSOR_CLASS) -from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers import template -from homeassistant.helpers.event import track_state_change - -CONF_SENSORS = 'sensors' +_LOGGER = logging.getLogger(__name__) SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, @@ -33,15 +33,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), }) -_LOGGER = logging.getLogger(__name__) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup template binary sensors.""" sensors = [] for device, device_config in config[CONF_SENSORS].items(): - value_template = device_config[CONF_VALUE_TEMPLATE] entity_ids = device_config[ATTR_ENTITY_ID] friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) @@ -85,8 +82,7 @@ class BinarySensorTemplate(BinarySensorDevice): """Called when the target device changes state.""" self.update_ha_state(True) - track_state_change(hass, entity_ids, - template_bsensor_state_listener) + track_state_change(hass, entity_ids, template_bsensor_state_listener) @property def name(self): @@ -111,8 +107,8 @@ class BinarySensorTemplate(BinarySensorDevice): def update(self): """Get the latest data and update the state.""" try: - self._state = template.render(self.hass, - self._template).lower() == 'true' + self._state = template.render( + self.hass, self._template).lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 961b6f39c17..743a1909ea5 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -5,20 +5,20 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.template/ """ import logging + import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - ATTR_ENTITY_ID, MATCH_ALL) + ATTR_ENTITY_ID, MATCH_ALL, CONF_SENSORS) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.event import track_state_change +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_SENSORS = 'sensors' SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, @@ -80,8 +80,7 @@ class SensorTemplate(Entity): """Called when the target device changes state.""" self.update_ha_state(True) - track_state_change(hass, entity_ids, - template_sensor_state_listener) + track_state_change(hass, entity_ids, template_sensor_state_listener) @property def name(self): diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 6778315843e..2b043c110a4 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -5,28 +5,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.template/ """ import logging + import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import ( ENTITY_ID_FORMAT, SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, - ATTR_ENTITY_ID, MATCH_ALL) + ATTR_ENTITY_ID, MATCH_ALL, CONF_SWITCHES) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.script import Script from homeassistant.helpers import template +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import track_state_change - -CONF_SWITCHES = 'switches' - -ON_ACTION = 'turn_on' -OFF_ACTION = 'turn_off' +from homeassistant.helpers.script import Script +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] +ON_ACTION = 'turn_on' +OFF_ACTION = 'turn_off' + SWITCH_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA, @@ -91,8 +90,7 @@ class SwitchTemplate(SwitchDevice): """Called when the target device changes state.""" self.update_ha_state(True) - track_state_change(hass, entity_ids, - template_switch_state_listener) + track_state_change(hass, entity_ids, template_switch_state_listener) @property def name(self): From 267cda447e17d5643bca365718d3a049c402aeb6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 8 Sep 2016 18:19:47 +0200 Subject: [PATCH 177/208] Add support for complex template structures to data_template (#3255) --- homeassistant/helpers/config_validation.py | 16 +++++++++++++++- homeassistant/helpers/service.py | 14 +++++++++++++- tests/helpers/test_config_validation.py | 22 +++++++++++++++++++--- tests/helpers/test_service.py | 8 ++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1e67effb97f..1be157c789d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -244,6 +244,20 @@ def template(value): raise vol.Invalid('invalid template ({})'.format(ex)) +def template_complex(value): + """Validate a complex jinja2 template.""" + if isinstance(value, list): + for idx, element in enumerate(value): + value[idx] = template_complex(element) + return value + if isinstance(value, dict): + for key, element in value.items(): + value[key] = template_complex(element) + return value + + return template(value) + + def time(value): """Validate time.""" time_val = dt_util.parse_time(value) @@ -310,7 +324,7 @@ SERVICE_SCHEMA = vol.All(vol.Schema({ vol.Exclusive('service', 'service name'): service, vol.Exclusive('service_template', 'service name'): template, vol.Optional('data'): dict, - vol.Optional('data_template'): {match_all: template}, + vol.Optional('data_template'): {match_all: template_complex}, vol.Optional(CONF_ENTITY_ID): entity_ids, }), has_at_least_one_key('service', 'service_template')) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index b594889fd77..21cfb0aab54 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -63,9 +63,21 @@ def call_from_config(hass, config, blocking=False, variables=None, domain, service_name = domain_service.split('.', 1) service_data = dict(config.get(CONF_SERVICE_DATA, {})) + def _data_template_creator(value): + """Recursive template creator helper function.""" + if isinstance(value, list): + for idx, element in enumerate(value): + value[idx] = _data_template_creator(element) + return value + if isinstance(value, dict): + for key, element in value.items(): + value[key] = _data_template_creator(element) + return value + return template.render(hass, value, variables) + if CONF_SERVICE_DATA_TEMPLATE in config: for key, value in config[CONF_SERVICE_DATA_TEMPLATE].items(): - service_data[key] = template.render(hass, value, variables) + service_data[key] = _data_template_creator(value) if CONF_SERVICE_ENTITY_ID in config: service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 637d5ead0b7..d9da2c51da7 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -299,9 +299,7 @@ def test_template(): """Test template validator.""" schema = vol.Schema(cv.template) - for value in ( - None, '{{ partial_print }', '{% if True %}Hello', {'dict': 'isbad'} - ): + for value in (None, '{{ partial_print }', '{% if True %}Hello', ['test']): with pytest.raises(vol.MultipleInvalid): schema(value) @@ -313,6 +311,24 @@ def test_template(): schema(value) +def test_template_complex(): + """Test template_complex validator.""" + schema = vol.Schema(cv.template_complex) + + for value in (None, '{{ partial_print }', '{% if True %}Hello'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ( + 1, 'Hello', + '{{ beer }}', + '{% if 1 == 1 %}Hello{% else %}World{% endif %}', + {'test': 1, 'test': '{{ beer }}'}, + ['{{ beer }}', 1] + ): + schema(value) + + def test_time_zone(): """Test time zone validation.""" schema = vol.Schema(cv.time_zone) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 5372b6a77d4..34f321776d6 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -43,6 +43,11 @@ class TestServiceHelpers(unittest.TestCase): 'entity_id': 'hello.world', 'data_template': { 'hello': '{{ \'goodbye\' }}', + 'data': { + 'value': '{{ \'complex\' }}', + 'simple': 'simple' + }, + 'list': ['{{ \'list\' }}', '2'], }, } runs = [] @@ -54,6 +59,9 @@ class TestServiceHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual('goodbye', runs[0].data['hello']) + self.assertEqual('complex', runs[0].data['data']['value']) + self.assertEqual('simple', runs[0].data['data']['simple']) + self.assertEqual('list', runs[0].data['list'][0]) def test_passing_variables_to_templates(self): """Test passing variables to templates.""" From e8ad76c8164a97d328032e46802aeffe09ea9808 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Thu, 8 Sep 2016 22:20:38 +0200 Subject: [PATCH 178/208] Improve yaml fault tolerance and handle check_config border cases (#3159) --- homeassistant/scripts/check_config.py | 8 +- homeassistant/util/yaml.py | 10 +++ tests/common.py | 11 ++- tests/scripts/test_check_config.py | 9 ++- tests/util/test_yaml.py | 111 ++++++++++++++------------ 5 files changed, 90 insertions(+), 59 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 624452b0592..d1bf12187e8 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -221,14 +221,18 @@ def check(config_path): try: bootstrap.from_config_file(config_path, skip_pip=True) - res['secret_cache'] = yaml.__SECRET_CACHE - return res + res['secret_cache'] = dict(yaml.__SECRET_CACHE) + except Exception as err: # pylint: disable=broad-except + print(color('red', 'Fatal error while loading config:'), str(err)) finally: # Stop all patches for pat in PATCHES.values(): pat.stop() # Ensure !secrets point to the original function yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + bootstrap.clear_secret_cache() + + return res def dump_dict(layer, indent_count=1, listi=False, **kwargs): diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index b834ac8048c..035a96b657e 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -121,6 +121,16 @@ def _ordered_dict(loader: SafeLineLoader, line = getattr(node, '__line__', 'unknown') if line != 'unknown' and (min_line is None or line < min_line): min_line = line + + try: + hash(key) + except TypeError: + fname = getattr(loader.stream, 'name', '') + raise yaml.MarkedYAMLError( + context="invalid key: \"{}\"".format(key), + context_mark=yaml.Mark(fname, 0, min_line, -1, None, None) + ) + if key in seen: fname = getattr(loader.stream, 'name', '') first_mark = yaml.Mark(fname, 0, seen[key], -1, None, None) diff --git a/tests/common.py b/tests/common.py index c82f6c13a0f..3c6815ece02 100644 --- a/tests/common.py +++ b/tests/common.py @@ -247,20 +247,23 @@ def patch_yaml_files(files_dict, endswith=True): """Patch load_yaml with a dictionary of yaml files.""" # match using endswith, start search with longest string matchlist = sorted(list(files_dict.keys()), key=len) if endswith else [] - # matchlist.sort(key=len) def mock_open_f(fname, **_): """Mock open() in the yaml module, used by load_yaml.""" # Return the mocked file on full match if fname in files_dict: _LOGGER.debug('patch_yaml_files match %s', fname) - return StringIO(files_dict[fname]) + res = StringIO(files_dict[fname]) + setattr(res, 'name', fname) + return res # Match using endswith for ends in matchlist: if fname.endswith(ends): _LOGGER.debug('patch_yaml_files end match %s: %s', ends, fname) - return StringIO(files_dict[ends]) + res = StringIO(files_dict[ends]) + setattr(res, 'name', fname) + return res # Fallback for hass.components (i.e. services.yaml) if 'homeassistant/components' in fname: @@ -268,6 +271,6 @@ def patch_yaml_files(files_dict, endswith=True): return open(fname, encoding='utf-8') # Not found - raise IOError('File not found: {}'.format(fname)) + raise FileNotFoundError('File not found: {}'.format(fname)) return patch.object(yaml, 'open', mock_open_f, create=True) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index fbd80760c12..f9ebaa634ff 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -137,7 +137,10 @@ class TestCheckConfig(unittest.TestCase): self.maxDiff = None with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('secret.yaml')) + config_path = get_test_config_dir('secret.yaml') + secrets_path = get_test_config_dir('secrets.yaml') + + res = check_config.check(config_path) change_yaml_files(res) # convert secrets OrderedDict to dict for assertequal @@ -148,7 +151,7 @@ class TestCheckConfig(unittest.TestCase): 'components': {'http': {'api_password': 'abc123', 'server_port': 8123}}, 'except': {}, - 'secret_cache': {'secrets.yaml': {'http_pw': 'abc123'}}, + 'secret_cache': {secrets_path: {'http_pw': 'abc123'}}, 'secrets': {'http_pw': 'abc123'}, - 'yaml_files': ['.../secret.yaml', 'secrets.yaml'] + 'yaml_files': ['.../secret.yaml', '.../secrets.yaml'] }, res) diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 4ce0def08ac..6b35e4f844c 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -3,59 +3,68 @@ import io import unittest import os import tempfile +from unittest.mock import patch + from homeassistant.exceptions import HomeAssistantError from homeassistant.util import yaml -import homeassistant.config as config_util -from tests.common import get_test_config_dir +from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file +from tests.common import get_test_config_dir, patch_yaml_files class TestYaml(unittest.TestCase): """Test util.yaml loader.""" + # pylint: disable=no-self-use,invalid-name def test_simple_list(self): """Test simple list.""" conf = "config:\n - simple\n - list" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc['config'] == ["simple", "list"] def test_simple_dict(self): """Test simple dict.""" conf = "key: value" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc['key'] == 'value' def test_duplicate_key(self): - """Test simple dict.""" - conf = "key: thing1\nkey: thing2" - try: - with io.StringIO(conf) as f: - yaml.yaml.safe_load(f) - except Exception: - pass - else: - assert 0 + """Test duplicate dict keys.""" + files = {YAML_CONFIG_FILE: 'key: thing1\nkey: thing2'} + with self.assertRaises(HomeAssistantError): + with patch_yaml_files(files): + load_yaml_config_file(YAML_CONFIG_FILE) + + def test_unhashable_key(self): + """Test an unhasable key.""" + files = {YAML_CONFIG_FILE: 'message:\n {{ states.state }}'} + with self.assertRaises(HomeAssistantError), \ + patch_yaml_files(files): + load_yaml_config_file(YAML_CONFIG_FILE) + + def test_no_key(self): + """Test item without an key.""" + files = {YAML_CONFIG_FILE: 'a: a\nnokeyhere'} + with self.assertRaises(HomeAssistantError), \ + patch_yaml_files(files): + yaml.load_yaml(YAML_CONFIG_FILE) def test_enviroment_variable(self): """Test config file with enviroment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc['password'] == "secret_password" del os.environ["PASSWORD"] def test_invalid_enviroment_variable(self): """Test config file with no enviroment variable sat.""" conf = "password: !env_var PASSWORD" - try: - with io.StringIO(conf) as f: - yaml.yaml.safe_load(f) - except Exception: - pass - else: - assert 0 + with self.assertRaises(HomeAssistantError): + with io.StringIO(conf) as file: + yaml.yaml.safe_load(file) def test_include_yaml(self): """Test include yaml.""" @@ -63,8 +72,8 @@ class TestYaml(unittest.TestCase): include_file.write(b"value") include_file.seek(0) conf = "key: !include {}".format(include_file.name) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc["key"] == "value" def test_include_dir_list(self): @@ -79,8 +88,8 @@ class TestYaml(unittest.TestCase): file_2.write(b"two") file_2.close() conf = "key: !include_dir_list {}".format(include_dir) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert sorted(doc["key"]) == sorted(["one", "two"]) def test_include_dir_named(self): @@ -98,8 +107,8 @@ class TestYaml(unittest.TestCase): correct = {} correct[os.path.splitext(os.path.basename(file_1.name))[0]] = "one" correct[os.path.splitext(os.path.basename(file_2.name))[0]] = "two" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc["key"] == correct def test_include_dir_merge_list(self): @@ -114,8 +123,8 @@ class TestYaml(unittest.TestCase): file_2.write(b"- two\n- three") file_2.close() conf = "key: !include_dir_merge_list {}".format(include_dir) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert sorted(doc["key"]) == sorted(["one", "two", "three"]) def test_include_dir_merge_named(self): @@ -130,23 +139,25 @@ class TestYaml(unittest.TestCase): file_2.write(b"key2: two\nkey3: three") file_2.close() conf = "key: !include_dir_merge_named {}".format(include_dir) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc["key"] == { "key1": "one", "key2": "two", "key3": "three" } +FILES = {} + def load_yaml(fname, string): """Write a string to file and return the parsed yaml.""" - with open(fname, 'w') as file: - file.write(string) - return config_util.load_yaml_config_file(fname) + FILES[fname] = string + with patch_yaml_files(FILES): + return load_yaml_config_file(fname) -class FakeKeyring(): +class FakeKeyring(): # pylint: disable=too-few-public-methods """Fake a keyring class.""" def __init__(self, secrets_dict): @@ -162,20 +173,16 @@ class FakeKeyring(): class TestSecrets(unittest.TestCase): """Test the secrets parameter in the yaml utility.""" + # pylint: disable=protected-access,invalid-name def setUp(self): # pylint: disable=invalid-name """Create & load secrets file.""" config_dir = get_test_config_dir() yaml.clear_secret_cache() - self._yaml_path = os.path.join(config_dir, - config_util.YAML_CONFIG_FILE) + self._yaml_path = os.path.join(config_dir, YAML_CONFIG_FILE) self._secret_path = os.path.join(config_dir, yaml._SECRET_YAML) self._sub_folder_path = os.path.join(config_dir, 'subFolder') - if not os.path.exists(self._sub_folder_path): - os.makedirs(self._sub_folder_path) self._unrelated_path = os.path.join(config_dir, 'unrelated') - if not os.path.exists(self._unrelated_path): - os.makedirs(self._unrelated_path) load_yaml(self._secret_path, 'http_pw: pwhttp\n' @@ -194,12 +201,7 @@ class TestSecrets(unittest.TestCase): def tearDown(self): # pylint: disable=invalid-name """Clean up secrets.""" yaml.clear_secret_cache() - for path in [self._yaml_path, self._secret_path, - os.path.join(self._sub_folder_path, 'sub.yaml'), - os.path.join(self._sub_folder_path, yaml._SECRET_YAML), - os.path.join(self._unrelated_path, yaml._SECRET_YAML)]: - if os.path.isfile(path): - os.remove(path) + FILES.clear() def test_secrets_from_yaml(self): """Did secrets load ok.""" @@ -263,3 +265,12 @@ class TestSecrets(unittest.TestCase): """Ensure logger: debug was removed.""" with self.assertRaises(yaml.HomeAssistantError): load_yaml(self._yaml_path, 'api_password: !secret logger') + + @patch('homeassistant.util.yaml._LOGGER.error') + def test_bad_logger_value(self, mock_error): + """Ensure logger: debug was removed.""" + yaml.clear_secret_cache() + load_yaml(self._secret_path, 'logger: info\npw: abc') + load_yaml(self._yaml_path, 'api_password: !secret pw') + assert mock_error.call_count == 1, \ + "Expected an error about logger: value" From 02848b394976439ebf594337e66cc98833120dec Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 8 Sep 2016 23:06:57 +0200 Subject: [PATCH 179/208] Use voluptuous for nx584 alarm (#3231) * Migrate to voluptuous * Fix pylint issue --- .../components/alarm_control_panel/nx584.py | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py index 2b3facbdb0e..45857f3ef29 100644 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -7,22 +7,40 @@ https://home-assistant.io/components/alarm_control_panel.nx584/ import logging import requests +import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN) + STATE_UNKNOWN, CONF_NAME, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pynx584==0.2'] + _LOGGER = logging.getLogger(__name__) +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'NX584' +DEFAULT_PORT = 5007 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup nx584 platform.""" - host = config.get('host', 'localhost:5007') + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + url = 'http://{}:{}'.format(host, port) try: - add_devices([NX584Alarm(hass, host, config.get('name', 'NX584'))]) + add_devices([NX584Alarm(hass, url, name)]) except requests.exceptions.ConnectionError as ex: _LOGGER.error('Unable to connect to NX584: %s', str(ex)) return False @@ -31,13 +49,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class NX584Alarm(alarm.AlarmControlPanel): """Represents the NX584-based alarm panel.""" - def __init__(self, hass, host, name): + def __init__(self, hass, url, name): """Initalize the nx584 alarm panel.""" from nx584 import client self._hass = hass - self._host = host self._name = name - self._alarm = client.Client('http://%s' % host) + self._url = url + self._alarm = client.Client(self._url) # Do an initial list operation so that we will try to actually # talk to the API and trigger a requests exception for setup_platform() # to catch @@ -66,7 +84,7 @@ class NX584Alarm(alarm.AlarmControlPanel): zones = self._alarm.list_zones() except requests.exceptions.ConnectionError as ex: _LOGGER.error('Unable to connect to %(host)s: %(reason)s', - dict(host=self._host, reason=ex)) + dict(host=self._url, reason=ex)) return STATE_UNKNOWN except IndexError: _LOGGER.error('nx584 reports no partitions') From 1cace5782ce71f0bc01bdccdad969243008c2811 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Thu, 8 Sep 2016 20:26:50 -0400 Subject: [PATCH 180/208] fastdotcom from pypi (#3269) --- homeassistant/components/sensor/fastdotcom.py | 3 +-- requirements_all.txt | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 95d91d42efc..ad6aa2ca630 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -REQUIREMENTS = ['https://github.com/nkgilley/fast.com/archive/' - 'master.zip#fastdotcom==0.0.1'] +REQUIREMENTS = ['fastdotcom==0.0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f38ef89ef84..2d6ad24f330 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,6 +81,9 @@ enocean==0.31 # homeassistant.components.thermostat.honeywell evohomeclient==0.2.5 +# homeassistant.components.sensor.fastdotcom +fastdotcom==0.0.1 + # homeassistant.components.feedreader feedparser==5.2.1 @@ -172,9 +175,6 @@ https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip#pyqwikswitch==0.4 # homeassistant.components.media_player.russound_rnet https://github.com/laf/russound/archive/0.1.6.zip#russound==0.1.6 -# homeassistant.components.sensor.fastdotcom -https://github.com/nkgilley/fast.com/archive/master.zip#fastdotcom==0.0.1 - # homeassistant.components.ecobee https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6 From fb0232429ea13a7fbff4f161988cf030a6f99481 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 9 Sep 2016 02:32:32 +0200 Subject: [PATCH 181/208] Use constants and update ordering (#3268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- .../components/alarm_control_panel/mqtt.py | 40 +++++++++---------- homeassistant/components/automation/mqtt.py | 5 +-- .../components/binary_sensor/mqtt.py | 37 ++++++++--------- 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 3bc7b860869..b5bdf478add 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -13,33 +13,31 @@ import homeassistant.components.mqtt as mqtt from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, - CONF_NAME) + CONF_NAME, CONF_CODE) from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['mqtt'] - CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' -CONF_CODE = 'code' -DEFAULT_NAME = "MQTT Alarm" -DEFAULT_DISARM = "DISARM" -DEFAULT_ARM_HOME = "ARM_HOME" -DEFAULT_ARM_AWAY = "ARM_AWAY" +DEFAULT_ARM_AWAY = 'ARM_AWAY' +DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_DISARM = 'DISARM' +DEFAULT_NAME = 'MQTT Alarm' +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, - vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, - vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, + vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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_DISARM, default=DEFAULT_DISARM): cv.string, }) @@ -47,20 +45,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MQTT platform.""" add_devices([MqttAlarm( hass, - config[CONF_NAME], - config[CONF_STATE_TOPIC], - config[CONF_COMMAND_TOPIC], - config[CONF_QOS], - config[CONF_PAYLOAD_DISARM], - config[CONF_PAYLOAD_ARM_HOME], - config[CONF_PAYLOAD_ARM_AWAY], + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_COMMAND_TOPIC), + config.get(CONF_QOS), + config.get(CONF_PAYLOAD_DISARM), + config.get(CONF_PAYLOAD_ARM_HOME), + config.get(CONF_PAYLOAD_ARM_AWAY), config.get(CONF_CODE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes # pylint: disable=abstract-method class MqttAlarm(alarm.AlarmControlPanel): - """Represent a MQTT alarm status.""" + """Representation of a MQTT alarm status.""" def __init__(self, hass, name, state_topic, command_topic, qos, payload_disarm, payload_arm_home, payload_arm_away, code): diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 5cd60ff0cea..6824c32bf07 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -7,13 +7,12 @@ at https://home-assistant.io/components/automation/#mqtt-trigger import voluptuous as vol import homeassistant.components.mqtt as mqtt -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['mqtt'] CONF_TOPIC = 'topic' -CONF_PAYLOAD = 'payload' TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): mqtt.DOMAIN, @@ -24,7 +23,7 @@ TRIGGER_SCHEMA = vol.Schema({ def trigger(hass, config, action): """Listen for state changes based on configuration.""" - topic = config[CONF_TOPIC] + topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) def mqtt_automation_listener(msg_topic, msg_payload, qos): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index a381305691a..fd767bb1528 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -9,45 +9,42 @@ import logging import voluptuous as vol import homeassistant.components.mqtt as mqtt -from homeassistant.components.binary_sensor import (BinarySensorDevice, - SENSOR_CLASSES) -from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE -from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, SENSOR_CLASSES) +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, + CONF_SENSOR_CLASS) +from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['mqtt'] - -CONF_SENSOR_CLASS = 'sensor_class' -CONF_PAYLOAD_ON = 'payload_on' -CONF_PAYLOAD_OFF = 'payload_off' - DEFAULT_NAME = 'MQTT Binary sensor' -DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' +DEFAULT_PAYLOAD_ON = 'ON' +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_SENSOR_CLASS, default=None): vol.Any(vol.In(SENSOR_CLASSES), vol.SetTo(None)), - vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, - vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, }) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Add MQTT binary sensor.""" + """Setup the MQTT binary sensor.""" add_devices([MqttBinarySensor( hass, - config[CONF_NAME], - config[CONF_STATE_TOPIC], - config[CONF_SENSOR_CLASS], - config[CONF_QOS], - config[CONF_PAYLOAD_ON], - config[CONF_PAYLOAD_OFF], + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_SENSOR_CLASS), + config.get(CONF_QOS), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF), config.get(CONF_VALUE_TEMPLATE) )]) From ee6c83f569a022a8c2a559322fae3f1b1fe3b2eb Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 9 Sep 2016 02:34:55 +0200 Subject: [PATCH 182/208] Use constants and update ordering (#3267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐬 --- homeassistant/components/lock/mqtt.py | 30 ++++++++-------- homeassistant/components/switch/mqtt.py | 46 ++++++++++++------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index 81ab179efd4..b8f8ad9c5b3 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -8,25 +8,26 @@ import logging import voluptuous as vol -import homeassistant.components.mqtt as mqtt from homeassistant.components.lock import LockDevice -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) from homeassistant.helpers import template +import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['mqtt'] CONF_PAYLOAD_LOCK = 'payload_lock' CONF_PAYLOAD_UNLOCK = 'payload_unlock' DEFAULT_NAME = 'MQTT Lock' +DEFAULT_OPTIMISTIC = False DEFAULT_PAYLOAD_LOCK = 'LOCK' DEFAULT_PAYLOAD_UNLOCK = 'UNLOCK' -DEFAULT_OPTIMISTIC = False +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -43,15 +44,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MQTT lock.""" add_devices([MqttLock( hass, - config[CONF_NAME], + config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), - config[CONF_COMMAND_TOPIC], - config[CONF_QOS], - config[CONF_RETAIN], - config[CONF_PAYLOAD_LOCK], - config[CONF_PAYLOAD_UNLOCK], - config[CONF_OPTIMISTIC], - config.get(CONF_VALUE_TEMPLATE))]) + config.get(CONF_COMMAND_TOPIC), + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_PAYLOAD_LOCK), + config.get(CONF_PAYLOAD_UNLOCK), + config.get(CONF_OPTIMISTIC), + config.get(CONF_VALUE_TEMPLATE) + )]) # pylint: disable=too-many-arguments, too-many-instance-attributes @@ -88,8 +90,8 @@ class MqttLock(LockDevice): # Force into optimistic mode. self._optimistic = True else: - mqtt.subscribe(hass, self._state_topic, message_received, - self._qos) + mqtt.subscribe( + hass, self._state_topic, message_received, self._qos) @property def should_poll(self): diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 2a2b2aed547..d17ea82cd32 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -8,24 +8,23 @@ import logging import voluptuous as vol -import homeassistant.components.mqtt as mqtt -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON) from homeassistant.helpers import template +import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] -CONF_PAYLOAD_ON = 'payload_on' -CONF_PAYLOAD_OFF = 'payload_off' - -DEFAULT_NAME = "MQTT Switch" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_NAME = 'MQTT Switch' +DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @@ -37,19 +36,20 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Add MQTT switch.""" - add_devices_callback([MqttSwitch( +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the MQTT switch.""" + add_devices([MqttSwitch( hass, - config[CONF_NAME], + config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), - config[CONF_COMMAND_TOPIC], - config[CONF_QOS], - config[CONF_RETAIN], - config[CONF_PAYLOAD_ON], - config[CONF_PAYLOAD_OFF], - config[CONF_OPTIMISTIC], - config.get(CONF_VALUE_TEMPLATE))]) + config.get(CONF_COMMAND_TOPIC), + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF), + config.get(CONF_OPTIMISTIC), + config.get(CONF_VALUE_TEMPLATE) + )]) # pylint: disable=too-many-arguments, too-many-instance-attributes @@ -86,8 +86,8 @@ class MqttSwitch(SwitchDevice): # Force into optimistic mode. self._optimistic = True else: - mqtt.subscribe(hass, self._state_topic, message_received, - self._qos) + mqtt.subscribe( + hass, self._state_topic, message_received, self._qos) @property def should_poll(self): From 44f5a66b669c07697e831ac43df67f39b3d88f6a Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Fri, 9 Sep 2016 01:49:02 +0100 Subject: [PATCH 183/208] Add additional template for custom date formats (#3262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I can live with a few visual line breaks 🐬 --- homeassistant/helpers/template.py | 17 ++++++++++++++++- tests/helpers/test_template.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 056a4e60183..e083534f828 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -252,6 +252,20 @@ def multiply(value, amount): return value +def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): + """Filter to convert given timestamp to format.""" + try: + date = dt_util.utc_from_timestamp(value) + + if local: + date = dt_util.as_local(date) + + return date.strftime(date_format) + except (ValueError, TypeError): + # If timestamp can't be converted + return value + + def timestamp_local(value): """Filter to convert given timestamp to local date/time.""" try: @@ -263,7 +277,7 @@ def timestamp_local(value): def timestamp_utc(value): - """Filter to convert gibrn timestamp to UTC date/time.""" + """Filter to convert given timestamp to UTC date/time.""" try: return dt_util.utc_from_timestamp(value).strftime(DATE_STR_FORMAT) except (ValueError, TypeError): @@ -289,5 +303,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): ENV = TemplateEnvironment() ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply +ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 266138d1fd5..64bac46264d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -127,6 +127,30 @@ class TestUtilTemplate(unittest.TestCase): template.render(self.hass, '{{ %s | multiply(10) | round }}' % inp)) + def test_timestamp_custom(self): + """Test the timestamps to custom filter.""" + tests = [ + (None, None, None, 'None'), + (1469119144, None, True, '2016-07-21 16:39:04'), + (1469119144, '%Y', True, '2016'), + (1469119144, 'invalid', True, 'invalid'), + (dt_util.as_timestamp(dt_util.utcnow()), None, False, + dt_util.now().strftime('%Y-%m-%d %H:%M:%S')) + ] + + for inp, fmt, local, out in tests: + if fmt: + fil = 'timestamp_custom(\'{}\')'.format(fmt) + elif fmt and local: + fil = 'timestamp_custom(\'{0}\', {1})'.format(fmt, local) + else: + fil = 'timestamp_custom' + + self.assertEqual( + out, + template.render(self.hass, '{{ %s | %s }}' % (inp, fil)) + ) + def test_timestamp_local(self): """Test the timestamps to local filter.""" tests = { From 911231afc1372d85a70a2a2747b1a533d96641cb Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 9 Sep 2016 08:37:30 +0200 Subject: [PATCH 184/208] Use constants and update ordering (#3266) --- homeassistant/components/sensor/mqtt.py | 17 ++++---- homeassistant/components/sensor/mqtt_room.py | 43 ++++++++++++-------- homeassistant/const.py | 3 ++ 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index c3d4910b527..f12df688385 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -8,20 +8,19 @@ import logging import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) -from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'MQTT Sensor' DEPENDENCIES = ['mqtt'] -DEFAULT_NAME = "MQTT Sensor" - PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -33,9 +32,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MQTT Sensor.""" add_devices([MqttSensor( hass, - config[CONF_NAME], - config[CONF_STATE_TOPIC], - config[CONF_QOS], + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_QOS), config.get(CONF_UNIT_OF_MEASUREMENT), config.get(CONF_VALUE_TEMPLATE), )]) diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 6980b7e6f7b..a640d1e5268 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, STATE_UNKNOWN, CONF_TIMEOUT) from homeassistant.components.mqtt import CONF_STATE_TOPIC import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -21,12 +22,18 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] -CONF_DEVICE_ID = 'device_id' -CONF_TIMEOUT = 'timeout' +ATTR_DEVICE_ID = 'device_id' +ATTR_DISTANCE = 'distance' +ATTR_ID = 'id' +ATTR_ROOM = 'room' + +CONF_DEVICE_ID = 'device_id' +CONF_ROOM = 'room' -DEFAULT_TOPIC = 'room_presence' -DEFAULT_TIMEOUT = 5 DEFAULT_NAME = 'Room Sensor' +DEFAULT_TIMEOUT = 5 +DEFAULT_TOPIC = 'room_presence' +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_ID): cv.string, @@ -36,15 +43,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) MQTT_PAYLOAD = vol.Schema(vol.All(json.loads, vol.Schema({ - vol.Required('id'): cv.string, - vol.Required('distance'): vol.Coerce(float) + vol.Required(ATTR_ID): cv.string, + vol.Required(ATTR_DISTANCE): vol.Coerce(float), }, extra=vol.ALLOW_EXTRA))) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MQTT Sensor.""" - add_devices_callback([MQTTRoomSensor( + add_devices([MQTTRoomSensor( hass, config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), @@ -62,7 +69,7 @@ class MQTTRoomSensor(Entity): self._state = STATE_UNKNOWN self._hass = hass self._name = name - self._state_topic = state_topic + '/+' + self._state_topic = '{}{}'.format(state_topic, '/+') self._device_id = slugify(device_id).upper() self._timeout = timeout self._distance = None @@ -86,7 +93,7 @@ class MQTTRoomSensor(Entity): return device = _parse_update_data(topic, data) - if device.get('device_id') == self._device_id: + if device.get(CONF_DEVICE_ID) == self._device_id: if self._distance is None or self._updated is None: update_state(**device) else: @@ -95,8 +102,8 @@ class MQTTRoomSensor(Entity): # device is closer to another room OR # last update from other room was too long ago timediff = dt.utcnow() - self._updated - if device.get('room') == self._state \ - or device.get('distance') < self._distance \ + if device.get(ATTR_ROOM) == self._state \ + or device.get(ATTR_DISTANCE) < self._distance \ or timediff.seconds >= self._timeout: update_state(**device) @@ -116,7 +123,7 @@ class MQTTRoomSensor(Entity): def device_state_attributes(self): """Return the state attributes.""" return { - 'distance': self._distance + ATTR_DISTANCE: self._distance } @property @@ -129,11 +136,11 @@ def _parse_update_data(topic, data): """Parse the room presence update.""" parts = topic.split('/') room = parts[-1] - device_id = slugify(data.get('id')).upper() + device_id = slugify(data.get(ATTR_ID)).upper() distance = data.get('distance') parsed_data = { - 'device_id': device_id, - 'room': room, - 'distance': distance + ATTR_DEVICE_ID: device_id, + ATTR_ROOM: room, + ATTR_DISTANCE: distance } return parsed_data diff --git a/homeassistant/const.py b/homeassistant/const.py index 7b61daa1c8e..4812f2f87e1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -61,6 +61,7 @@ CONF_AUTHENTICATION = 'authentication' CONF_BEFORE = 'before' CONF_BELOW = 'below' CONF_BLACKLIST = 'blacklist' +CONF_BRIGHTNESS = 'brightness' CONF_CODE = 'code' CONF_COMMAND = 'command' CONF_COMMAND_CLOSE = 'command_close' @@ -110,6 +111,7 @@ CONF_PREFIX = 'prefix' CONF_RECIPIENT = 'recipient' CONF_RESOURCE = 'resource' CONF_RESOURCES = 'resources' +CONF_RGB = 'rgb' CONF_SCAN_INTERVAL = 'scan_interval' CONF_SENDER = 'sender' CONF_SENSOR_CLASS = 'sensor_class' @@ -120,6 +122,7 @@ CONF_STRUCTURE = 'structure' CONF_SWITCHES = 'switches' CONF_TEMPERATURE_UNIT = 'temperature_unit' CONF_TIME_ZONE = 'time_zone' +CONF_TIMEOUT = 'timeout' CONF_TOKEN = 'token' CONF_TRIGGER_TIME = 'trigger_time' CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' From 53c8115f82b18724016c176836b4414c1cc3d4e8 Mon Sep 17 00:00:00 2001 From: Brian Karani Ndwiga Date: Fri, 9 Sep 2016 14:38:32 +0800 Subject: [PATCH 185/208] Updated braviatv's braviarc version to 0.3.5 (#3271) --- homeassistant/components/media_player/braviatv.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 7d560beddda..b4bab417742 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -21,8 +21,8 @@ from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ - 'https://github.com/aparraga/braviarc/archive/0.3.4.zip' - '#braviarc==0.3.4'] + 'https://github.com/aparraga/braviarc/archive/0.3.5.zip' + '#braviarc==0.3.5'] BRAVIA_CONFIG_FILE = 'bravia.conf' diff --git a/requirements_all.txt b/requirements_all.txt index 2d6ad24f330..57fd9e20068 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -146,7 +146,7 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 # homeassistant.components.media_player.braviatv -https://github.com/aparraga/braviarc/archive/0.3.4.zip#braviarc==0.3.4 +https://github.com/aparraga/braviarc/archive/0.3.5.zip#braviarc==0.3.5 # homeassistant.components.media_player.roku https://github.com/bah2830/python-roku/archive/3.1.2.zip#roku==3.1.2 From 5bf66cae1f655c3e3aee1e85780b379bd7e6afcf Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 9 Sep 2016 09:06:24 +0200 Subject: [PATCH 186/208] Use voluptuous for Device Sun Light Trigger (#3105) * Migrate to voluptuous * Use default --- .../components/device_sun_light_trigger.py | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index a954ae8fd0f..1bf921c2e06 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -7,23 +7,38 @@ https://home-assistant.io/components/device_sun_light_trigger/ import logging from datetime import timedelta +import voluptuous as vol + import homeassistant.util.dt as dt_util from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.event_decorators import track_state_change from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DOMAIN = "device_sun_light_trigger" +DOMAIN = 'device_sun_light_trigger' DEPENDENCIES = ['light', 'device_tracker', 'group', 'sun'] +CONF_DEVICE_GROUP = 'device_group' +CONF_DISABLE_TURN_OFF = 'disable_turn_off' +CONF_LIGHT_GROUP = 'light_group' +CONF_LIGHT_PROFILE = 'light_profile' + +DEFAULT_DISABLE_TURN_OFF = False +DEFAULT_LIGHT_PROFILE = 'relax' + LIGHT_TRANSITION_TIME = timedelta(minutes=15) -# Light profile to be used if none given -LIGHT_PROFILE = 'relax' - -CONF_LIGHT_PROFILE = 'light_profile' -CONF_LIGHT_GROUP = 'light_group' -CONF_DEVICE_GROUP = 'device_group' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICE_GROUP): cv.entity_id, + vol.Optional(CONF_DISABLE_TURN_OFF, default=DEFAULT_DISABLE_TURN_OFF): + cv.boolean, + vol.Optional(CONF_LIGHT_GROUP): cv.string, + vol.Optional(CONF_LIGHT_PROFILE, default=DEFAULT_LIGHT_PROFILE): + cv.string, + }), +}, extra=vol.ALLOW_EXTRA) # pylint: disable=too-many-locals @@ -35,10 +50,10 @@ def setup(hass, config): light = get_component('light') sun = get_component('sun') - disable_turn_off = 'disable_turn_off' in config[DOMAIN] + disable_turn_off = config[DOMAIN].get(CONF_DISABLE_TURN_OFF) light_group = config[DOMAIN].get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) - light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE, LIGHT_PROFILE) + light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE) device_group = config[DOMAIN].get(CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) device_entity_ids = group.get_entity_ids(hass, device_group, @@ -52,7 +67,7 @@ def setup(hass, config): light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN) if not light_ids: - logger.error("No lights found to turn on ") + logger.error("No lights found to turn on") return False def calc_time_for_light_when_sunset(): From 5881f6000eab630f63ce3aec0869b194e4f4e1af Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 9 Sep 2016 16:53:18 +0200 Subject: [PATCH 187/208] Point to master till archive is back (#3285) --- homeassistant/components/netatmo.py | 6 ++---- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index a808985ae0e..f56c9b515b9 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -12,17 +12,15 @@ from homeassistant.helpers import validate_config, discovery REQUIREMENTS = [ 'https://github.com/jabesq/netatmo-api-python/archive/' - 'v0.5.0.zip#lnetatmo==0.5.0'] + 'master.zip#lnetatmo==0.5.0'] _LOGGER = logging.getLogger(__name__) CONF_SECRET_KEY = 'secret_key' -DOMAIN = "netatmo" +DOMAIN = 'netatmo' NETATMO_AUTH = None -_LOGGER = logging.getLogger(__name__) - def setup(hass, config): """Setup the Netatmo devices.""" diff --git a/requirements_all.txt b/requirements_all.txt index 57fd9e20068..c0ef98ffdc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. https://github.com/gadgetreactor/pyHS100/archive/master.zip#pyHS100==0.1.2 # homeassistant.components.netatmo -https://github.com/jabesq/netatmo-api-python/archive/v0.5.0.zip#lnetatmo==0.5.0 +https://github.com/jabesq/netatmo-api-python/archive/master.zip#lnetatmo==0.5.0 # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 From 545329174d8ec3ddd3184ef527c3e02c0d38ad8e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 9 Sep 2016 17:10:46 +0200 Subject: [PATCH 188/208] Pi-Hole statistics sensor (#3158) * Add Pi-Hole sensor * Update docstrings and remove print() * Use None for payload --- .coveragerc | 1 + homeassistant/components/sensor/pi_hole.py | 101 +++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 homeassistant/components/sensor/pi_hole.py diff --git a/.coveragerc b/.coveragerc index 99186832e7d..ff540ca1f2e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -235,6 +235,7 @@ omit = homeassistant/components/sensor/onewire.py homeassistant/components/sensor/openexchangerates.py homeassistant/components/sensor/openweathermap.py + homeassistant/components/sensor/pi_hole.py homeassistant/components/sensor/plex.py homeassistant/components/sensor/rest.py homeassistant/components/sensor/sabnzbd.py diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py new file mode 100644 index 00000000000..7cd3423bf65 --- /dev/null +++ b/homeassistant/components/sensor/pi_hole.py @@ -0,0 +1,101 @@ +""" +Support for getting statistical data from a Pi-Hole system. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.pi_hole/ +""" +import logging +import json + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor.rest import RestData +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +_ENDPOINT = '/admin/api.php' + +ATTR_BLOCKED_DOMAINS = 'domains_blocked' +ATTR_PERCENTAGE_TODAY = 'percentage_today' +ATTR_QUERIES_TODAY = 'queries_today' + +DEFAULT_HOST = 'localhost' +DEFAULT_METHOD = 'GET' +DEFAULT_NAME = 'Pi-hole' +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Pi-Hole sensor.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + method = 'GET' + payload = None + verify_ssl = config.get(CONF_VERIFY_SSL) + use_ssl = config.get(CONF_SSL) + + if use_ssl: + uri_scheme = 'https://' + else: + uri_scheme = 'http://' + + resource = "{}{}{}".format(uri_scheme, host, _ENDPOINT) + + rest = RestData(method, resource, payload, verify_ssl) + rest.update() + + if rest.data is None: + _LOGGER.error('Unable to fetch REST data') + return False + + add_devices([PiHoleSensor(hass, rest, name)]) + + +class PiHoleSensor(Entity): + """Representation of a Pi-Hole sensor.""" + + def __init__(self, hass, rest, name): + """Initialize a Pi-Hole sensor.""" + self._hass = hass + self.rest = rest + self._name = name + self._state = False + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + return self._state.get('ads_blocked_today') + + # pylint: disable=no-member + @property + def state_attributes(self): + """Return the state attributes of the GPS.""" + return { + ATTR_BLOCKED_DOMAINS: self._state.get('domains_being_blocked'), + ATTR_PERCENTAGE_TODAY: self._state.get('ads_percentage_today'), + ATTR_QUERIES_TODAY: self._state.get('dns_queries_today'), + } + + def update(self): + """Get the latest data from REST API and updates the state.""" + self.rest.update() + self._state = json.loads(self.rest.data) From ba2820810623a4bf9d822661b15ce6611cda5f7b Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 9 Sep 2016 19:06:53 +0200 Subject: [PATCH 189/208] Added stuff for support range setting (#3189) --- homeassistant/components/climate/__init__.py | 61 +++++++++++------- homeassistant/components/climate/demo.py | 38 ++++++++--- homeassistant/components/climate/ecobee.py | 25 ++++++-- .../components/climate/eq3btsmart.py | 7 +- .../components/climate/generic_thermostat.py | 8 ++- homeassistant/components/climate/heatmiser.py | 10 +-- homeassistant/components/climate/homematic.py | 7 +- homeassistant/components/climate/honeywell.py | 13 +++- homeassistant/components/climate/knx.py | 7 +- homeassistant/components/climate/nest.py | 8 ++- homeassistant/components/climate/proliphix.py | 7 +- .../components/climate/radiotherm.py | 7 +- homeassistant/components/climate/zwave.py | 57 ++++++++++------- tests/components/climate/test_demo.py | 64 ++++++++++++++++--- tests/components/climate/test_honeywell.py | 8 +-- 15 files changed, 231 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index e4215bcea85..726ed4f674c 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.helpers.entity_component import EntityComponent from homeassistant.config import load_yaml_config_file -import homeassistant.util as util from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -44,6 +43,8 @@ STATE_FAN_ONLY = "fan_only" ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" +ATTR_TARGET_TEMP_HIGH = "target_temp_high" +ATTR_TARGET_TEMP_LOW = "target_temp_low" ATTR_AWAY_MODE = "away_mode" ATTR_AUX_HEAT = "aux_heat" ATTR_FAN_MODE = "fan_mode" @@ -68,8 +69,10 @@ SET_AUX_HEAT_SCHEMA = vol.Schema({ vol.Required(ATTR_AUX_HEAT): cv.boolean, }) SET_TEMPERATURE_SCHEMA = vol.Schema({ + vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_TEMPERATURE): vol.Coerce(float), }) SET_FAN_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -113,14 +116,19 @@ def set_aux_heat(hass, aux_heat, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) -def set_temperature(hass, temperature, entity_id=None): +def set_temperature(hass, temperature=None, entity_id=None, + target_temp_high=None, target_temp_low=None): """Set new target temperature.""" - data = {ATTR_TEMPERATURE: temperature} - - if entity_id is not None: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, data) + kwargs = { + key: value for key, value in [ + (ATTR_TEMPERATURE, temperature), + (ATTR_TARGET_TEMP_HIGH, target_temp_high), + (ATTR_TARGET_TEMP_LOW, target_temp_low), + (ATTR_ENTITY_ID, entity_id), + ] if value is not None + } + _LOGGER.debug("set_temperature start data=%s", kwargs) + hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs) def set_humidity(hass, humidity, entity_id=None): @@ -227,20 +235,9 @@ def setup(hass, config): def temperature_set_service(service): """Set temperature on the target climate devices.""" target_climate = component.extract_from_service(service) - - temperature = util.convert( - service.data.get(ATTR_TEMPERATURE), float) - - if temperature is None: - _LOGGER.error( - "Received call to %s without attribute %s", - SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE) - return - + kwargs = service.data for climate in target_climate: - climate.set_temperature(convert_temperature( - temperature, hass.config.units.temperature_unit, - climate.unit_of_measurement)) + climate.set_temperature(**kwargs) if climate.should_poll: climate.update_ha_state(True) @@ -351,7 +348,7 @@ class ClimateDevice(Entity): @property def state(self): """Return the current state.""" - return self.target_temperature or STATE_UNKNOWN + return self.current_operation or STATE_UNKNOWN @property def state_attributes(self): @@ -364,6 +361,12 @@ class ClimateDevice(Entity): ATTR_TEMPERATURE: self._convert_for_display(self.target_temperature), } + target_temp_high = self.target_temperature_high + if target_temp_high is not None: + data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( + self.target_temperature_high) + data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( + self.target_temperature_low) humidity = self.target_humidity if humidity is not None: @@ -432,6 +435,16 @@ class ClimateDevice(Entity): """Return the temperature we try to reach.""" return None + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return None + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return None + @property def is_away_mode_on(self): """Return true if away mode is on.""" @@ -462,7 +475,7 @@ class ClimateDevice(Entity): """List of available swing modes.""" return None - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" raise NotImplementedError() diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 340cc29f582..cb85a153cc8 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -4,17 +4,20 @@ Demo platform that offers a fake climate device. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.climate import ( + ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Demo climate devices.""" add_devices([ DemoClimate("HeatPump", 68, TEMP_FAHRENHEIT, None, 77, "Auto Low", - None, None, "Auto", "Heat", None), + None, None, "Auto", "heat", None, None, None), DemoClimate("Hvac", 21, TEMP_CELSIUS, True, 22, "On High", - 67, 54, "Off", "Cool", False), + 67, 54, "Off", "cool", False, None, None), + DemoClimate("Ecobee", 23, TEMP_CELSIUS, None, 23, "Auto Low", + None, None, "Auto", "auto", None, 24, 21) ]) @@ -26,7 +29,7 @@ class DemoClimate(ClimateDevice): def __init__(self, name, target_temperature, unit_of_measurement, away, current_temperature, current_fan_mode, target_humidity, current_humidity, current_swing_mode, - current_operation, aux): + current_operation, aux, target_temp_high, target_temp_low): """Initialize the climate device.""" self._name = name self._target_temperature = target_temperature @@ -40,8 +43,10 @@ class DemoClimate(ClimateDevice): self._aux = aux self._current_swing_mode = current_swing_mode self._fan_list = ["On Low", "On High", "Auto Low", "Auto High", "Off"] - self._operation_list = ["Heat", "Cool", "Auto Changeover", "Off"] + self._operation_list = ["heat", "cool", "auto", "off"] self._swing_list = ["Auto", "1", "2", "3", "Off"] + self._target_temperature_high = target_temp_high + self._target_temperature_low = target_temp_low @property def should_poll(self): @@ -68,6 +73,16 @@ class DemoClimate(ClimateDevice): """Return the temperature we try to reach.""" return self._target_temperature + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return self._target_temperature_high + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return self._target_temperature_low + @property def current_humidity(self): """Return the current humidity.""" @@ -108,9 +123,14 @@ class DemoClimate(ClimateDevice): """List of available fan modes.""" return self._fan_list - def set_temperature(self, temperature): - """Set new target temperature.""" - self._target_temperature = temperature + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + if kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None and \ + kwargs.get(ATTR_TARGET_TEMP_LOW) is not None: + self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) self.update_ha_state() def set_humidity(self, humidity): diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 08bb93d5458..5d78aeb8597 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -11,9 +11,10 @@ import voluptuous as vol from homeassistant.components import ecobee from homeassistant.components.climate import ( - DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) + DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH) from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv @@ -145,7 +146,11 @@ class Thermostat(ClimateDevice): @property def current_operation(self): """Return current operation.""" - return self.operation_mode + if self.operation_mode == 'auxHeatOnly' or \ + self.operation_mode == 'heatPump': + return STATE_HEAT + else: + return self.operation_mode @property def operation_list(self): @@ -214,11 +219,17 @@ class Thermostat(ClimateDevice): """Turn away off.""" self.data.ecobee.resume_program(self.thermostat_index) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = int(temperature) - low_temp = temperature - 1 - high_temp = temperature + 1 + if kwargs.get(ATTR_TEMPERATURE) is not None: + temperature = kwargs.get(ATTR_TEMPERATURE) + low_temp = temperature - 1 + high_temp = temperature + 1 + if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None and \ + kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None: + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if self.hold_temp: self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, high_temp, "indefinite") diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index ee7fb7050f5..646bf7f2aa8 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/climate.eq3btsmart/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS, CONF_DEVICES +from homeassistant.const import TEMP_CELSIUS, CONF_DEVICES, ATTR_TEMPERATURE from homeassistant.util.temperature import convert REQUIREMENTS = ['bluepy_devices==0.2.0'] @@ -60,8 +60,11 @@ class EQ3BTSmartThermostat(ClimateDevice): """Return the temperature we try to reach.""" return self._thermostat.target_temperature - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self._thermostat.target_temperature = temperature @property diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 11e6707ad47..fd85d7fd46b 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -11,7 +11,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components import switch from homeassistant.components.climate import ( STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE) from homeassistant.helpers import condition from homeassistant.helpers.event import track_state_change @@ -123,8 +124,11 @@ class GenericThermostat(ClimateDevice): """Return the temperature we try to reach.""" return self._target_temp - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self._target_temp = temperature self._control_heating() self.update_ha_state() diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py index c7dd5534f57..941f211c411 100644 --- a/homeassistant/components/climate/heatmiser.py +++ b/homeassistant/components/climate/heatmiser.py @@ -10,7 +10,7 @@ https://home-assistant.io/components/climate.heatmiser/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE CONF_IPADDRESS = 'ipaddress' CONF_PORT = 'port' @@ -98,16 +98,18 @@ class HeatmiserV3Thermostat(ClimateDevice): """Return the temperature we try to reach.""" return self._target_temperature - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = int(temperature) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self.heatmiser.hmSendAddress( self._id, 18, temperature, 1, self.serport) - self._target_temperature = int(temperature) + self._target_temperature = temperature def update(self): """Get the latest data.""" diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index e51ad5e67a5..a8fa47999d2 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -8,7 +8,7 @@ import logging import homeassistant.components.homematic as homematic from homeassistant.components.climate import ClimateDevice, STATE_AUTO from homeassistant.util.temperature import convert -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN +from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE DEPENDENCIES = ['homematic'] @@ -90,10 +90,13 @@ class HMThermostat(homematic.HMDevice, ClimateDevice): return None return self._data.get('SET_TEMPERATURE', None) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) if not self.available: return None + if temperature is None: + return self._hmdevice.set_temperature(temperature) def set_operation_mode(self, operation_mode): diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 1efce2b95de..001bf8806ac 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -9,7 +9,8 @@ import socket from homeassistant.components.climate import ClimateDevice from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + ATTR_TEMPERATURE) REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.2.1'] @@ -132,8 +133,11 @@ class RoundThermostat(ClimateDevice): return None return self._target_temperature - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self.device.set_temperature(self._name, temperature) @property @@ -234,8 +238,11 @@ class HoneywellUSThermostat(ClimateDevice): """Return current operation ie. heat, cool, idle.""" return getattr(self._device, 'system_mode', None) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return import somecomfort try: if self._device.system_mode == 'cool': diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 10f02d80cc7..a9d4358a059 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/knx/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE from homeassistant.components.knx import ( KNXConfig, KNXMultiAddressDevice) @@ -71,8 +71,11 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): return knx2_to_float(self.value("setpoint")) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return from knxip.conversion import float_to_knx2 self.set_value("setpoint", float_to_knx2(temperature)) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 585ff804526..f55d1d856eb 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -9,7 +9,8 @@ import voluptuous as vol import homeassistant.components.nest as nest from homeassistant.components.climate import ( STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.const import TEMP_CELSIUS, CONF_SCAN_INTERVAL +from homeassistant.const import ( + TEMP_CELSIUS, CONF_SCAN_INTERVAL, ATTR_TEMPERATURE) DEPENDENCIES = ['nest'] @@ -131,8 +132,11 @@ class NestThermostat(ClimateDevice): """Return if away mode is on.""" return self.structure.away - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return if self.device.mode == 'range': if self.target_temperature == self.target_temperature_low: temperature = (temperature, self.target_temperature_high) diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py index c6e8ed69617..fa2230fba55 100644 --- a/homeassistant/components/climate/proliphix.py +++ b/homeassistant/components/climate/proliphix.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/climate.proliphix/ from homeassistant.components.climate import ( STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) REQUIREMENTS = ['proliphix==0.3.1'] @@ -85,6 +85,9 @@ class ProliphixThermostat(ClimateDevice): elif state == 6: return STATE_COOL - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self._pdp.setback = temperature diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index deee3d53f3f..90611ce20b2 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -11,7 +11,7 @@ from urllib.error import URLError from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF, ClimateDevice) -from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT +from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE REQUIREMENTS = ['radiotherm==1.2'] HOLD_TEMP = 'hold_temp' @@ -107,8 +107,11 @@ class RadioThermostat(ClimateDevice): else: self._current_operation = STATE_IDLE - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return if self._current_operation == STATE_COOL: self.device.t_cool = temperature elif self._current_operation == STATE_HEAT: diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 3a1152c7a96..0ba85105c18 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -12,7 +12,8 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ( ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) from homeassistant.components import zwave -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -96,6 +97,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self._current_swing_mode = None self._swing_list = None self._unit = temp_unit + self._index_operation = None _LOGGER.debug("temp_unit is %s", self._unit) self._zxt_120 = None self._hrt4_zw = None @@ -132,6 +134,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): self._current_operation = value.data + self._index_operation = SET_TEMP_TO_INDEX.get( + self._current_operation) self._operation_list = list(value.data_items) _LOGGER.debug("self._operation_list=%s", self._operation_list) _LOGGER.debug("self._current_operation=%s", @@ -165,22 +169,14 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if self.current_operation is not None and \ self.current_operation != 'Off': - if SET_TEMP_TO_INDEX.get(self._current_operation) \ - != value.index: + if self._index_operation != value.index: continue if self._zxt_120: - continue + break self._target_temperature = int(value.data) - _LOGGER.debug("Get setpoint value: SET_TEMP_TO_INDEX=%s and" - " self._current_operation=%s", - SET_TEMP_TO_INDEX.get(self._current_operation), - self._current_operation) break - _LOGGER.debug("Get setpoint value not matching any " - "SET_TEMP_TO_INDEX=%s and " - "self._current_operation=%s. Using value.data=%s", - SET_TEMP_TO_INDEX.get(self._current_operation), - self._current_operation, int(value.data)) + _LOGGER.debug("Device can't set setpoint based on operation mode." + " Defaulting to index=1") self._target_temperature = int(value.data) @property @@ -238,31 +234,48 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): """Return the temperature we try to reach.""" return self._target_temperature - def set_temperature(self, temperature): +# pylint: disable=too-many-branches, too-many-statements + def set_temperature(self, **kwargs): """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + temperature = kwargs.get(ATTR_TEMPERATURE) + else: + return + for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if self.current_operation is not None: if self._hrt4_zw and self.current_operation == 'Off': # HRT4-ZW can change setpoint when off. value.data = int(temperature) - if SET_TEMP_TO_INDEX.get(self._current_operation) \ - != value.index: + if self._index_operation != value.index: continue - _LOGGER.debug("SET_TEMP_TO_INDEX=%s and" + _LOGGER.debug("self._index_operation=%s and" " self._current_operation=%s", - SET_TEMP_TO_INDEX.get(self._current_operation), + self._index_operation, self._current_operation) if self._zxt_120: + _LOGGER.debug("zxt_120: Setting new setpoint for %s, " + " operation=%s, temp=%s", + self._index_operation, + self._current_operation, temperature) # ZXT-120 does not support get setpoint self._target_temperature = temperature # ZXT-120 responds only to whole int - value.data = int(round(temperature, 0)) + value.data = round(temperature, 0) + break else: - value.data = int(temperature) - break + _LOGGER.debug("Setting new setpoint for %s, " + "operation=%s, temp=%s", + self._index_operation, + self._current_operation, temperature) + value.data = temperature + break else: - value.data = int(temperature) + _LOGGER.debug("Setting new setpoint for no known " + "operation mode. Index=1 and " + "temperature=%s", temperature) + value.data = temperature break def set_fan_mode(self, fan): diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 4b3d4fcc64a..dbb9f8a192e 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -10,6 +10,7 @@ from tests.common import get_test_home_assistant ENTITY_CLIMATE = 'climate.hvac' +ENTITY_ECOBEE = 'climate.ecobee' class TestDemoClimate(unittest.TestCase): @@ -37,7 +38,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual(67, state.attributes.get('humidity')) self.assertEqual(54, state.attributes.get('current_humidity')) self.assertEqual("Off", state.attributes.get('swing_mode')) - self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.attributes.get('operation_mode')) self.assertEqual('off', state.attributes.get('aux_heat')) def test_default_setup_params(self): @@ -48,7 +49,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual(30, state.attributes.get('min_humidity')) self.assertEqual(99, state.attributes.get('max_humidity')) - def test_set_target_temp_bad_attr(self): + def test_set_only_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(21, state.attributes.get('temperature')) @@ -56,23 +57,55 @@ class TestDemoClimate(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(21, state.attributes.get('temperature')) - def test_set_target_temp(self): + def test_set_only_target_temp(self): """Test the setting of the target temperature.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) climate.set_temperature(self.hass, 30, ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(30.0, state.attributes.get('temperature')) + def test_set_target_temp_range(self): + """Test the setting of the target temperature with range.""" + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(23.0, state.attributes.get('temperature')) + self.assertEqual(21.0, state.attributes.get('target_temp_low')) + self.assertEqual(24.0, state.attributes.get('target_temp_high')) + climate.set_temperature(self.hass, 30, ENTITY_ECOBEE, 25, 20) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(30.0, state.attributes.get('temperature')) + self.assertEqual(20.0, state.attributes.get('target_temp_low')) + self.assertEqual(25.0, state.attributes.get('target_temp_high')) + + def test_set_target_temp_range_bad_attr(self): + """Test setting the target temperature range without required + attribute.""" + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(23, state.attributes.get('temperature')) + self.assertEqual(21.0, state.attributes.get('target_temp_low')) + self.assertEqual(24.0, state.attributes.get('target_temp_high')) + climate.set_temperature(self.hass, None, ENTITY_ECOBEE, None, None) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(23, state.attributes.get('temperature')) + self.assertEqual(21.0, state.attributes.get('target_temp_low')) + self.assertEqual(24.0, state.attributes.get('target_temp_high')) + def test_set_target_humidity_bad_attr(self): """Test setting the target humidity without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(67, state.attributes.get('humidity')) climate.set_humidity(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(67, state.attributes.get('humidity')) def test_set_target_humidity(self): """Test the setting of the target humidity.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(67, state.attributes.get('humidity')) climate.set_humidity(self.hass, 64, ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) @@ -84,10 +117,13 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual("On High", state.attributes.get('fan_mode')) climate.set_fan_mode(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("On High", state.attributes.get('fan_mode')) def test_set_fan_mode(self): """Test setting of new fan mode.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("On High", state.attributes.get('fan_mode')) climate.set_fan_mode(self.hass, "On Low", ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) @@ -99,30 +135,40 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual("Off", state.attributes.get('swing_mode')) climate.set_swing_mode(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("Off", state.attributes.get('swing_mode')) def test_set_swing(self): """Test setting of new swing mode.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("Off", state.attributes.get('swing_mode')) climate.set_swing_mode(self.hass, "Auto", ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("Auto", state.attributes.get('swing_mode')) - def test_set_operation_bad_attr(self): - """Test setting operation mode without required attribute.""" + def test_set_operation_bad_attr_and_state(self): + """Test setting operation mode without required attribute, and + check the state.""" state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) climate.set_operation_mode(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) def test_set_operation(self): """Test setting of new operation mode.""" - climate.set_operation_mode(self.hass, "Heat", ENTITY_CLIMATE) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) + climate.set_operation_mode(self.hass, "heat", ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("Heat", state.attributes.get('operation_mode')) + self.assertEqual("heat", state.attributes.get('operation_mode')) + self.assertEqual("heat", state.state) def test_set_away_mode_bad_attr(self): """Test setting the away mode without required attribute.""" diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py index 6c97b65dea7..75a4d1081f3 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -274,7 +274,7 @@ class TestHoneywellRound(unittest.TestCase): def test_set_temperature(self): """Test setting the temperature.""" - self.round1.set_temperature(25) + self.round1.set_temperature(temperature=25) self.device.set_temperature.assert_called_once_with('House', 25) def test_set_operation_mode(self: unittest.TestCase) -> None: @@ -327,13 +327,13 @@ class TestHoneywellUS(unittest.TestCase): def test_set_temp(self): """Test setting the temperature.""" - self.honeywell.set_temperature(70) + self.honeywell.set_temperature(temperature=70) self.assertEqual(70, self.device.setpoint_heat) self.assertEqual(70, self.honeywell.target_temperature) self.device.system_mode = 'cool' self.assertEqual(78, self.honeywell.target_temperature) - self.honeywell.set_temperature(74) + self.honeywell.set_temperature(temperature=74) self.assertEqual(74, self.device.setpoint_cool) self.assertEqual(74, self.honeywell.target_temperature) @@ -351,7 +351,7 @@ class TestHoneywellUS(unittest.TestCase): """Test if setting the temperature fails.""" self.device.setpoint_heat = mock.MagicMock( side_effect=somecomfort.SomeComfortError) - self.honeywell.set_temperature(123) + self.honeywell.set_temperature(temperature=123) def test_attributes(self): """Test the attributes.""" From e87da765c57319f845abc31edc0bcaea8aa8ef64 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 9 Sep 2016 19:33:12 +0200 Subject: [PATCH 190/208] cleanup Homematic code (#3291) * cleanup old code * cleanup round 2 * remove unwanted platforms --- .../components/binary_sensor/homematic.py | 44 +------- homeassistant/components/climate/homematic.py | 26 +---- homeassistant/components/cover/homematic.py | 25 +---- homeassistant/components/homematic.py | 102 ++++++++---------- homeassistant/components/light/homematic.py | 45 ++------ .../components/rollershutter/homematic.py | 102 ------------------ homeassistant/components/sensor/homematic.py | 45 +------- homeassistant/components/switch/homematic.py | 51 ++------- .../components/thermostat/homematic.py | 90 ---------------- 9 files changed, 78 insertions(+), 452 deletions(-) delete mode 100644 homeassistant/components/rollershutter/homematic.py delete mode 100644 homeassistant/components/thermostat/homematic.py diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index 117642c65f1..073f5d7eb6d 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -31,9 +31,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMBinarySensor, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMBinarySensor, + discovery_info, + add_callback_devices + ) class HMBinarySensor(homematic.HMDevice, BinarySensorDevice): @@ -57,44 +59,8 @@ class HMBinarySensor(homematic.HMDevice, BinarySensorDevice): return "motion" return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__, None) - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.sensors import HMBinarySensor\ - as pyHMBinarySensor - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # check if the Homematic device correct for this HA device - if not isinstance(self._hmdevice, pyHMBinarySensor): - _LOGGER.critical("This %s can't be use as binary", self._name) - return False - - # if exists user value? - if self._state and self._state not in self._hmdevice.BINARYNODE: - _LOGGER.critical("This %s have no binary with %s", self._name, - self._state) - return False - - # only check and give a warning to the user - if self._state is None and len(self._hmdevice.BINARYNODE) > 1: - _LOGGER.critical("%s have multiple binary params. It use all " - "binary nodes as one. Possible param values: %s", - self._name, str(self._hmdevice.BINARYNODE)) - return False - - return True - def _init_data_struct(self): """Generate a data struct (self._data) from the Homematic metadata.""" - super()._init_data_struct() - - # object have 1 binary - if self._state is None and len(self._hmdevice.BINARYNODE) == 1: - for value in self._hmdevice.BINARYNODE: - self._state = value - # add state to data struct if self._state: _LOGGER.debug("%s init datastruct with main node '%s'", self._name, diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index a8fa47999d2..7e0b4fd6450 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -29,9 +29,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMThermostat, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMThermostat, + discovery_info, + add_callback_devices + ) # pylint: disable=abstract-method @@ -116,26 +118,8 @@ class HMThermostat(homematic.HMDevice, ClimateDevice): """Return the maximum temperature - 30.5 means on.""" return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.thermostats import HMThermostat\ - as pyHMThermostat - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device correct for this HA device - if isinstance(self._hmdevice, pyHMThermostat): - return True - - _LOGGER.critical("This %s can't be use as thermostat", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" - super()._init_data_struct() - # Add state to data dict self._data.update({"CONTROL_MODE": STATE_UNKNOWN, "SET_TEMPERATURE": STATE_UNKNOWN, diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index fd68ac3d265..aea05a9160a 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -24,9 +24,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMCover, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMCover, + discovery_info, + add_callback_devices + ) # pylint: disable=abstract-method @@ -77,25 +79,8 @@ class HMCover(homematic.HMDevice, CoverDevice): if self.available: self._hmdevice.stop(self._channel) - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.actors import Blind - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the homematic device is correct for this HA device - if isinstance(self._hmdevice, Blind): - return True - - _LOGGER.critical("This %s can't be use as cover!", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from hm metadata.""" - super()._init_data_struct() - # Add state to data dict self._state = "LEVEL" self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index b0dcad55cfe..1baf52d37d7 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -331,9 +331,11 @@ def _get_devices(device_type, keys): _LOGGER.debug("Handling %s:%i", key, channel) if channel in params: for param in params[channel]: - name = _create_ha_name(name=device.NAME, - channel=channel, - param=param) + name = _create_ha_name( + name=device.NAME, + channel=channel, + param=param + ) device_dict = { CONF_PLATFORM: "homematic", ATTR_ADDRESS: key, @@ -354,8 +356,7 @@ def _get_devices(device_type, keys): _LOGGER.debug("Channel %i not in params", channel) else: _LOGGER.debug("Got no params for %s", key) - _LOGGER.debug("%s autodiscovery: %s", - device_type, str(device_arr)) + _LOGGER.debug("%s autodiscovery: %s", device_type, str(device_arr)) return device_arr @@ -365,9 +366,7 @@ def _create_params_list(hmdevice, metadata, device_type): merge = False # use merge? - if device_type == DISCOVER_SENSORS: - merge = True - elif device_type == DISCOVER_BINARY_SENSORS: + if device_type in (DISCOVER_SENSORS, DISCOVER_BINARY_SENSORS): merge = True # Search in sensor and binary metadata per elements @@ -429,11 +428,10 @@ def setup_hmdevice_discovery_helper(hmdevicetype, discovery_info, # create object and add to HA new_device = hmdevicetype(config) - add_callback_devices([new_device]) - - # link to HM new_device.link_homematic() + add_callback_devices([new_device]) + return True @@ -668,7 +666,7 @@ class HMDevice(Entity): attr[data[0]] = value # static attributes - attr["ID"] = self._hmdevice.ADDRESS + attr['ID'] = self._hmdevice.ADDRESS return attr @@ -678,6 +676,10 @@ class HMDevice(Entity): if self._connected: return True + # pyhomematic is loaded + if HOMEMATIC is None: + return False + # Does a HMDevice from pyhomematic exist? if self._address in HOMEMATIC.devices: # Init @@ -686,33 +688,25 @@ class HMDevice(Entity): # Check if Homematic class is okay for HA class _LOGGER.info("Start linking %s to %s", self._address, self._name) - if self._check_hm_to_ha_object(): - try: - # Init datapoints of this object - self._init_data_struct() - if HOMEMATIC_LINK_DELAY: - # We delay / pause loading of data to avoid overloading - # of CCU / Homegear when doing auto detection - time.sleep(HOMEMATIC_LINK_DELAY) - self._load_init_data_from_hm() - _LOGGER.debug("%s datastruct: %s", - self._name, str(self._data)) + try: + # Init datapoints of this object + self._init_data() + if HOMEMATIC_LINK_DELAY: + # We delay / pause loading of data to avoid overloading + # of CCU / Homegear when doing auto detection + time.sleep(HOMEMATIC_LINK_DELAY) + self._load_data_from_hm() + _LOGGER.debug("%s datastruct: %s", self._name, str(self._data)) - # Link events from pyhomatic - self._subscribe_homematic_events() - self._available = not self._hmdevice.UNREACH - # pylint: disable=broad-except - except Exception as err: - self._connected = False - _LOGGER.error("Exception while linking %s: %s", - self._address, str(err)) - else: - _LOGGER.critical("Delink %s object from HM", self._name) + # Link events from pyhomatic + self._subscribe_homematic_events() + self._available = not self._hmdevice.UNREACH + _LOGGER.debug("%s linking done", self._name) + # pylint: disable=broad-except + except Exception as err: self._connected = False - - # Update HA - _LOGGER.debug("%s linking done, send update_ha_state", self._name) - self.update_ha_state() + _LOGGER.error("Exception while linking %s: %s", + self._address, str(err)) else: _LOGGER.debug("%s not found in HOMEMATIC.devices", self._address) @@ -730,7 +724,7 @@ class HMDevice(Entity): have_change = True # If available it has changed - if attribute is "UNREACH": + if attribute is 'UNREACH': self._available = bool(value) have_change = True @@ -771,7 +765,7 @@ class HMDevice(Entity): bequeath=False, channel=channel) - def _load_init_data_from_hm(self): + def _load_data_from_hm(self): """Load first value from pyhomematic.""" if not self._connected: return False @@ -800,27 +794,15 @@ class HMDevice(Entity): return self._data[self._state] return None - def _check_hm_to_ha_object(self): - """Check if it is possible to use the Homematic object as this HA type. - - NEEDS overwrite by inherit! - """ - if not self._connected or self._hmdevice is None: - _LOGGER.error("HA object is not linked to homematic.") - return False - - # Check if button option is correctly set for this object - if self._channel > self._hmdevice.ELEMENT: - _LOGGER.critical("Button option is not correct for this object!") - return False - - return True - - def _init_data_struct(self): - """Generate a data dict (self._data) from the Homematic metadata. - - NEEDS overwrite by inherit! - """ + def _init_data(self): + """Generate a data dict (self._data) from the Homematic metadata.""" # Add all attributes to data dict for data_note in self._hmdevice.ATTRIBUTENODE: self._data.update({data_note: STATE_UNKNOWN}) + + # init device specified data + self._init_data_struct() + + def _init_data_struct(self): + """Generate a data dict from the Homematic device metadata.""" + raise NotImplementedError diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 2e233e0e3ff..3f8eb1a22a5 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -22,9 +22,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMLight, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMLight, + discovery_info, + add_callback_devices + ) class HMLight(homematic.HMDevice, Light): @@ -70,41 +72,8 @@ class HMLight(homematic.HMDevice, Light): if self.available: self._hmdevice.off(self._channel) - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.actors import Dimmer, Switch - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device is correct for this HA device - if isinstance(self._hmdevice, Switch): - return True - if isinstance(self._hmdevice, Dimmer): - return True - - _LOGGER.critical("This %s can't be use as light", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" - from pyhomematic.devicetypes.actors import Dimmer, Switch - - super()._init_data_struct() - - # Use STATE - if isinstance(self._hmdevice, Switch): - self._state = "STATE" - # Use LEVEL - if isinstance(self._hmdevice, Dimmer): - self._state = "LEVEL" - - # Add state to data dict - if self._state: - _LOGGER.debug("%s init datadict with main node '%s'", self._name, - self._state) - self._data.update({self._state: STATE_UNKNOWN}) - else: - _LOGGER.critical("Can't correctly init light %s.", self._name) + self._state = "LEVEL" + self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/rollershutter/homematic.py b/homeassistant/components/rollershutter/homematic.py deleted file mode 100644 index 613d7884919..00000000000 --- a/homeassistant/components/rollershutter/homematic.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -The homematic rollershutter platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/rollershutter.homematic/ - -Important: For this platform to work the homematic component has to be -properly configured. -""" - -import logging -from homeassistant.const import (STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) -from homeassistant.components.rollershutter import RollershutterDevice,\ - ATTR_CURRENT_POSITION -import homeassistant.components.homematic as homematic - - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['homematic'] - - -def setup_platform(hass, config, add_callback_devices, discovery_info=None): - """Setup the platform.""" - if discovery_info is None: - return - - return homematic.setup_hmdevice_discovery_helper(HMRollershutter, - discovery_info, - add_callback_devices) - - -# pylint: disable=abstract-method -class HMRollershutter(homematic.HMDevice, RollershutterDevice): - """Represents a Homematic Rollershutter in Home Assistant.""" - - @property - def current_position(self): - """ - Return current position of rollershutter. - - None is unknown, 0 is closed, 100 is fully open. - """ - if self.available: - return int((1 - self._hm_get_state()) * 100) - return None - - def move_position(self, **kwargs): - """Move the roller shutter to a specific position.""" - if self.available: - if ATTR_CURRENT_POSITION in kwargs: - position = float(kwargs[ATTR_CURRENT_POSITION]) - position = min(100, max(0, position)) - level = (100 - position) / 100.0 - self._hmdevice.set_level(level, self._channel) - - @property - def state(self): - """Return the state of the rollershutter.""" - current = self.current_position - if current is None: - return STATE_UNKNOWN - - return STATE_CLOSED if current == 100 else STATE_OPEN - - def move_up(self, **kwargs): - """Move the rollershutter up.""" - if self.available: - self._hmdevice.move_up(self._channel) - - def move_down(self, **kwargs): - """Move the rollershutter down.""" - if self.available: - self._hmdevice.move_down(self._channel) - - def stop(self, **kwargs): - """Stop the device if in motion.""" - if self.available: - self._hmdevice.stop(self._channel) - - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.actors import Blind - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the homematic device is correct for this HA device - if isinstance(self._hmdevice, Blind): - return True - - _LOGGER.critical("This %s can't be use as rollershutter!", self._name) - return False - - def _init_data_struct(self): - """Generate a data dict (self._data) from hm metadata.""" - super()._init_data_struct() - - # Add state to data dict - self._state = "LEVEL" - self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 35cc4aea42b..8857ee6d889 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -46,9 +46,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMSensor, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMSensor, + discovery_info, + add_callback_devices + ) class HMSensor(homematic.HMDevice): @@ -76,45 +78,8 @@ class HMSensor(homematic.HMDevice): return HM_UNIT_HA_CAST.get(self._state, None) - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.sensors import HMSensor as pyHMSensor - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the homematic device is correct for this HA device - if not isinstance(self._hmdevice, pyHMSensor): - _LOGGER.critical("This %s can't be use as sensor!", self._name) - return False - - # Does user defined value exist? - if self._state and self._state not in self._hmdevice.SENSORNODE: - # pylint: disable=logging-too-many-args - _LOGGER.critical("This %s have no sensor with %s! Values are", - self._name, self._state, - str(self._hmdevice.SENSORNODE.keys())) - return False - - # No param is set and more than 1 sensor nodes are present - if self._state is None and len(self._hmdevice.SENSORNODE) > 1: - _LOGGER.critical("This %s has multiple sensor nodes. " + - "Please us param. Values are: %s", self._name, - str(self._hmdevice.SENSORNODE.keys())) - return False - - _LOGGER.debug("%s is okay for linking", self._name) - return True - def _init_data_struct(self): """Generate a data dict (self._data) from hm metadata.""" - super()._init_data_struct() - - if self._state is None and len(self._hmdevice.SENSORNODE) == 1: - for value in self._hmdevice.SENSORNODE: - self._state = value - # Add state to data dict if self._state: _LOGGER.debug("%s init datadict with main node '%s'", self._name, diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index e9f103b95fa..e13027780c6 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -19,9 +19,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMSwitch, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMSwitch, + discovery_info, + add_callback_devices + ) class HMSwitch(homematic.HMDevice, SwitchDevice): @@ -56,47 +58,12 @@ class HMSwitch(homematic.HMDevice, SwitchDevice): if self.available: self._hmdevice.off(self._channel) - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.actors import Dimmer, Switch - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device is correct for this HA device - if isinstance(self._hmdevice, Switch): - return True - if isinstance(self._hmdevice, Dimmer): - return True - - _LOGGER.critical("This %s can't be use as switch", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" - from pyhomematic.devicetypes.actors import Dimmer,\ - Switch, SwitchPowermeter - - super()._init_data_struct() - # Use STATE - if isinstance(self._hmdevice, Switch): - self._state = "STATE" - - # Use LEVEL - if isinstance(self._hmdevice, Dimmer): - self._state = "LEVEL" + self._state = "STATE" + self._data.update({self._state: STATE_UNKNOWN}) # Need sensor values for SwitchPowermeter - if isinstance(self._hmdevice, SwitchPowermeter): - for node in self._hmdevice.SENSORNODE: - self._data.update({node: STATE_UNKNOWN}) - - # Add state to data dict - if self._state: - _LOGGER.debug("%s init data dict with main node '%s'", self._name, - self._state) - self._data.update({self._state: STATE_UNKNOWN}) - else: - _LOGGER.critical("Can't correctly init light %s.", self._name) + for node in self._hmdevice.SENSORNODE: + self._data.update({node: STATE_UNKNOWN}) diff --git a/homeassistant/components/thermostat/homematic.py b/homeassistant/components/thermostat/homematic.py deleted file mode 100644 index 73901ab61df..00000000000 --- a/homeassistant/components/thermostat/homematic.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Support for Homematic thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/thermostat.homematic/ -""" -import logging -import homeassistant.components.homematic as homematic -from homeassistant.components.thermostat import ThermostatDevice -from homeassistant.util.temperature import convert -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN - -DEPENDENCIES = ['homematic'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_callback_devices, discovery_info=None): - """Setup the Homematic thermostat platform.""" - if discovery_info is None: - return - - return homematic.setup_hmdevice_discovery_helper(HMThermostat, - discovery_info, - add_callback_devices) - - -# pylint: disable=abstract-method -class HMThermostat(homematic.HMDevice, ThermostatDevice): - """Representation of a Homematic thermostat.""" - - @property - def unit_of_measurement(self): - """Return the unit of measurement that is used.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - if not self.available: - return None - return self._data["ACTUAL_TEMPERATURE"] - - @property - def target_temperature(self): - """Return the target temperature.""" - if not self.available: - return None - return self._data["SET_TEMPERATURE"] - - def set_temperature(self, temperature): - """Set new target temperature.""" - if not self.available: - return None - self._hmdevice.set_temperature(temperature) - - @property - def min_temp(self): - """Return the minimum temperature - 4.5 means off.""" - return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) - - @property - def max_temp(self): - """Return the maximum temperature - 30.5 means on.""" - return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) - - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.thermostats import HMThermostat\ - as pyHMThermostat - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device correct for this HA device - if isinstance(self._hmdevice, pyHMThermostat): - return True - - _LOGGER.critical("This %s can't be use as thermostat", self._name) - return False - - def _init_data_struct(self): - """Generate a data dict (self._data) from the Homematic metadata.""" - super()._init_data_struct() - - # Add state to data dict - self._data.update({"CONTROL_MODE": STATE_UNKNOWN, - "SET_TEMPERATURE": STATE_UNKNOWN, - "ACTUAL_TEMPERATURE": STATE_UNKNOWN}) From d466bae24436146a7ff7b9a83bcecbfe37b59160 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 9 Sep 2016 14:23:03 -0700 Subject: [PATCH 191/208] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../components/frontend/www_static/core.js.gz | Bin 32161 -> 32161 bytes .../frontend/www_static/frontend.html | 4 ++-- .../frontend/www_static/frontend.html.gz | Bin 124636 -> 126732 bytes .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-dev-event.html.gz | Bin 2639 -> 2639 bytes .../panels/ha-panel-dev-info.html.gz | Bin 1308 -> 1308 bytes .../panels/ha-panel-dev-service.html.gz | Bin 2824 -> 2824 bytes .../panels/ha-panel-dev-state.html.gz | Bin 2772 -> 2772 bytes .../panels/ha-panel-dev-template.html.gz | Bin 7290 -> 7290 bytes .../panels/ha-panel-history.html.gz | Bin 6842 -> 6842 bytes .../www_static/panels/ha-panel-iframe.html.gz | Bin 403 -> 403 bytes .../panels/ha-panel-logbook.html.gz | Bin 7344 -> 7344 bytes .../www_static/panels/ha-panel-map.html.gz | Bin 43920 -> 43920 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2279 -> 2282 bytes .../www_static/webcomponents-lite.min.js.gz | Bin 12355 -> 12355 bytes 17 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 3e635091aaf..796d91cade8 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -2,7 +2,7 @@ FINGERPRINTS = { "core.js": "1fd10c1fcdf56a61f60cf861d5a0368c", - "frontend.html": "1903f9cc2ad4ed725c81f544e53d2ee4", + "frontend.html": "20defe06c11b2fa2f076dc92b6c3b0dd", "mdi.html": "710b84acc99b32514f52291aba9cd8e8", "panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b", "panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169", diff --git a/homeassistant/components/frontend/www_static/core.js.gz b/homeassistant/components/frontend/www_static/core.js.gz index 29ce0de683238a9ca18c6b900a7e5785f6afd1d1..e9a51484a98a59b63b0150aba3350692f79655d8 100644 GIT binary patch delta 18 acmZ4Zn{nZ9Mt1pb4h~(7%NyBe)dB!T$OiKO delta 18 acmZ4Zn{nZ9Mt1pb4i4wlr#G_Css#W^Ob3Jj diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 2b7e3f23be0..3d70d83b986 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,5 +1,5 @@ \ No newline at end of file +var r=t.propertyDataFromStyles(n._styles,this),i=!this.__notStyleScopeCacheable;i&&(r.key.customStyle=this.customStyle,e=n._styleCache.retrieve(this.is,r.key,this._styles));var a=Boolean(e);a?this._styleProperties=e._styleProperties:this._computeStyleProperties(r.properties),this._computeOwnStyleProperties(),a||(e=o.retrieve(this.is,this._ownStyleProperties,this._styles));var l=Boolean(e)&&!a,h=this._applyStyleProperties(e);a||(h=h&&s?h.cloneNode(!0):h,e={style:h,_scopeSelector:this._scopeSelector,_styleProperties:this._styleProperties},i&&(r.key.customStyle={},this.mixin(r.key.customStyle,this.customStyle),n._styleCache.store(this.is,e,r.key,this._styles)),l||o.store(this.is,Object.create(e),this._ownStyleProperties,this._styles))},_computeStyleProperties:function(e){var n=this._findStyleHost();n._styleProperties||n._computeStyleProperties();var r=Object.create(n._styleProperties),s=t.hostAndRootPropertiesForScope(this);this.mixin(r,s.hostProps),e=e||t.propertyDataFromStyles(n._styles,this).properties,this.mixin(r,e),this.mixin(r,s.rootProps),t.mixinCustomStyle(r,this.customStyle),t.reify(r),this._styleProperties=r},_computeOwnStyleProperties:function(){for(var e,t={},n=0;n0&&l.push(t);return[{removed:a,added:l}]}},Polymer.Collection.get=function(e){return Polymer._collections.get(e)||new Polymer.Collection(e)},Polymer.Collection.applySplices=function(e,t){var n=Polymer._collections.get(e);return n?n._applySplices(t):null},Polymer({is:"dom-repeat",extends:"template",_template:null,properties:{items:{type:Array},as:{type:String,value:"item"},indexAs:{type:String,value:"index"},sort:{type:Function,observer:"_sortChanged"},filter:{type:Function,observer:"_filterChanged"},observe:{type:String,observer:"_observeChanged"},delay:Number,renderedItemCount:{type:Number,notify:!0,readOnly:!0},initialCount:{type:Number,observer:"_initializeChunking"},targetFramerate:{type:Number,value:20},_targetFrameTime:{type:Number,computed:"_computeFrameTime(targetFramerate)"}},behaviors:[Polymer.Templatizer],observers:["_itemsChanged(items.*)"],created:function(){this._instances=[],this._pool=[],this._limit=1/0;var e=this;this._boundRenderChunk=function(){e._renderChunk()}},detached:function(){this.__isDetached=!0;for(var e=0;e=0;t--){var n=this._instances[t];n.isPlaceholder&&t=this._limit&&(n=this._downgradeInstance(t,n.__key__)),e[n.__key__]=t,n.isPlaceholder||n.__setProperty(this.indexAs,t,!0)}this._pool.length=0,this._setRenderedItemCount(this._instances.length),this.fire("dom-change"),this._tryRenderChunk()},_applyFullRefresh:function(){var e,t=this.collection;if(this._sortFn)e=t?t.getKeys():[];else{e=[];var n=this.items;if(n)for(var r=0;r=r;a--)this._detachAndRemoveInstance(a)},_numericSort:function(e,t){return e-t},_applySplicesUserSort:function(e){for(var t,n,r=this.collection,s={},i=0;i=0;i--){var h=a[i];void 0!==h&&this._detachAndRemoveInstance(h)}var c=this;if(l.length){this._filterFn&&(l=l.filter(function(e){return c._filterFn(r.getItem(e))})),l.sort(function(e,t){return c._sortFn(r.getItem(e),r.getItem(t))});var u=0;for(i=0;i>1,a=this._instances[o].__key__,l=this._sortFn(n.getItem(a),r);if(l<0)e=o+1;else{if(!(l>0)){i=o;break}s=o-1}}return i<0&&(i=s+1),this._insertPlaceholder(i,t),i},_applySplicesArrayOrder:function(e){for(var t,n=0;n=0?(e=this.as+"."+e.substring(n+1),i._notifyPath(e,t,!0)):i.__setProperty(this.as,t,!0))}},itemForElement:function(e){var t=this.modelForElement(e);return t&&t[this.as]},keyForElement:function(e){var t=this.modelForElement(e);return t&&t.__key__},indexForElement:function(e){var t=this.modelForElement(e);return t&&t[this.indexAs]}}),Polymer({is:"array-selector",_template:null,properties:{items:{type:Array,observer:"clearSelection"},multi:{type:Boolean,value:!1,observer:"clearSelection"},selected:{type:Object,notify:!0},selectedItem:{type:Object,notify:!0},toggle:{type:Boolean,value:!1}},clearSelection:function(){if(Array.isArray(this.selected))for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index aff3fd48b4245aa7046d67544be495b39656463f..3c052db8e0c1fec54a7e48a81272bcfe17c2fb65 100644 GIT binary patch delta 64745 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 delta 62631 zcmV(@K-RyE-Ur;x2L~UE2na)~%drRY)qjM&ZRA)K{VNnEQHT*plU`;a4fE4@IhhxG z>}zYz$vuibU5ErF#1x?bkZncce}A>~-VKoQOiu25UosZadskOiS6A0!;cuuxXOKoM zQQ%mHiM8i-BHfCZWrqa2O-mRyu@$vv9QsU;Pg(=5`C;0`9=ca_Hr^*3ldUx@uzv~o zSzu;fKU~H`7PDg$jb)r6Z@5e&5^F!jNfhm+{wU1RU6Aq>rNCi+Xb|7NUs1l$7?nb$ z$YY4LgM1G|?W;JI!POhX-ImB#-fY$~u&z~|UFWP@Bc5&FKPNPp*7w__CZTn(+;kZZcWyBom@!JlYNimIsb+)z|N z^w`Uaw$vZFur{}fK2Os1l`eostX^Bq)R z0(I_*Txv;|#g}|Bov(_kNrh2-STT0w7|M~fYy||01SK%~97Q0<$ESGzJvnJLH8W#m zMZjT^CgmK+h3m`sP=C%NwbnWW9BTz}D8cLbRSDRKcRLzlun6i(u_aKe!y{{Gu$=6x z8+hRDg*dcxqMUx6m5ZCuHGpO)Y8%%9Y*(ZjH(~PK$f{@9ISGg^(GmQ zMw&@E47IryKG`u4y7119l?9t2htan(5z~_$U_(wn+sjEOjDMe?-Hho-`Y%|aHiv8> ztc$&+G(6vJ?<=+=$8RNaT6_^D09WX#p2#a>i%W39WTI|HnA#y437MWDg;S}v9FDgN zT4<6!o1kf?s7N`y&Q>cRP>XkPtmf6)-fCv-W2>0q!V$YD%Io?+-5%WS3Pri{;ff>; z!X~v%if0Lp2!D1g0AeV9%x}n^jMDxhJ|dK1oMWvj#Awg6^$Vb;Su?+&ZTuELNg1ZE z;c$O1_{Ur!&8&Ajn= zY-WxhK9=6|`wi`Ze28mm`h(zOZek97a8V5az(hpW3+RW5dAuWey2>$BlYbo)_x@DhXN_X;#�~clA=3ATf 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 diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 247d951e1ee..ba588fc779d 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 247d951e1ee6c22705be591867225f3df954869b +Subproject commit ba588fc779d34a2fbf7cc9a23103c38e3e3e0356 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz index ee1f948129721ac2f56aeaed1d72bf84ff71ae1b..83efff860019c98f65e17c0be02770263ce1112b 100644 GIT binary patch delta 16 XcmX>va$ba8zMF$XSL5va$ba8zMF%?dG+ay>>gYIFJc8t diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index 4a0cb5d16e79224b8e5ea8210e523288dab0c93e..aa8a5cc85170da70f52c2bda3d694f55d5f9dc59 100644 GIT binary patch delta 16 XcmbQkHHV8`zMF$XSL5kwm?@8;mp)wsNoosk;=BR2!; delta 16 XcmeAW>kwm?@8;leUVVBaJ0mv$Cc6ZC diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index 0fd2a7754eac377708ebfb77434c3cf51ad1e839..edfb67103e0f9cc3f2cc7f7528f44eed4ebdef48 100644 GIT binary patch delta 16 Xcmca2dPS67zMF$XSL5}RVFk diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz index 650dfe9f1e7291ff249b2ba8cf3a573a88344adb..974200ba1f7cd466eb528561fcae0109777158da 100644 GIT binary patch delta 16 XcmbQtJeiqYzMF$XSL5^+PCCjA8L diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index 3512f61878c2df1a9e061c677facc4c3ff30cb22..845f8a31b188913b1c055ef643af2e879b3010af 100644 GIT binary patch delta 16 XcmdmBxxtcMzMF$XSL5?>seFrWp> diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index d0538f72d06b73d091c342b32904c81cc9c1d8ad..e1700035dbdbf9888c5e4ced486305e8d54972c0 100644 GIT binary patch delta 18 acmbPmooT{#CU*I54h~(7%NyA{R|5b)WCo}J delta 18 acmbPmooT{#CU*I54i4wlr#G^9t_A=?=m$9f diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 1ec7b9be402..e9ac4725af5 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","6d087a0b1d68afd80fdf2bdebfb41182"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-1fd10c1fcdf56a61f60cf861d5a0368c.js","800ebb1bbb48274790f2ee1a2e53a24c"],["/static/frontend-1903f9cc2ad4ed725c81f544e53d2ee4.html","5fa014ebc7b1b36133b141f8f01a95f8"],["/static/mdi-710b84acc99b32514f52291aba9cd8e8.html","149c8eaf6bb78a9b642c7bcedab86900"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e["delete"](a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))})["catch"](function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;amPn!n zk2z0L6^0QHxnQD00rdc5pon6Mk~B?amV(F=MpQ<1%wiE8*Hcuw9)%Gwr771TgM?_U zCD)JwAsNpZP4bPJ6!I`vGEJD0L=wQaT!{#Hn(#=F{J5GHn#)4MNQhX*S&B#`c?3kO zh)Lialu9%toNqN{Oh#EkWDFT@G>uqD1<#dMG0QZ2N|WIRMo~htkR>_FiIXIXWW)pw z^9ZeG8IY|WmZWhe(ui_N1p)`T)Kaj>ZZS*KCxA1sSe8~5;Uv#h6lZx7CZIwRW;}#k z6GoyWLlGKkA(3Xj>@LNJRV+eA8Ofq37fi7{)LE~=0#wMctQ#0njS(ZMmP*G7PpC%3b(TG{Dwbq`;0TE^CHAB*R53~^5n05g%yZmhOe2hOmQ#-LAyo$1 zMlg+XbST%DYAFV?NHEq?AwhAGB{}JnZ(}tY-Hw}D)TSPP>>i_^>rw^AXcVPFmSZZ4 zFiMhCqaS%5M~rdK6%`~2e%T4C$<1wz9wFfFg4xe&9pH9U-1MirGb3o*tp zJuwo{lsqJmP|kOyU4fAGVp&&MecMr47$o+h>eE0Fj8m>KZ&Qq{h$t*jm~t9{$yFw( zbAIl39Po}WVCuA2qYWvHF4<`0oVYENI%r^4Ac1rY^WtD6Xl0E=>^PoZ%qq-L>_k5B z1|=G#u(up}5UFQ+HvVm878m1lpDXnX60)Vw>(`Zn->3IIhUH~TPJC)<@XN*=5xR5 zHOfPcgK9XGJCd+tvZP*lx3{hgT4Y)|KbdY{UpPa@!f*nWtQCCu`1;kxF|P4WoT>BJ z7w&n!+%vamKj2=z&e4@6q73Q*K81E|qXO%;#OsZHMkf2f8E=cpWZT-c^#UGi>G*nY zU3cKyZejVlbEew!<{$GEO6w7sZeG^DqF&Hq|6*K3$~)eFuIEf0+l1<(M9q&)Gy%38 z&Gr2!v-$K4D;Kobpq`O)uWMzG>iSj})kanSj;1|`pE+;m9R_`xTi}tZ;XXh!xa8#u@EG&JgS^WEbTh(WGid8h+@ot@{0RS9 z*$CkN4ML}I*99-zQ>R7yN7ydUipoJ?x}SrS&dz=iw)T6m{ECS5!C)8oN8q&Qm`ili zKAyd&H)p1!8=U+fxF0#}@ki0-PeEzVe)n*mIsKu=Imc;#1S?qP|@e zP@y5t-+%q=bk(itT^RJIq&1G1EJurzd%p&E@e~<(6El72h(40K8d^;;Gmw}1wruhY z4|kR2nCSs?xt2csW5RlAa*E8=36aC0Q(@aMvoW&2M`VTJ@SyO!XU=b~`{J3~bNwwI zCU0$3C~w!NlZ&gm`3?=T{q2HRg|@Qnu@Ljc`|Dq@P1u8^Bg$=+kq@fOZq+!tBj4Cl za04x}m-jPdU)8HpRsVi&1{Mw& z^k%g%&^4Zbe`=k9tto@fs?)(w@1O6(fef}jrf-M4h&>*%%T+snB)KR3nIF-`ioU*0 zXl-uzyE*+x<20|Y-tAmnxJX-tDJ``g;&*s|(%pYJ7>-a}f^yc^B=%-xeUoAz_Il+0 zM#Ot6A$M<4Fp=#Q#o<m5#3E zs>R|ks5UjS3c4}Zo8XF#88#WdcB(hi4!^j6&K-!~zB`_D1kUdKaOX|_2ZUoqpg9x( E0AT=X3IG5A literal 2279 zcmV)QU``#?&vWLeJcbS8-yka)-Uj_(1^s)a#o znnD_9qF0qPMO_VAFz@v%F6W@WG-d08chi7bHPrxu?{M9^USQ@>xtk4pA1wIt@fWM6 zyz}mkZ>`{J@CgjA;AdM~s@fE~klg;~*Gs6(D_)j@%kM7u#=Fr)10PoHe7kzXyM3o7 z=pTH=4afiO$_eb6Z}{xtIxhLOgqrsS)`x@1L*H&hfk){>3i`F zr0LP?UwJ_Z)y&L?!}X`Kt}qfSY3gRXVL03q=W8%>?mA<>EXJLA*TLK@%6Kf-HaPyx z0?fQtr_P7>pFTVO9Jqp}J+&o$8u24u* z{I^>>b$$lkKr?Vo%$?_pT3w(1^V9oxfz6z1hF0`JyX4Ja*?_c}gBo-(o1UFHW5;(A zMY5C=K~<7*tumsOWZUVGjhDQFvK=e99D$AbD3mhGD2I%&L=r7{ z%z2WkFpPM}1rr?#s0SDWMHEw%q-iR%6hxjdqB5#u7K`Y(o}$wAD2#w9O}P#kBt&a1 zxrP)7$#~9al5fi9_Ml7U)=Sr)XWtu&u$#4UsC?Q$Mk{sp4NfJdeVuFTw zgjTZ*$W{+a(l`@oM7g8_frDIXDOhA(%+mA;;0!F5rBy{Z$#WIOS)POmsE~vi4r|15Yy44hl;$$#gwRCDOhrni zYNH_`A;mC?NU>1GB4m`2EQ)f$6w5=M^%^Wdg&fPeff3c1F_LPjbe!;nYD8RT300^e zOfngKYf)}S2na!i5K)GqNOPhY0Obtgkh4hc$=x_GqB#k5jvjIqfl65{Z8k;`Zc?~C z+E^WHPG|%|rl=xJXc!6_QJrZ*c^*HodZCI@N{PrKE@hshk1>rf$5~D}=7&@nWE;UW z$}yl^Cqkr{$Rfd9ON9i*MV91*bb>clqtWfSsYPw-@yG6G^mARRz!;69RLIbiOoUOA zq#EOhmPL$l&J`6T34bK&O);CBwX9UcT4b4~OaMX>g&N^yRLGDqj9By|S#LoVtHoNF z1{y*{QGUITOgc%FR(1Hp}7#VwKY79SVk3NSG`7g zsBusYhjK>}PE3~6EARH!l|hS4E9WQE?c)n)=vWv|ppvzMFCSmO+62Zm-ib4H9(%(* z&zF1V7VQW0``6c>Z01H>YvfH2l+GS?YzUFFO!R%I&pj)mfO$uCb`-U zjkl<6f4n*{z(QxH)v{2(EZxzEq|t-OhtH!1}J-ZKF7TFgQGpJot1Q zp}~iyUKA|^sNQ|qH2gY18eX^$&CgO#E_U?wZ9;2n z!{4pxM+T>Pef4hV>cU0ZDokmq^$@?q`;+ee!@+cf;u4gzz9z9ZBkP+K`(v+1?r%iA zrwVfS76l90UQryndi<8h>GoJdz&gHnCnL+c1@~aNN9~bC8=f_skG|{EaHn6c`%afz zFR{t3!MVaZuCJWO?y|bs#jhrqD_9g{!vD4zs3!d6#KTrpAun^}WvdXY6;(RAma7(< z!=T#q$SUa8TyKIqHfGpj_}Z!7e0KQ8{d3_!{Px4~WFYYCE)I9z^24fHdqDa From de150ecbc9272e8de82cf5ed6c7ec53e769cb5a3 Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Sat, 10 Sep 2016 15:36:55 +0100 Subject: [PATCH 192/208] Hotfix for #3100 (#3302) --- homeassistant/components/notify/__init__.py | 19 ++++++----- tests/components/notify/test_demo.py | 35 +++++++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index d7ebfbbcd1f..9fb6ca1f842 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -91,19 +91,22 @@ def setup(hass, config): def notify_message(notify_service, call): """Handle sending notification message service calls.""" + kwargs = {} message = call.data[ATTR_MESSAGE] + title = call.data.get(ATTR_TITLE) + + if title: + kwargs[ATTR_TITLE] = template.render(hass, title) - title = template.render( - hass, call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)) if targets.get(call.service) is not None: - target = targets[call.service] + kwargs[ATTR_TARGET] = targets[call.service] else: - target = call.data.get(ATTR_TARGET) - message = template.render(hass, message) - data = call.data.get(ATTR_DATA) + kwargs[ATTR_TARGET] = call.data.get(ATTR_TARGET) - notify_service.send_message(message, title=title, target=target, - data=data) + kwargs[ATTR_MESSAGE] = template.render(hass, message) + kwargs[ATTR_DATA] = call.data.get(ATTR_DATA) + + notify_service.send_message(**kwargs) service_call_handler = partial(notify_message, notify_service) diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index f0a05a01c1f..6f0daeaf7b8 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -68,7 +68,7 @@ class TestNotifyDemo(unittest.TestCase): 'data': {'hello': 'world'} } == data - def test_calling_notify_from_script_loaded_from_yaml(self): + def test_calling_notify_from_script_loaded_from_yaml_without_title(self): """Test if we can call a notify from a script.""" yaml_conf = """ service: notify.notify @@ -92,7 +92,38 @@ data_template: assert { 'message': 'Test 123 4', 'target': None, - 'title': 'Home Assistant', + 'data': { + 'push': { + 'sound': + 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'}} + } == self.events[0].data + + def test_calling_notify_from_script_loaded_from_yaml_with_title(self): + """Test if we can call a notify from a script.""" + yaml_conf = """ +service: notify.notify +data: + data: + push: + sound: US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav +data_template: + title: Test + message: > + Test 123 {{ 2 + 2 }} +""" + + with tempfile.NamedTemporaryFile() as fp: + fp.write(yaml_conf.encode('utf-8')) + fp.flush() + conf = yaml.load_yaml(fp.name) + + script.call_from_config(self.hass, conf) + self.hass.pool.block_till_done() + self.assertTrue(len(self.events) == 1) + assert { + 'message': 'Test 123 4', + 'title': 'Test', + 'target': None, 'data': { 'push': { 'sound': From 843800194246b69564548cd8428023169bb299d2 Mon Sep 17 00:00:00 2001 From: Mal Curtis Date: Sun, 11 Sep 2016 03:08:51 +1200 Subject: [PATCH 193/208] Fix TP-Link Archer C7 long passwords (#3225) * Fix tplink C7 long passwords Fixes an issue where passwords longer than 15 chars could not log in to Archer C7 routers. * Truncate in correct place * Add comment about TP-Link C7 pass truncation * Fix lint error * Truncate comment at 79 chars not 80 --- homeassistant/components/device_tracker/tplink.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 17beab02532..ad295099bf5 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -277,8 +277,10 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): _LOGGER.info("Retrieving auth tokens...") url = 'http://{}/userRpm/LoginRpm.htm?Save=Save'.format(self.host) - # Generate md5 hash of password - password = hashlib.md5(self.password.encode('utf')).hexdigest() + # Generate md5 hash of password. The C7 appears to use the first 15 + # characters of the password only, so we truncate to remove additional + # characters from being hashed. + password = hashlib.md5(self.password.encode('utf')[:15]).hexdigest() credentials = '{}:{}'.format(self.username, password).encode('utf') # Encode the credentials to be sent as a cookie. From 54a17f5d98f984a3331db53425a06053c2acead1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Sandstr=C3=B6m?= Date: Sat, 10 Sep 2016 17:17:28 +0200 Subject: [PATCH 194/208] modbus write registers service (#3252) --- homeassistant/components/modbus.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 4aab9ddc756..b0391f9ba45 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -87,11 +87,20 @@ def setup(hass, config): hass.services.register(DOMAIN, SERVICE_WRITE_REGISTER, write_register) def write_register(service): - """Write modbus register.""" + """Write modbus registers.""" unit = int(float(service.data.get(ATTR_UNIT))) address = int(float(service.data.get(ATTR_ADDRESS))) - value = int(float(service.data.get(ATTR_VALUE))) - HUB.write_register(unit, address, value) + value = service.data.get(ATTR_VALUE) + if isinstance(value, list): + HUB.write_registers( + unit, + address, + [int(float(i)) for i in value]) + else: + HUB.write_register( + unit, + address, + int(float(value))) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) @@ -147,3 +156,11 @@ class ModbusHub(object): address, value, unit=unit) + + def write_registers(self, unit, address, values): + """Write registers.""" + with self._lock: + self._client.write_registers( + address, + values, + unit=unit) From b8251b084a19fcf0e75764368451bd0d5b936ff6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 10 Sep 2016 09:12:24 -0700 Subject: [PATCH 195/208] Fix bloomsky platform discovery (#3303) --- homeassistant/components/binary_sensor/bloomsky.py | 3 ++- homeassistant/components/sensor/bloomsky.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index 6e958dcd0ad..2419d6f766e 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -33,7 +33,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available BloomSky weather binary sensors.""" bloomsky = get_component('bloomsky') - sensors = config.get(CONF_MONITORED_CONDITIONS) + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) for device in bloomsky.BLOOMSKY.devices.values(): for variable in sensors: diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index b8f5e7e8470..1026e2a92db 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -46,7 +46,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available BloomSky weather sensors.""" bloomsky = get_component('bloomsky') - sensors = config.get(CONF_MONITORED_CONDITIONS) + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) for device in bloomsky.BLOOMSKY.devices.values(): for variable in sensors: From e8f8ea080b18d92a141a8d1492eec5517dd59951 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 10 Sep 2016 18:13:27 -0700 Subject: [PATCH 196/208] Remove dev tag --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4812f2f87e1..eb8b65df998 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 28 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) From 5966c46a679c746a6a762183dd2918e05838265a Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Sun, 11 Sep 2016 03:07:13 -0400 Subject: [PATCH 197/208] Fixed voluptuous to accept string instead positive_int for CODE on Simplisafe (#3310) --- homeassistant/components/alarm_control_panel/simplisafe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 82927246ec6..38128489ba0 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -26,7 +26,7 @@ DEFAULT_NAME = 'SimpliSafe' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) From 784cf0c4bd877af5696356ab4339503cc2ab25de Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Mon, 12 Sep 2016 06:46:14 +0200 Subject: [PATCH 198/208] Revert only add 1 device (#3324) --- homeassistant/components/climate/zwave.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 0ba85105c18..6c08bf391d8 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -70,8 +70,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] value = node.values[discovery_info[ATTR_VALUE_ID]] value.set_change_verified(False) - if value.index != 1: # Only add 1 device - return add_devices([ZWaveClimate(value, temp_unit)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", discovery_info, zwave.NETWORK) From a6673f67413c2e76d5902315e4a1e402ffb0cf42 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 11 Sep 2016 22:53:05 -0600 Subject: [PATCH 199/208] Automatic Device Tracker Bug Fix (#3330) * Iterate over items * Pass display name as host name --- homeassistant/components/device_tracker/__init__.py | 2 +- homeassistant/components/device_tracker/automatic.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 4247213087b..236fde6fb3f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -338,7 +338,7 @@ class Device(Entity): attr[ATTR_BATTERY] = self.battery if self.attributes: - for key, value in self.attributes: + for key, value in self.attributes.items(): attr[key] = value return attr diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 927c515b3a5..7855323ba06 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -142,6 +142,7 @@ class AutomaticDeviceScanner(object): for vehicle in self.last_results: dev_id = vehicle.get('id') + host_name = vehicle.get('display_name') attrs = { 'fuel_level': vehicle.get('fuel_level_percent') @@ -149,6 +150,7 @@ class AutomaticDeviceScanner(object): kwargs = { 'dev_id': dev_id, + 'host_name': host_name, 'mac': dev_id, ATTR_ATTRIBUTES: attrs } From 287f9c9bda567abeb5ed45d08df3eccabde13d4e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Sep 2016 22:25:01 -0700 Subject: [PATCH 200/208] Bugfix group order (#3323) * Add ordered dict config validator * Have group component use ordered dict config validator * Improve config_validation testing * update doc string config_validation.ordered_dict * validate full dict entries * Further simplify ordered_dict validator. * Lint fix --- homeassistant/components/group.py | 4 +- homeassistant/helpers/config_validation.py | 22 ++++++++++ tests/components/test_group.py | 17 +++++--- tests/helpers/test_config_validation.py | 50 ++++++++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index c4cd177925d..41901d87e86 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -46,12 +46,12 @@ def _conf_preprocess(value): CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {cv.match_all: vol.Schema(vol.All(_conf_preprocess, { + DOMAIN: cv.ordered_dict(vol.All(_conf_preprocess, { vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), CONF_VIEW: cv.boolean, CONF_NAME: cv.string, CONF_ICON: cv.icon, - }))} + }, cv.match_all)) }, extra=vol.ALLOW_EXTRA) # List of ON/OFF state tuples for groupable states diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1be157c789d..009736024a1 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,4 +1,5 @@ """Helpers for config validation using voluptuous.""" +from collections import OrderedDict from datetime import timedelta import os from urllib.parse import urlparse @@ -290,6 +291,27 @@ def url(value: Any) -> str: raise vol.Invalid('invalid url') +def ordered_dict(value_validator, key_validator=match_all): + """Validate an ordered dict validator that maintains ordering. + + value_validator will be applied to each value of the dictionary. + key_validator (optional) will be applied to each key of the dictionary. + """ + item_validator = vol.Schema({key_validator: value_validator}) + + def validator(value): + """Validate ordered dict.""" + config = OrderedDict() + + for key, val in value.items(): + v_res = item_validator({key: val}) + config.update(v_res) + + return config + + return validator + + # Validator helpers def key_dependency(key, dependency): diff --git a/tests/components/test_group.py b/tests/components/test_group.py index e82190a3f29..6c601a411fb 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -1,5 +1,6 @@ """The tests for the Group components.""" # pylint: disable=protected-access,too-many-public-methods +from collections import OrderedDict import unittest from unittest.mock import patch @@ -220,16 +221,16 @@ class TestComponentsGroup(unittest.TestCase): test_group = group.Group( self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False) - _setup_component(self.hass, 'group', {'group': { - 'second_group': { + group_conf = OrderedDict() + group_conf['second_group'] = { 'entities': 'light.Bowl, ' + test_group.entity_id, 'icon': 'mdi:work', 'view': True, - }, - 'test_group': 'hello.world,sensor.happy', - 'empty_group': {'name': 'Empty Group', 'entities': None}, - } - }) + } + group_conf['test_group'] = 'hello.world,sensor.happy' + group_conf['empty_group'] = {'name': 'Empty Group', 'entities': None} + + _setup_component(self.hass, 'group', {'group': group_conf}) group_state = self.hass.states.get( group.ENTITY_ID_FORMAT.format('second_group')) @@ -241,6 +242,7 @@ class TestComponentsGroup(unittest.TestCase): group_state.attributes.get(ATTR_ICON)) self.assertTrue(group_state.attributes.get(group.ATTR_VIEW)) self.assertTrue(group_state.attributes.get(ATTR_HIDDEN)) + self.assertEqual(1, group_state.attributes.get(group.ATTR_ORDER)) group_state = self.hass.states.get( group.ENTITY_ID_FORMAT.format('test_group')) @@ -251,6 +253,7 @@ class TestComponentsGroup(unittest.TestCase): self.assertIsNone(group_state.attributes.get(ATTR_ICON)) self.assertIsNone(group_state.attributes.get(group.ATTR_VIEW)) self.assertIsNone(group_state.attributes.get(ATTR_HIDDEN)) + self.assertEqual(2, group_state.attributes.get(group.ATTR_ORDER)) def test_groups_get_unique_names(self): """Two groups with same name should both have a unique entity id.""" diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index d9da2c51da7..60b14757378 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1,3 +1,5 @@ +"""Test config validators.""" +from collections import OrderedDict from datetime import timedelta import os import tempfile @@ -367,3 +369,51 @@ def test_has_at_least_one_key(): for value in ({'beer': None}, {'soda': None}): schema(value) + + +def test_ordered_dict_order(): + """Test ordered_dict validator.""" + schema = vol.Schema(cv.ordered_dict(int, cv.string)) + + val = OrderedDict() + val['first'] = 1 + val['second'] = 2 + + validated = schema(val) + + assert isinstance(validated, OrderedDict) + assert ['first', 'second'] == list(validated.keys()) + + +def test_ordered_dict_key_validator(): + """Test ordered_dict key validator.""" + schema = vol.Schema(cv.ordered_dict(cv.match_all, cv.string)) + + with pytest.raises(vol.Invalid): + schema({None: 1}) + + schema({'hello': 'world'}) + + schema = vol.Schema(cv.ordered_dict(cv.match_all, int)) + + with pytest.raises(vol.Invalid): + schema({'hello': 1}) + + schema({1: 'works'}) + + +def test_ordered_dict_value_validator(): + """Test ordered_dict validator.""" + schema = vol.Schema(cv.ordered_dict(cv.string)) + + with pytest.raises(vol.Invalid): + schema({'hello': None}) + + schema({'hello': 'world'}) + + schema = vol.Schema(cv.ordered_dict(int)) + + with pytest.raises(vol.Invalid): + schema({'hello': 'world'}) + + schema({'hello': 5}) From 9aff839925f92cd85eac8d5b803727a7907bc49d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Sep 2016 22:28:53 -0700 Subject: [PATCH 201/208] Version bump to 0.28.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eb8b65df998..797fd3108b9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 28 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) From e5af126fae17de0324912c2409175c5ab345f6ab Mon Sep 17 00:00:00 2001 From: David-Leon Pohl Date: Tue, 13 Sep 2016 03:28:11 +0200 Subject: [PATCH 202/208] Bugfix pilight component (#3355) * BUG Message data cannot be changed thus use voluptuous to ensure format * Pilight daemon expects JSON serializable data Thus dict is needed and not a mapping proxy. * Add explanation why dict as message data is needed * Use more obvious voluptuous validation scheme * Pylint: Trailing whitespace --- homeassistant/components/pilight.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 764b972d393..3475a6be65a 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -10,7 +10,6 @@ import socket import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import ensure_list from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT, CONF_WHITELIST) @@ -29,7 +28,10 @@ EVENT = 'pilight_received' # The pilight code schema depends on the protocol # Thus only require to have the protocol information -RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): cv.string}, +# Ensure that protocol is in a list otherwise segfault in pilight-daemon +# https://github.com/pilight/pilight/issues/296 +RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): + vol.All(cv.ensure_list, [cv.string])}, extra=vol.ALLOW_EXTRA) SERVICE_NAME = 'send' @@ -71,12 +73,9 @@ def setup(hass, config): def send_code(call): """Send RF code to the pilight-daemon.""" - message_data = call.data - - # Patch data because of bug: - # https://github.com/pilight/pilight/issues/296 - # Protocol has to be in a list otherwise segfault in pilight-daemon - message_data['protocol'] = ensure_list(message_data['protocol']) + # Change type to dict from mappingproxy + # since data has to be JSON serializable + message_data = dict(call.data) try: pilight_client.send_code(message_data) From b7430d939dcce990e0a0dc22e65b42a97cf1ee76 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 13 Sep 2016 03:21:35 +0200 Subject: [PATCH 203/208] Bugfix voluptuous on recorder (#3350) --- homeassistant/components/recorder/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5e4415d81a0..8f373700165 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, QueryType import homeassistant.util.dt as dt_util @@ -40,10 +41,9 @@ QUERY_RETRY_WAIT = 0.1 CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_PURGE_DAYS): vol.All(vol.Coerce(int), - vol.Range(min=1)), - # pylint: disable=no-value-for-parameter - vol.Optional(CONF_DB_URL): vol.Url(), + vol.Optional(CONF_PURGE_DAYS): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_DB_URL): cv.string, }) }, extra=vol.ALLOW_EXTRA) From 329474d3e3aa32303f209a657884db7a80793cfb Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Tue, 13 Sep 2016 03:23:18 +0200 Subject: [PATCH 204/208] Missing garage door detection (#3349) --- homeassistant/components/cover/wink.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 9b76e234303..59676b1f6a7 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -28,8 +28,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pywink.set_bearer_token(token) - add_devices(WinkCoverDevice(shade) for shade, door in + add_devices(WinkCoverDevice(shade) for shade in pywink.get_shades()) + add_devices(WinkCoverDevice(door) for door in + pywink.get_garage_doors()) class WinkCoverDevice(WinkDevice, CoverDevice): From ad7683470a71387b881f1b4d026779724ffe5c78 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Mon, 12 Sep 2016 17:40:46 +0200 Subject: [PATCH 205/208] Bugfix ecobee: inverted high and low temps and enforce int to temps (#3325) * inverted high and low temps * Looks like somethings are mixed up * Added debugentires * Added debugentires 2 * Enforce int on temperatures --- homeassistant/components/climate/ecobee.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 5d78aeb8597..21a2c89c31a 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -116,9 +116,6 @@ class Thermostat(ClimateDevice): return self.target_temperature_low elif self.operation_mode == 'cool': return self.target_temperature_high - else: - return (self.target_temperature_low + - self.target_temperature_high) / 2 @property def target_temperature_low(self): @@ -223,19 +220,27 @@ class Thermostat(ClimateDevice): """Set new target temperature.""" if kwargs.get(ATTR_TEMPERATURE) is not None: temperature = kwargs.get(ATTR_TEMPERATURE) - low_temp = temperature - 1 - high_temp = temperature + 1 + low_temp = int(temperature) + high_temp = int(temperature) if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None and \ kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None: - low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) - high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + high_temp = int(kwargs.get(ATTR_TARGET_TEMP_LOW)) + low_temp = int(kwargs.get(ATTR_TARGET_TEMP_HIGH)) if self.hold_temp: self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, high_temp, "indefinite") + _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " + "high=%s, is=%s", low_temp, isinstance( + low_temp, (int, float)), high_temp, + isinstance(high_temp, (int, float))) else: self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, high_temp) + _LOGGER.debug("Setting ecobee temp to: low=%s, is=%s, " + "high=%s, is=%s", low_temp, isinstance( + low_temp, (int, float)), high_temp, + isinstance(high_temp, (int, float))) def set_operation_mode(self, operation_mode): """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" From bc9d2586c6dffa2cf2bccc5eb4c4e013a389d0ab Mon Sep 17 00:00:00 2001 From: Nick Vella Date: Tue, 13 Sep 2016 11:31:44 +1000 Subject: [PATCH 206/208] Add open/closed state for open_cover and close_cover in SERVICE_TO_STATE (#3180) * Add open/closed state mapping for open_cover and close_cover * Add 'open', 'closed' for open/close_cover_tilt * Revert "Add 'open', 'closed' for open/close_cover_tilt" This reverts commit e45582d4394a33feedfce190a1dba96473d24825. --- homeassistant/helpers/state.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 9c6e797acd1..4935251db7d 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -29,10 +29,10 @@ from homeassistant.const import ( SERVICE_CLOSE, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_MOVE_DOWN, SERVICE_MOVE_UP, SERVICE_OPEN, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, - SERVICE_VOLUME_SET, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_LOCKED, - STATE_OFF, STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, STATE_UNLOCKED) + SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_LOCKED, STATE_OFF, STATE_ON, + STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_UNLOCKED) from homeassistant.core import State _LOGGER = logging.getLogger(__name__) @@ -77,6 +77,8 @@ SERVICE_TO_STATE = { SERVICE_OPEN: STATE_OPEN, SERVICE_MOVE_UP: STATE_OPEN, SERVICE_MOVE_DOWN: STATE_CLOSED, + SERVICE_OPEN_COVER: STATE_OPEN, + SERVICE_CLOSE_COVER: STATE_CLOSED } From 5fd93e8d80107d06c70fbdbe647bb456f9cacc84 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 12 Sep 2016 18:33:36 -0700 Subject: [PATCH 207/208] Version bump to 0.28.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 797fd3108b9..392a10aedb9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 28 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) From 380993f2cad25eff87802ed02085254fc8c21771 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 12 Sep 2016 20:59:34 -0600 Subject: [PATCH 208/208] Automatic polling (#3360) * Test updating automatic * Scan interval * Schedule scan every time delta * Pass around has * Recursive issue * Method invocation * Oops * Set up poll * Default argument value * Unused import * Semicolon * Fix tests * Linting * Unneeded throttle as it's handled by time event * Use track time change event listener * Disable lint rule * Attribute removed - removing test * Debug instead of info * Unused import --- .../components/device_tracker/automatic.py | 31 +++++++------------ .../device_tracker/test_automatic.py | 26 ++++------------ 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 7855323ba06..27bd9c6b477 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -15,12 +15,11 @@ from homeassistant.components.device_tracker import (PLATFORM_SCHEMA, ATTR_ATTRIBUTES) from homeassistant.const import CONF_USERNAME, CONF_PASSWORD import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, datetime as dt_util +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import datetime as dt_util _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - CONF_CLIENT_ID = 'client_id' CONF_SECRET = 'secret' CONF_DEVICES = 'devices' @@ -53,7 +52,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_scanner(hass, config: dict, see): """Validate the configuration and return an Automatic scanner.""" try: - AutomaticDeviceScanner(config, see) + AutomaticDeviceScanner(hass, config, see) except requests.HTTPError as err: _LOGGER.error(str(err)) return False @@ -61,11 +60,14 @@ def setup_scanner(hass, config: dict, see): return True +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-few-public-methods class AutomaticDeviceScanner(object): """A class representing an Automatic device.""" - def __init__(self, config: dict, see) -> None: + def __init__(self, hass, config: dict, see) -> None: """Initialize the automatic device scanner.""" + self.hass = hass self._devices = config.get(CONF_DEVICES, None) self._access_token_payload = { 'username': config.get(CONF_USERNAME), @@ -81,20 +83,10 @@ class AutomaticDeviceScanner(object): self.last_trips = {} self.see = see - self.scan_devices() - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [item['id'] for item in self.last_results] - - def get_device_name(self, device): - """Get the device name from id.""" - vehicle = [item['display_name'] for item in self.last_results - if item['id'] == device] - - return vehicle[0] + track_utc_time_change(self.hass, self._update_info, + second=range(0, 60, 30)) def _update_headers(self): """Get the access token from automatic.""" @@ -114,10 +106,9 @@ class AutomaticDeviceScanner(object): 'Authorization': 'Bearer {}'.format(access_token) } - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self) -> None: + def _update_info(self, now=None) -> None: """Update the device info.""" - _LOGGER.info('Updating devices') + _LOGGER.debug('Updating devices %s', now) self._update_headers() response = requests.get(URL_VEHICLES, headers=self._headers) diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index e026d91a43c..2e476ac742d 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -6,8 +6,9 @@ import unittest from unittest.mock import patch from homeassistant.components.device_tracker.automatic import ( - URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner, - AutomaticDeviceScanner) + URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner) + +from tests.common import get_test_home_assistant _LOGGER = logging.getLogger(__name__) @@ -205,6 +206,7 @@ class TestAutomatic(unittest.TestCase): def setUp(self): """Set up test data.""" + self.hass = get_test_home_assistant() def tearDown(self): """Tear down test data.""" @@ -221,7 +223,7 @@ class TestAutomatic(unittest.TestCase): 'secret': CLIENT_SECRET } - self.assertFalse(setup_scanner(None, config, self.see_mock)) + self.assertFalse(setup_scanner(self.hass, config, self.see_mock)) @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) @@ -235,20 +237,4 @@ class TestAutomatic(unittest.TestCase): 'secret': CLIENT_SECRET } - self.assertTrue(setup_scanner(None, config, self.see_mock)) - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_device_attributes(self, mock_get, mock_post): - """Test device attributes are set on load.""" - config = { - 'platform': 'automatic', - 'username': VALID_USERNAME, - 'password': PASSWORD, - 'client_id': CLIENT_ID, - 'secret': CLIENT_SECRET - } - - scanner = AutomaticDeviceScanner(config, self.see_mock) - - self.assertEqual(DISPLAY_NAME, scanner.get_device_name('vid')) + self.assertTrue(setup_scanner(self.hass, config, self.see_mock))