diff --git a/.coveragerc b/.coveragerc index 7ebab01d399..1ff145e2de3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -49,6 +49,7 @@ omit = homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py + homeassistant/components/media_player/plex.py homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/sonos.py homeassistant/components/notify/file.py @@ -69,6 +70,7 @@ omit = homeassistant/components/sensor/glances.py homeassistant/components/sensor/mysensors.py homeassistant/components/sensor/openweathermap.py + homeassistant/components/sensor/rest.py homeassistant/components/sensor/rfxtrx.py homeassistant/components/sensor/rpi_gpio.py homeassistant/components/sensor/sabnzbd.py @@ -77,6 +79,7 @@ omit = homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/transmission.py + homeassistant/components/sensor/worldclock.py homeassistant/components/switch/arest.py homeassistant/components/switch/command_switch.py homeassistant/components/switch/edimax.py diff --git a/.travis.yml b/.travis.yml index 4a4dfbc2354..da3516554ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ sudo: false language: python +cache: + directories: + - $HOME/virtualenv/python3.4.2/ python: - "3.4" install: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f2fd110a1d..f646766a231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ For help on building your component, please see the [developer documentation](ht After you finish adding support for your device: - Update the supported devices in the `README.md` file. - - Add any new dependencies to `requirements.txt`. + - Add any new dependencies to `requirements_all.txt`. There is no ordering right now, so just add it to the end. - Update the `.coveragerc` file. - Provide some documentation for [home-assistant.io](https://home-assistant.io/). The documentation is handled in a separate [git repository](https://github.com/balloob/home-assistant.io). - Make sure all your code passes Pylint and flake8 (PEP8 and some more) validation. To generate reports, run `pylint homeassistant > pylint.txt` and `flake8 homeassistant --exclude bower_components,external > flake8.txt`. diff --git a/MANIFEST.in b/MANIFEST.in index aae95799ac4..53d3a9e22a9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ recursive-exclude tests * +recursive-include homeassistant services.yaml diff --git a/config/configuration.yaml.example b/config/configuration.yaml.example index 5acca361a30..ae9959a193d 100644 --- a/config/configuration.yaml.example +++ b/config/configuration.yaml.example @@ -1,7 +1,9 @@ homeassistant: # Omitted values in this section will be auto detected using freegeoip.net - # Location required to calculate the time the sun rises and sets + # Location required to calculate the time the sun rises and sets. + # Cooridinates are also used for location for weather related components. + # Google Maps can be used to determine more precise GPS cooridinates. latitude: 32.87336 longitude: 117.22743 @@ -68,11 +70,18 @@ device_sun_light_trigger: # A comma separated list of states that have to be tracked as a single group # Grouped states should share the same type of states (ON/OFF or HOME/NOT_HOME) +# You can also have groups within groups. group: + Home: + - group.living_room + - group.kitchen living_room: - light.Bowl - light.Ceiling - light.TV_back_light + kitchen: + - light.fan_bulb_1 + - light.fan_bulb_2 children: - device_tracker.child_1 - device_tracker.child_2 @@ -94,28 +103,36 @@ browser: keyboard: automation: - platform: state - alias: Sun starts shining +- alias: 'Rule 1 Light on in the evening' + trigger: + - platform: sun + event: sunset + offset: "-01:00:00" + - platform: state + entity_id: group.all_devices + state: home + condition: + - platform: state + entity_id: group.all_devices + state: home + - platform: time + after: "16:00:00" + before: "23:00:00" + action: + service: homeassistant.turn_on + entity_id: group.living_room - state_entity_id: sun.sun - # Next two are optional, omit to match all - state_from: below_horizon - state_to: above_horizon +- alias: 'Rule 2 - Away Mode' - execute_service: light.turn_off - service_entity_id: group.living_room + trigger: + - platform: state + entity_id: group.all_devices + state: 'not_home' -automation 2: - platform: time - alias: Beer o Clock - - time_hours: 16 - time_minutes: 0 - time_seconds: 0 - - execute_service: notify.notify - service_data: - message: It's 4, time for beer! + condition: use_trigger_values + action: + service: light.turn_off + entity_id: group.all_lights sensor: platform: systemmonitor @@ -135,6 +152,23 @@ sensor: - type: 'process' arg: 'octave-cli' +sensor 2: + platform: forecast + api_key: + monitored_conditions: + - summary + - precip_type + - precip_intensity + - temperature + - dew_point + - wind_speed + - wind_bearing + - cloud_cover + - humidity + - pressure + - visibility + - ozone + script: # Turns on the bedroom lights and then the living room lights 1 minute later wakeup: diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index a7e4dbfdc14..daee13914fd 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -186,8 +186,8 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, dict, {key: value or {} for key, value in config.items()}) # Filter out the repeating and common config section [homeassistant] - components = (key for key in config.keys() - if ' ' not in key and key != core.DOMAIN) + components = set(key.split(' ')[0] for key in config.keys() + if key != core.DOMAIN) if not core_components.setup(hass, config): _LOGGER.error('Home Assistant core failed to initialize. ' @@ -297,11 +297,15 @@ def process_ha_core_config(hass, config): else: _LOGGER.error('Received invalid time zone %s', time_zone_str) - for key, attr in ((CONF_LATITUDE, 'latitude'), - (CONF_LONGITUDE, 'longitude'), - (CONF_NAME, 'location_name')): + for key, attr, typ in ((CONF_LATITUDE, 'latitude', float), + (CONF_LONGITUDE, 'longitude', float), + (CONF_NAME, 'location_name', str)): if key in config: - setattr(hac, attr, config[key]) + try: + setattr(hac, attr, typ(config[key])) + except ValueError: + _LOGGER.error('Received invalid %s value for %s: %s', + typ.__name__, key, attr) set_time_zone(config.get(CONF_TIME_ZONE)) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index bf68e35ffe3..b85305b6d18 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,15 +1,18 @@ """ homeassistant.components.alarm_control_panel -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Component to interface with a alarm control panel. """ import logging -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent +import os + from homeassistant.components import verisure from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY) +from homeassistant.config import load_yaml_config_file +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent DOMAIN = 'alarm_control_panel' DEPENDENCIES = [] @@ -29,9 +32,11 @@ SERVICE_TO_METHOD = { } ATTR_CODE = 'code' +ATTR_CODE_FORMAT = 'code_format' ATTR_TO_PROPERTY = [ ATTR_CODE, + ATTR_CODE_FORMAT ] @@ -57,8 +62,12 @@ def setup(hass, config): for alarm in target_alarms: getattr(alarm, method)(code) + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + for service in SERVICE_TO_METHOD: - hass.services.register(DOMAIN, service, alarm_service_handler) + hass.services.register(DOMAIN, service, alarm_service_handler, + descriptions.get(service)) return True @@ -93,16 +102,31 @@ def alarm_arm_away(hass, code, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) +# pylint: disable=no-self-use class AlarmControlPanel(Entity): """ ABC for alarm control devices. """ - def alarm_disarm(self, code): + + @property + def code_format(self): + """ regex for code format or None if no code is required. """ + return None + + def alarm_disarm(self, code=None): """ Send disarm command. """ raise NotImplementedError() - def alarm_arm_home(self, code): + def alarm_arm_home(self, code=None): """ Send arm home command. """ raise NotImplementedError() - def alarm_arm_away(self, code): + def alarm_arm_away(self, code=None): """ Send arm away command. """ raise NotImplementedError() + + @property + def state_attributes(self): + """ Return the state attributes. """ + state_attr = { + ATTR_CODE_FORMAT: self.code_format, + } + return state_attr diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py new file mode 100644 index 00000000000..c04c8ee6031 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -0,0 +1,167 @@ +""" +homeassistant.components.alarm_control_panel.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This platform enables the possibility to control a MQTT alarm. +In this platform, 'state_topic' and 'command_topic' are required. +The alarm will only change state after receiving the a new state +from 'state_topic'. If these messages are published with RETAIN flag, +the MQTT alarm will receive an instant state update after subscription +and will start with correct state. Otherwise, the initial state will +be 'unknown'. + +Configuration: + +alarm_control_panel: + platform: mqtt + name: "MQTT Alarm" + state_topic: "home/alarm" + command_topic: "home/alarm/set" + qos: 0 + payload_disarm: "DISARM" + payload_arm_home: "ARM_HOME" + payload_arm_away: "ARM_AWAY" + code: "mySecretCode" + +Variables: + +name +*Optional +The name of the alarm. Default is 'MQTT Alarm'. + +state_topic +*Required +The MQTT topic subscribed to receive state updates. + +command_topic +*Required +The MQTT topic to publish commands to change the alarm state. + +qos +*Optional +The maximum QoS level of the state topic. Default is 0. +This QoS will also be used to publishing messages. + +payload_disarm +*Optional +The payload do disarm alarm. Default is "DISARM". + +payload_arm_home +*Optional +The payload to set armed-home mode. Default is "ARM_HOME". + +payload_arm_away +*Optional +The payload to set armed-away mode. Default is "ARM_AWAY". + +code +*Optional +If defined, specifies a code to enable or disable the alarm in the frontend. +""" +import logging +import homeassistant.components.mqtt as mqtt +import homeassistant.components.alarm_control_panel as alarm + +from homeassistant.const import (STATE_UNKNOWN) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Alarm" +DEFAULT_QOS = 0 +DEFAULT_PAYLOAD_DISARM = "DISARM" +DEFAULT_PAYLOAD_ARM_HOME = "ARM_HOME" +DEFAULT_PAYLOAD_ARM_AWAY = "ARM_AWAY" + +DEPENDENCIES = ['mqtt'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the MQTT platform. """ + + if config.get('state_topic') is None: + _LOGGER.error("Missing required variable: state_topic") + return False + + if config.get('command_topic') is None: + _LOGGER.error("Missing required variable: command_topic") + return False + + add_devices([MqttAlarm( + hass, + config.get('name', DEFAULT_NAME), + config.get('state_topic'), + config.get('command_topic'), + config.get('qos', DEFAULT_QOS), + config.get('payload_disarm', DEFAULT_PAYLOAD_DISARM), + config.get('payload_arm_home', DEFAULT_PAYLOAD_ARM_HOME), + config.get('payload_arm_away', DEFAULT_PAYLOAD_ARM_AWAY), + config.get('code'))]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MqttAlarm(alarm.AlarmControlPanel): + """ represents a MQTT alarm status within home assistant. """ + + def __init__(self, hass, name, state_topic, command_topic, qos, + payload_disarm, payload_arm_home, payload_arm_away, code): + self._state = STATE_UNKNOWN + self._hass = hass + self._name = name + self._state_topic = state_topic + self._command_topic = command_topic + self._qos = qos + self._payload_disarm = payload_disarm + self._payload_arm_home = payload_arm_home + self._payload_arm_away = payload_arm_away + self._code = code + + def message_received(topic, payload, qos): + """ A new MQTT message has been received. """ + self._state = payload + self.update_ha_state() + + mqtt.subscribe(hass, self._state_topic, message_received, self._qos) + + @property + def should_poll(self): + """ No polling needed """ + return False + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def code_format(self): + """ One or more characters if code is defined """ + return None if self._code is None else '.+' + + def alarm_disarm(self, code=None): + """ Send disarm command. """ + if code == str(self._code) or self.code_format is None: + mqtt.publish(self.hass, self._command_topic, + self._payload_disarm, self._qos) + else: + _LOGGER.warning("Wrong code entered while disarming!") + + def alarm_arm_home(self, code=None): + """ Send arm home command. """ + if code == str(self._code) or self.code_format is None: + mqtt.publish(self.hass, self._command_topic, + self._payload_arm_home, self._qos) + else: + _LOGGER.warning("Wrong code entered while arming home!") + + def alarm_arm_away(self, code=None): + """ Send arm away command. """ + if code == str(self._code) or self.code_format is None: + mqtt.publish(self.hass, self._command_topic, + self._payload_arm_away, self._qos) + else: + _LOGGER.warning("Wrong code entered while arming away!") diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index f19cdc102d2..c7c24a60c4a 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -1,6 +1,6 @@ """ homeassistant.components.alarm_control_panel.verisure -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Interfaces with Verisure alarm control panel. """ import logging @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class VerisureAlarm(alarm.AlarmControlPanel): - """ represents a Verisure alarm status within home assistant. """ + """ Represents a Verisure alarm status. """ def __init__(self, alarm_status): self._id = alarm_status.id @@ -51,8 +51,13 @@ class VerisureAlarm(alarm.AlarmControlPanel): """ Returns the state of the device. """ return self._state + @property + def code_format(self): + """ Four digit code required. """ + return '^\\d{4}$' + def update(self): - ''' update alarm status ''' + """ Update alarm status """ verisure.update() if verisure.STATUS[self._device][self._id].status == 'unarmed': @@ -66,21 +71,21 @@ class VerisureAlarm(alarm.AlarmControlPanel): 'Unknown alarm state %s', verisure.STATUS[self._device][self._id].status) - def alarm_disarm(self, code): + def alarm_disarm(self, code=None): """ Send disarm command. """ verisure.MY_PAGES.set_alarm_status( code, verisure.MY_PAGES.ALARM_DISARMED) _LOGGER.warning('disarming') - def alarm_arm_home(self, code): + def alarm_arm_home(self, code=None): """ Send arm home command. """ verisure.MY_PAGES.set_alarm_status( code, verisure.MY_PAGES.ALARM_ARMED_HOME) _LOGGER.warning('arming home') - def alarm_arm_away(self, code): + def alarm_arm_away(self, code=None): """ Send arm away command. """ verisure.MY_PAGES.set_alarm_status( code, diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 108cc88741b..e4c794df424 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -103,6 +103,10 @@ def _handle_get_api_stream(handler, path_match, data): write_lock = threading.Lock() block = threading.Event() + restrict = data.get('restrict') + if restrict: + restrict = restrict.split(',') + def write_message(payload): """ Writes a message to the output. """ with write_lock: @@ -118,7 +122,8 @@ def _handle_get_api_stream(handler, path_match, data): """ Forwards events to the open request. """ nonlocal gracefully_closed - if block.is_set() or event.event_type == EVENT_TIME_CHANGED: + if block.is_set() or event.event_type == EVENT_TIME_CHANGED or \ + restrict and event.event_type not in restrict: return elif event.event_type == EVENT_HOMEASSISTANT_STOP: gracefully_closed = True diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py new file mode 100644 index 00000000000..f62aec8bf2a --- /dev/null +++ b/homeassistant/components/automation/zone.py @@ -0,0 +1,85 @@ +""" +homeassistant.components.automation.zone +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers zone automation rules. +""" +import logging + +from homeassistant.components import zone +from homeassistant.helpers.event import track_state_change +from homeassistant.const import ( + ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL) + + +CONF_ENTITY_ID = "entity_id" +CONF_ZONE = "zone" +CONF_EVENT = "event" +EVENT_ENTER = "enter" +EVENT_LEAVE = "leave" +DEFAULT_EVENT = EVENT_ENTER + + +def trigger(hass, config, action): + """ Listen for state changes based on `config`. """ + entity_id = config.get(CONF_ENTITY_ID) + zone_entity_id = config.get(CONF_ZONE) + + if entity_id is None or zone_entity_id is None: + logging.getLogger(__name__).error( + "Missing trigger configuration key %s or %s", CONF_ENTITY_ID, + CONF_ZONE) + return False + + event = config.get(CONF_EVENT, DEFAULT_EVENT) + + def zone_automation_listener(entity, from_s, to_s): + """ Listens for state changes and calls action. """ + if from_s and None in (from_s.attributes.get(ATTR_LATITUDE), + from_s.attributes.get(ATTR_LONGITUDE)) or \ + None in (to_s.attributes.get(ATTR_LATITUDE), + to_s.attributes.get(ATTR_LONGITUDE)): + return + + from_match = _in_zone(hass, zone_entity_id, from_s) if from_s else None + to_match = _in_zone(hass, zone_entity_id, to_s) + + if event == EVENT_ENTER and not from_match and to_match or \ + event == EVENT_LEAVE and from_match and not to_match: + action() + + track_state_change( + hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL) + + return True + + +def if_action(hass, config): + """ Wraps action method with zone based condition. """ + entity_id = config.get(CONF_ENTITY_ID) + zone_entity_id = config.get(CONF_ZONE) + + if entity_id is None or zone_entity_id is None: + logging.getLogger(__name__).error( + "Missing condition configuration key %s or %s", CONF_ENTITY_ID, + CONF_ZONE) + return False + + def if_in_zone(): + """ Test if condition. """ + return _in_zone(hass, zone_entity_id, hass.states.get(entity_id)) + + return if_in_zone + + +def _in_zone(hass, zone_entity_id, state): + """ Check if state is in zone. """ + if not state or None in (state.attributes.get(ATTR_LATITUDE), + state.attributes.get(ATTR_LONGITUDE)): + return False + + zone_state = hass.states.get(zone_entity_id) + return zone_state and zone.in_zone( + zone_state, state.attributes.get(ATTR_LATITUDE), + state.attributes.get(ATTR_LONGITUDE), + state.attributes.get(ATTR_GPS_ACCURACY, 0)) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index beb7a63b47c..f22135ec5bc 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -33,10 +33,10 @@ def setup(hass, config): # Setup sun if not hass.config.latitude: - hass.config.latitude = '32.87336' + hass.config.latitude = 32.87336 if not hass.config.longitude: - hass.config.longitude = '117.22743' + hass.config.longitude = 117.22743 bootstrap.setup_component(hass, 'sun') @@ -60,7 +60,7 @@ def setup(hass, config): {'camera': { 'platform': 'generic', 'name': 'IP Camera', - 'still_image_url': 'http://194.218.96.92/jpg/image.jpg', + 'still_image_url': 'http://home-assistant.io/demo/webcam.jpg', }}) # Setup scripts @@ -108,7 +108,9 @@ def setup(hass, config): "http://graph.facebook.com/297400035/picture", ATTR_FRIENDLY_NAME: 'Paulus'}) hass.states.set("device_tracker.anne_therese", "not_home", - {ATTR_FRIENDLY_NAME: 'Anne Therese'}) + {ATTR_FRIENDLY_NAME: 'Anne Therese', + 'latitude': hass.config.latitude + 0.002, + 'longitude': hass.config.longitude + 0.002}) hass.states.set("group.all_devices", "home", { diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 97c3d769715..55359b393ec 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -17,7 +17,12 @@ device_tracker: # New found devices auto found track_new_devices: yes + + # Maximum distance from home we consider people home + range_home: 100 """ +# pylint: disable=too-many-instance-attributes, too-many-arguments +# pylint: disable=too-many-locals import csv from datetime import timedelta import logging @@ -25,7 +30,7 @@ import os import threading from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.components import discovery, group +from homeassistant.components import discovery, group, zone from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform @@ -35,10 +40,11 @@ import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_utc_time_change from homeassistant.const import ( - ATTR_ENTITY_PICTURE, DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME) + ATTR_ENTITY_PICTURE, ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, + DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME) DOMAIN = "device_tracker" -DEPENDENCIES = [] +DEPENDENCIES = ['zone'] GROUP_NAME_ALL_DEVICES = 'all devices' ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') @@ -52,7 +58,7 @@ CONF_TRACK_NEW = "track_new_devices" DEFAULT_CONF_TRACK_NEW = True CONF_CONSIDER_HOME = 'consider_home' -DEFAULT_CONF_CONSIDER_HOME = 180 # seconds +DEFAULT_CONSIDER_HOME = 180 # seconds CONF_SCAN_INTERVAL = "interval_seconds" DEFAULT_SCAN_INTERVAL = 12 @@ -60,15 +66,17 @@ DEFAULT_SCAN_INTERVAL = 12 CONF_AWAY_HIDE = 'hide_if_away' DEFAULT_AWAY_HIDE = False +CONF_HOME_RANGE = 'home_range' +DEFAULT_HOME_RANGE = 100 + SERVICE_SEE = 'see' -ATTR_LATITUDE = 'latitude' -ATTR_LONGITUDE = 'longitude' ATTR_MAC = 'mac' ATTR_DEV_ID = 'dev_id' ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_GPS = 'gps' +ATTR_BATTERY = 'battery' DISCOVERY_PLATFORMS = { discovery.SERVICE_NETGEAR: 'netgear', @@ -86,7 +94,7 @@ def is_on(hass, entity_id=None): def see(hass, mac=None, dev_id=None, host_name=None, location_name=None, - gps=None): + gps=None, gps_accuracy=None, battery=None): """ Call service to notify you see device. """ data = {key: value for key, value in ((ATTR_MAC, mac), @@ -106,13 +114,17 @@ def setup(hass, config): os.remove(csv_path) conf = config.get(DOMAIN, {}) - consider_home = util.convert(conf.get(CONF_CONSIDER_HOME), int, - DEFAULT_CONF_CONSIDER_HOME) + consider_home = timedelta( + seconds=util.convert(conf.get(CONF_CONSIDER_HOME), int, + DEFAULT_CONSIDER_HOME)) track_new = util.convert(conf.get(CONF_TRACK_NEW), bool, DEFAULT_CONF_TRACK_NEW) + home_range = util.convert(conf.get(CONF_HOME_RANGE), int, + DEFAULT_HOME_RANGE) - devices = load_config(yaml_path, hass, timedelta(seconds=consider_home)) - tracker = DeviceTracker(hass, consider_home, track_new, devices) + devices = load_config(yaml_path, hass, consider_home, home_range) + tracker = DeviceTracker(hass, consider_home, track_new, home_range, + devices) def setup_platform(p_type, p_config, disc_info=None): """ Setup a device tracker platform. """ @@ -158,22 +170,26 @@ def setup(hass, config): """ 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, ATTR_GPS_ACCURACY, ATTR_BATTERY)} tracker.see(**args) - hass.services.register(DOMAIN, SERVICE_SEE, see_service) + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_SEE, see_service, + descriptions.get(SERVICE_SEE)) return True class DeviceTracker(object): """ Track devices """ - def __init__(self, hass, consider_home, track_new, devices): + def __init__(self, hass, consider_home, track_new, home_range, devices): self.hass = hass self.devices = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} - self.consider_home = timedelta(seconds=consider_home) + self.consider_home = consider_home self.track_new = track_new + self.home_range = home_range self.lock = threading.Lock() for device in devices: @@ -183,7 +199,7 @@ class DeviceTracker(object): self.group = None def see(self, mac=None, dev_id=None, host_name=None, location_name=None, - gps=None): + gps=None, gps_accuracy=None, battery=None): """ Notify device tracker that you see a device. """ with self.lock: if mac is None and dev_id is None: @@ -198,20 +214,21 @@ class DeviceTracker(object): device = self.devices.get(dev_id) if device: - device.seen(host_name, location_name, gps) + device.seen(host_name, location_name, gps, gps_accuracy, + battery) if device.track: device.update_ha_state() return # If no device can be found, create it device = Device( - self.hass, self.consider_home, self.track_new, dev_id, mac, - (host_name or dev_id).replace('_', ' ')) + self.hass, self.consider_home, self.home_range, self.track_new, + dev_id, mac, (host_name or dev_id).replace('_', ' ')) self.devices[dev_id] = device if mac is not None: self.mac_to_dev[mac] = device - device.seen(host_name, location_name, gps) + device.seen(host_name, location_name, gps, gps_accuracy, battery) if device.track: device.update_ha_state() @@ -239,19 +256,20 @@ class DeviceTracker(object): class Device(Entity): """ Tracked device. """ - # pylint: disable=too-many-instance-attributes, too-many-arguments host_name = None location_name = None gps = None + gps_accuracy = 0 last_seen = None + battery = None # Track if the last update of this device was HOME last_update_home = False _state = STATE_NOT_HOME - def __init__(self, hass, consider_home, track, dev_id, mac, name=None, - picture=None, away_hide=False): + def __init__(self, hass, consider_home, home_range, track, dev_id, mac, + name=None, picture=None, away_hide=False): self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -259,6 +277,8 @@ class Device(Entity): # detected anymore. self.consider_home = consider_home + # Distance in meters + self.home_range = home_range # Device ID self.dev_id = dev_id self.mac = mac @@ -273,6 +293,13 @@ class Device(Entity): self.config_picture = picture self.away_hide = away_hide + @property + def gps_home(self): + """ Return if device is within range of home. """ + distance = max( + 0, self.hass.config.distance(*self.gps) - self.gps_accuracy) + return self.gps is not None and distance <= self.home_range + @property def name(self): """ Returns the name of the entity. """ @@ -292,8 +319,12 @@ class Device(Entity): attr[ATTR_ENTITY_PICTURE] = self.config_picture if self.gps: - attr[ATTR_LATITUDE] = self.gps[0], - attr[ATTR_LONGITUDE] = self.gps[1], + attr[ATTR_LATITUDE] = self.gps[0] + attr[ATTR_LONGITUDE] = self.gps[1] + attr[ATTR_GPS_ACCURACY] = self.gps_accuracy + + if self.battery: + attr[ATTR_BATTERY] = self.battery return attr @@ -302,12 +333,23 @@ class Device(Entity): """ If device should be hidden. """ return self.away_hide and self.state != STATE_HOME - def seen(self, host_name=None, location_name=None, gps=None): + def seen(self, host_name=None, location_name=None, gps=None, + gps_accuracy=0, battery=None): """ Mark the device as seen. """ self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name - self.gps = gps + self.gps_accuracy = gps_accuracy or 0 + self.battery = battery + if gps is None: + self.gps = None + else: + try: + self.gps = tuple(float(val) for val in gps) + except ValueError: + _LOGGER.warning('Could not parse gps value for %s: %s', + self.dev_id, gps) + self.gps = None self.update() def stale(self, now=None): @@ -321,6 +363,16 @@ class Device(Entity): return elif self.location_name: self._state = self.location_name + elif self.gps is not None: + zone_state = zone.active_zone(self.hass, self.gps[0], self.gps[1], + self.gps_accuracy) + if zone_state is None: + self._state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + self._state = STATE_HOME + else: + self._state = zone_state.name + elif self.stale(): self._state = STATE_NOT_HOME self.last_update_home = False @@ -338,18 +390,18 @@ def convert_csv_config(csv_path, yaml_path): (util.slugify(row['name']) or DEVICE_DEFAULT_NAME).lower(), used_ids) used_ids.add(dev_id) - device = Device(None, None, row['track'] == '1', dev_id, + device = Device(None, None, None, row['track'] == '1', dev_id, row['device'], row['name'], row['picture']) update_config(yaml_path, dev_id, device) return True -def load_config(path, hass, consider_home): +def load_config(path, hass, consider_home, home_range): """ Load devices from YAML config file. """ if not os.path.isfile(path): return [] return [ - Device(hass, consider_home, device.get('track', False), + Device(hass, consider_home, home_range, device.get('track', False), str(dev_id).lower(), str(device.get('mac')).upper(), device.get('name'), device.get('picture'), device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) diff --git a/homeassistant/components/device_tracker/demo.py b/homeassistant/components/device_tracker/demo.py new file mode 100644 index 00000000000..e8cf906be9e --- /dev/null +++ b/homeassistant/components/device_tracker/demo.py @@ -0,0 +1,50 @@ +""" +homeassistant.components.device_tracker.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo platform for the device tracker. + +device_tracker: + platform: demo +""" +import random + +from homeassistant.components.device_tracker import DOMAIN + + +def setup_scanner(hass, config, see): + """ Set up a demo tracker. """ + + def offset(): + """ Return random offset. """ + return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1)) + + def random_see(dev_id, name): + """ Randomize a sighting. """ + see( + dev_id=dev_id, + host_name=name, + gps=(hass.config.latitude + offset(), + hass.config.longitude + offset()), + gps_accuracy=random.randrange(50, 150), + battery=random.randrange(10, 90) + ) + + def observe(call=None): + """ Observe three entities. """ + random_see('demo_paulus', 'Paulus') + random_see('demo_anne_therese', 'Anne Therese') + + observe() + + see( + dev_id='demo_home_boy', + host_name='Home Boy', + gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002], + gps_accuracy=20, + battery=53 + ) + + hass.services.register(DOMAIN, 'demo', observe) + + return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py new file mode 100644 index 00000000000..505fd6b7ad2 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks.py @@ -0,0 +1,54 @@ +""" +homeassistant.components.device_tracker.owntracks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OwnTracks platform for the device tracker. + +device_tracker: + platform: owntracks +""" +import json +import logging + +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +LOCATION_TOPIC = 'owntracks/+/+' + + +def setup_scanner(hass, config, see): + """ Set up a OwnTracksks tracker. """ + + def owntracks_location_update(topic, payload, qos): + """ MQTT message received. """ + + # Docs on available data: + # http://owntracks.org/booklet/tech/json/#_typelocation + try: + data = json.loads(payload) + except ValueError: + # If invalid JSON + logging.getLogger(__name__).error( + 'Unable to parse payload as JSON: %s', payload) + return + + if not isinstance(data, dict) or data.get('_type') != 'location': + return + + parts = topic.split('/') + kwargs = { + 'dev_id': '{}_{}'.format(parts[1], parts[2]), + 'host_name': parts[1], + 'gps': (data['lat'], data['lon']), + } + if 'acc' in data: + kwargs['gps_accuracy'] = data['acc'] + if 'batt' in data: + kwargs['battery'] = data['batt'] + + see(**kwargs) + + mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) + + return True diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 6a780693f25..089db3fb324 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,7 +19,7 @@ from homeassistant.const import ( DOMAIN = "discovery" DEPENDENCIES = [] -REQUIREMENTS = ['netdisco==0.4'] +REQUIREMENTS = ['netdisco==0.4.2'] SCAN_INTERVAL = 300 # seconds diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 902b14e38b3..419e48d55b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,8 @@ _LOGGER = logging.getLogger(__name__) FRONTEND_URLS = [ - URL_ROOT, '/logbook', '/history', '/devService', '/devState', '/devEvent'] + URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', + '/devEvent'] STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)') diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 5d1b9696feb..abf0c498b1a 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "5f35285bc502e3f69f564240fee04baa" +VERSION = "c4722afa376379bc4457d54bb9a38cee" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 8933710b823..73fdb905114 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -2277,7 +2277,490 @@ http://nicolasgallagher.com/micro-clearfix-hack/ .pika-table abbr { border-bottom: none; cursor: help; -}- \ No newline at end of file + } \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 68f6c6ae5d3..c8d99bc3ea2 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 68f6c6ae5d37a1f0fcd1c36a8803581f9367ac5f +Subproject commit c8d99bc3ea21cdd7bfb39e7700f92ed09f4b9efd diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png new file mode 100644 index 00000000000..a2cf7f9efef Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers.png b/homeassistant/components/frontend/www_static/images/leaflet/layers.png new file mode 100644 index 00000000000..bca0a0e4296 Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/layers.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png new file mode 100644 index 00000000000..0015b6495fa Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png new file mode 100644 index 00000000000..e2e9f757f51 Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png new file mode 100644 index 00000000000..d1e773c715a Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png differ diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 09a3ff97634..96fe2a67143 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -12,7 +12,8 @@ from homeassistant.helpers.entity import Entity import homeassistant.util as util from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, - STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN) + STATE_HOME, STATE_NOT_HOME, STATE_OPEN, STATE_CLOSED, + STATE_UNKNOWN) DOMAIN = "group" DEPENDENCIES = [] @@ -22,7 +23,8 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" ATTR_AUTO = "auto" # List of ON/OFF state tuples for groupable states -_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME)] +_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME), + (STATE_OPEN, STATE_CLOSED)] def _get_group_on_off(state): diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 8b2e2a6252c..bae720db8dc 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -232,7 +232,12 @@ class RequestHandler(SimpleHTTPRequestHandler): def log_message(self, fmt, *arguments): """ Redirect built-in log to HA logging """ - _LOGGER.info(fmt, *arguments) + if self.server.no_password_set: + _LOGGER.info(fmt, *arguments) + else: + _LOGGER.info( + fmt, *(arg.replace(self.server.api_password, '*******') + if isinstance(arg, str) else arg for arg in arguments)) def _handle_request(self, method): # pylint: disable=too-many-branches """ Does some common checks and calls appropriate method. """ diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d2f8033add7..8d09910093b 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -52,14 +52,14 @@ import logging import os import csv -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity import ToggleEntity - -import homeassistant.util as util -import homeassistant.util.color as color_util +from homeassistant.components import group, discovery, wink, isy994 +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) -from homeassistant.components import group, discovery, wink, isy994 +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +import homeassistant.util as util +import homeassistant.util.color as color_util DOMAIN = "light" @@ -275,11 +275,13 @@ def setup(hass, config): light.update_ha_state(True) # Listen for light on and light off service calls - hass.services.register(DOMAIN, SERVICE_TURN_ON, - handle_light_service) + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_light_service, + descriptions.get(SERVICE_TURN_ON)) - hass.services.register(DOMAIN, SERVICE_TURN_OFF, - handle_light_service) + hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_light_service, + descriptions.get(SERVICE_TURN_OFF)) return True diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 9096bb32a10..ba8b8235260 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -19,11 +19,15 @@ configuration.yaml file. light: platform: limitlessled - host: 192.168.1.10 - group_1_name: Living Room - group_2_name: Bedroom - group_3_name: Office - group_4_name: Kitchen + bridges: + - host: 192.168.1.10 + group_1_name: Living Room + group_2_name: Bedroom + group_3_name: Office + group_3_type: white + group_4_name: Kitchen + - host: 192.168.1.11 + group_2_name: Basement """ import logging @@ -33,19 +37,30 @@ from homeassistant.components.light import (Light, ATTR_BRIGHTNESS, from homeassistant.util.color import color_RGB_to_xy _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['ledcontroller==1.0.7'] +REQUIREMENTS = ['ledcontroller==1.1.0'] def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Gets the LimitlessLED lights. """ import ledcontroller - led = ledcontroller.LedController(config['host']) + # Handle old configuration format: + bridges = config.get('bridges', [config]) + + for bridge_id, bridge in enumerate(bridges): + bridge['id'] = bridge_id + + pool = ledcontroller.LedControllerPool([x['host'] for x in bridges]) lights = [] - for i in range(1, 5): - if 'group_%d_name' % (i) in config: - lights.append(LimitlessLED(led, i, config['group_%d_name' % (i)])) + for bridge in bridges: + for i in range(1, 5): + name_key = 'group_%d_name' % i + if name_key in bridge: + group_type = bridge.get('group_%d_type' % i, 'rgbw') + lights.append(LimitlessLED.factory(pool, bridge['id'], i, + bridge[name_key], + group_type)) add_devices_callback(lights) @@ -53,15 +68,57 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class LimitlessLED(Light): """ Represents a LimitlessLED light """ - def __init__(self, led, group, name): - self.led = led + @staticmethod + def factory(pool, controller_id, group, name, group_type): + ''' Construct a Limitless LED of the appropriate type ''' + if group_type == 'white': + return WhiteLimitlessLED(pool, controller_id, group, name) + elif group_type == 'rgbw': + return RGBWLimitlessLED(pool, controller_id, group, name) + + # pylint: disable=too-many-arguments + def __init__(self, pool, controller_id, group, name, group_type): + self.pool = pool + self.controller_id = controller_id self.group = group + self.pool.execute(self.controller_id, "set_group_type", self.group, + group_type) + # LimitlessLEDs don't report state, we have track it ourselves. - self.led.off(self.group) + self.pool.execute(self.controller_id, "off", self.group) self._name = name or DEVICE_DEFAULT_NAME self._state = False + + @property + def should_poll(self): + """ No polling needed. """ + return False + + @property + def name(self): + """ Returns the name of the device if any. """ + return self._name + + @property + def is_on(self): + """ True if device is on. """ + return self._state + + def turn_off(self, **kwargs): + """ Turn the device off. """ + self._state = False + self.pool.execute(self.controller_id, "off", self.group) + self.update_ha_state() + + +class RGBWLimitlessLED(LimitlessLED): + """ Represents a RGBW LimitlessLED light """ + + def __init__(self, pool, controller_id, group, name): + super().__init__(pool, controller_id, group, name, 'rgbw') + self._brightness = 100 self._xy_color = color_RGB_to_xy(255, 255, 255) @@ -87,16 +144,6 @@ class LimitlessLED(Light): ((0xE6, 0xE6, 0xFA), 'lavendar'), ]] - @property - def should_poll(self): - """ No polling needed for a demo light. """ - return False - - @property - def name(self): - """ Returns the name of the device if any. """ - return self._name - @property def brightness(self): return self._brightness @@ -117,11 +164,6 @@ class LimitlessLED(Light): # First candidate in the sorted list is closest to desired color: return sorted(candidates)[0][1] - @property - def is_on(self): - """ True if device is on. """ - return self._state - def turn_on(self, **kwargs): """ Turn the device on. """ self._state = True @@ -132,12 +174,21 @@ class LimitlessLED(Light): if ATTR_XY_COLOR in kwargs: self._xy_color = kwargs[ATTR_XY_COLOR] - self.led.set_color(self._xy_to_led_color(self._xy_color), self.group) - self.led.set_brightness(self._brightness / 255.0, self.group) + self.pool.execute(self.controller_id, "set_color", + self._xy_to_led_color(self._xy_color), self.group) + self.pool.execute(self.controller_id, "set_brightness", + self._brightness / 255.0, self.group) self.update_ha_state() - def turn_off(self, **kwargs): - """ Turn the device off. """ - self._state = False - self.led.off(self.group) + +class WhiteLimitlessLED(LimitlessLED): + """ Represents a White LimitlessLED light """ + + def __init__(self, pool, controller_id, group, name): + super().__init__(pool, controller_id, group, name, 'white') + + def turn_on(self, **kwargs): + """ Turn the device on. """ + self._state = True + self.pool.execute(self.controller_id, "on", self.group) self.update_ha_state() diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml new file mode 100644 index 00000000000..ed8b4b663ea --- /dev/null +++ b/homeassistant/components/light/services.yaml @@ -0,0 +1,52 @@ +# Describes the format for available light services + +turn_on: + description: Turn a light on + + fields: + entity_id: + description: Name(s) of entities to turn on + example: 'light.kitchen' + + transition: + description: Duration in seconds it takes to get to next state + example: 60 + + rgb_color: + description: Color for the light in RGB-format + example: '[255, 100, 100]' + + xy_color: + description: Color for the light in XY-format + example: '[0.52, 0.43]' + + brightness: + description: Number between 0..255 indicating brightness + example: 120 + + profile: + description: Name of a light profile to use + example: relax + + flash: + description: If the light should flash + values: + - short + - long + + effect: + description: Light effect + values: + - colorloop + +turn_off: + description: Turn a light off + + fields: + entity_id: + description: Name(s) of entities to turn off + example: 'light.kitchen' + + transition: + description: Duration in seconds it takes to get to next state + example: 60 diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 8068d20bb74..819dce499e9 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -6,12 +6,14 @@ Support for Tellstick lights. import logging # pylint: disable=no-name-in-module, import-error from homeassistant.components.light import Light, ATTR_BRIGHTNESS -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, + ATTR_FRIENDLY_NAME) import tellcore.constants as tellcore_constants - -REQUIREMENTS = ['tellcore-py==1.0.4'] +from tellcore.library import DirectCallbackDispatcher +REQUIREMENTS = ['tellcore-py==1.1.2'] +# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return Tellstick lights. """ @@ -22,13 +24,32 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return [] - core = telldus.TelldusCore() + core = telldus.TelldusCore(callback_dispatcher=DirectCallbackDispatcher()) + switches_and_lights = core.devices() lights = [] for switch in switches_and_lights: if switch.methods(tellcore_constants.TELLSTICK_DIM): lights.append(TellstickLight(switch)) + + def _device_event_callback(id_, method, data, cid): + """ Called from the TelldusCore library to update one device """ + for light_device in lights: + if light_device.tellstick_device.id == id_: + # Execute the update in another thread + light_device.update_ha_state(True) + break + + callback_id = core.register_device_event(_device_event_callback) + + def unload_telldus_lib(event): + """ Un-register the callback bindings """ + if callback_id is not None: + core.unregister_callback(callback_id) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, unload_telldus_lib) + add_devices_callback(lights) @@ -40,15 +61,15 @@ class TellstickLight(Light): tellcore_constants.TELLSTICK_UP | tellcore_constants.TELLSTICK_DOWN) - def __init__(self, tellstick): - self.tellstick = tellstick - self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name} + def __init__(self, tellstick_device): + self.tellstick_device = tellstick_device + self.state_attr = {ATTR_FRIENDLY_NAME: tellstick_device.name} self._brightness = 0 @property def name(self): """ Returns the name of the switch if any. """ - return self.tellstick.name + return self.tellstick_device.name @property def is_on(self): @@ -62,8 +83,9 @@ class TellstickLight(Light): def turn_off(self, **kwargs): """ Turns the switch off. """ - self.tellstick.turn_off() + self.tellstick_device.turn_off() self._brightness = 0 + self.update_ha_state() def turn_on(self, **kwargs): """ Turns the switch on. """ @@ -74,11 +96,12 @@ class TellstickLight(Light): else: self._brightness = brightness - self.tellstick.dim(self._brightness) + self.tellstick_device.dim(self._brightness) + self.update_ha_state() def update(self): """ Update state of the light. """ - last_command = self.tellstick.last_sent_command( + last_command = self.tellstick_device.last_sent_command( self.last_sent_command_mask) if last_command == tellcore_constants.TELLSTICK_TURNON: @@ -88,6 +111,11 @@ class TellstickLight(Light): elif (last_command == tellcore_constants.TELLSTICK_DIM or last_command == tellcore_constants.TELLSTICK_UP or last_command == tellcore_constants.TELLSTICK_DOWN): - last_sent_value = self.tellstick.last_sent_value() + last_sent_value = self.tellstick_device.last_sent_value() if last_sent_value is not None: self._brightness = last_sent_value + + @property + def should_poll(self): + """ Tells Home Assistant not to poll this entity. """ + return False diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 45ee7a2e319..75a5cd83823 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -10,7 +10,7 @@ import re from homeassistant.core import State, DOMAIN as HA_DOMAIN from homeassistant.const import ( - EVENT_STATE_CHANGED, STATE_HOME, STATE_ON, STATE_OFF, + EVENT_STATE_CHANGED, STATE_NOT_HOME, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST) from homeassistant import util import homeassistant.util.dt as dt_util @@ -162,10 +162,12 @@ def humanify(events): to_state = State.from_dict(event.data.get('new_state')) - # if last_changed == last_updated only attributes have changed - # we do not report on that yet. + # if last_changed != last_updated only attributes have changed + # we do not report on that yet. Also filter auto groups. if not to_state or \ - to_state.last_changed != to_state.last_updated: + to_state.last_changed != to_state.last_updated or \ + to_state.domain == 'group' and \ + to_state.attributes.get('auto', False): continue domain = to_state.domain @@ -218,10 +220,13 @@ def humanify(events): def _entry_message_from_state(domain, state): """ Convert a state to a message for the logbook. """ # We pass domain in so we don't have to split entity_id again + # pylint: disable=too-many-return-statements if domain == 'device_tracker': - return '{} home'.format( - 'arrived' if state.state == STATE_HOME else 'left') + if state.state == STATE_NOT_HOME: + return 'is away' + else: + return 'is at {}'.format(state.state) elif domain == 'sun': if state.state == sun.STATE_ABOVE_HORIZON: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 19ff0540c6b..143473e2fde 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,8 +5,10 @@ homeassistant.components.media_player Component to interface with various media players. """ import logging +import os from homeassistant.components import discovery +from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.const import ( @@ -186,6 +188,9 @@ def setup(hass, config): component.setup(config) + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + def media_player_service_handler(service): """ Maps services to methods on MediaPlayerDevice. """ target_players = component.extract_from_service(service) @@ -199,7 +204,8 @@ def setup(hass, config): player.update_ha_state(True) for service in SERVICE_TO_METHOD: - hass.services.register(DOMAIN, service, media_player_service_handler) + hass.services.register(DOMAIN, service, media_player_service_handler, + descriptions.get(service)) def volume_set_service(service): """ Set specified volume on the media player. """ @@ -216,7 +222,8 @@ def setup(hass, config): if player.should_poll: player.update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_VOLUME_SET, volume_set_service) + hass.services.register(DOMAIN, SERVICE_VOLUME_SET, volume_set_service, + descriptions.get(SERVICE_VOLUME_SET)) def volume_mute_service(service): """ Mute (true) or unmute (false) the media player. """ @@ -233,7 +240,8 @@ def setup(hass, config): if player.should_poll: player.update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE, volume_mute_service) + hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE, volume_mute_service, + descriptions.get(SERVICE_VOLUME_MUTE)) def media_seek_service(service): """ Seek to a position. """ @@ -250,7 +258,8 @@ def setup(hass, config): if player.should_poll: player.update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_MEDIA_SEEK, media_seek_service) + hass.services.register(DOMAIN, SERVICE_MEDIA_SEEK, media_seek_service, + descriptions.get(SERVICE_MEDIA_SEEK)) def play_youtube_video_service(service, media_id=None): """ Plays specified media_id on the media player. """ @@ -268,14 +277,17 @@ def setup(hass, config): hass.services.register( DOMAIN, "start_fireplace", - lambda service: play_youtube_video_service(service, "eyU3bRy2x44")) + lambda service: play_youtube_video_service(service, "eyU3bRy2x44"), + descriptions.get('start_fireplace')) hass.services.register( DOMAIN, "start_epic_sax", - lambda service: play_youtube_video_service(service, "kxopViU98Xo")) + lambda service: play_youtube_video_service(service, "kxopViU98Xo"), + descriptions.get('start_epic_sax')) hass.services.register( - DOMAIN, SERVICE_YOUTUBE_VIDEO, play_youtube_video_service) + DOMAIN, SERVICE_YOUTUBE_VIDEO, play_youtube_video_service, + descriptions.get(SERVICE_YOUTUBE_VIDEO)) return True diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py new file mode 100644 index 00000000000..46eb8947d99 --- /dev/null +++ b/homeassistant/components/media_player/plex.py @@ -0,0 +1,237 @@ +""" +homeassistant.components.media_player.plex +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides an interface to the Plex API + +Configuration: + +To use Plex add something like this to your configuration: + +media_player: + platform: plex + name: plex_server + user: plex + password: my_secure_password + +Variables: + +name +*Required +The name of the backend device (Under Plex Media Server > settings > server). + +user +*Required +The Plex username + +password +*Required +The Plex password +""" + +import logging +from datetime import timedelta + +from homeassistant.components.media_player import ( + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) +from homeassistant.const import ( + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) +import homeassistant.util as util + +REQUIREMENTS = ['https://github.com/adrienbrault/python-plexapi/archive/' + 'df2d0847e801d6d5cda920326d693cf75f304f1a.zip' + '#python-plexapi==1.0.2'] +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + +# pylint: disable=abstract-method +# pylint: disable=unused-argument + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the plex platform. """ + from plexapi.myplex import MyPlexUser + from plexapi.exceptions import BadRequest + + name = config.get('name', '') + user = config.get('user', '') + password = config.get('password', '') + plexuser = MyPlexUser.signin(user, password) + plexserver = plexuser.getResource(name).connect() + plex_clients = {} + plex_sessions = {} + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update_devices(): + """ Updates the devices objects """ + try: + devices = plexuser.devices() + except BadRequest: + _LOGGER.exception("Error listing plex devices") + return + + new_plex_clients = [] + for device in devices: + if (all(x not in ['client', 'player'] for x in device.provides) + or 'PlexAPI' == device.product): + continue + + if device.clientIdentifier not in plex_clients: + new_client = PlexClient(device, plex_sessions, update_devices, + update_sessions) + plex_clients[device.clientIdentifier] = new_client + new_plex_clients.append(new_client) + else: + plex_clients[device.clientIdentifier].set_device(device) + + if new_plex_clients: + add_devices(new_plex_clients) + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update_sessions(): + """ Updates the sessions objects """ + try: + sessions = plexserver.sessions() + except BadRequest: + _LOGGER.exception("Error listing plex sessions") + return + + plex_sessions.clear() + for session in sessions: + plex_sessions[session.player.machineIdentifier] = session + + update_devices() + update_sessions() + + +class PlexClient(MediaPlayerDevice): + """ Represents a Plex device. """ + + # pylint: disable=too-many-public-methods + + def __init__(self, device, plex_sessions, update_devices, update_sessions): + self.plex_sessions = plex_sessions + self.update_devices = update_devices + self.update_sessions = update_sessions + self.set_device(device) + + def set_device(self, device): + """ Sets the device property """ + self.device = device + + @property + def session(self): + """ Returns the session, if any """ + if self.device.clientIdentifier not in self.plex_sessions: + return None + + return self.plex_sessions[self.device.clientIdentifier] + + @property + def name(self): + """ Returns the name of the device. """ + return self.device.name or self.device.product or self.device.device + + @property + def state(self): + """ Returns the state of the device. """ + if self.session: + state = self.session.player.state + if state == 'playing': + return STATE_PLAYING + elif state == 'paused': + return STATE_PAUSED + elif self.device.isReachable: + return STATE_IDLE + else: + return STATE_OFF + + return STATE_UNKNOWN + + def update(self): + self.update_devices(no_throttle=True) + self.update_sessions(no_throttle=True) + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + if self.session is not None: + return self.session.ratingKey + + @property + def media_content_type(self): + """ Content type of current playing media. """ + if self.session is None: + return None + media_type = self.session.type + if media_type == 'episode': + return MEDIA_TYPE_TVSHOW + elif media_type == 'movie': + return MEDIA_TYPE_VIDEO + return None + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + if self.session is not None: + return self.session.duration + + @property + def media_image_url(self): + """ Image url of current playing media. """ + if self.session is not None: + return self.session.thumbUrl + + @property + def media_title(self): + """ Title of current playing media. """ + # find a string we can use as a title + if self.session is not None: + return self.session.title + + @property + def media_season(self): + """ Season of curent playing media. (TV Show only) """ + from plexapi.video import Show + if isinstance(self.session, Show): + return self.session.seasons()[0].index + + @property + def media_series_title(self): + """ Series title of current playing media. (TV Show only)""" + from plexapi.video import Show + if isinstance(self.session, Show): + return self.session.grandparentTitle + + @property + def media_episode(self): + """ Episode of current playing media. (TV Show only) """ + from plexapi.video import Show + if isinstance(self.session, Show): + return self.session.index + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_PLEX + + def media_play(self): + """ media_play media player. """ + self.device.play({'type': 'video'}) + + def media_pause(self): + """ media_pause media player. """ + self.device.pause({'type': 'video'}) + + def media_next_track(self): + """ Send next track command. """ + self.device.skipNext({'type': 'video'}) + + def media_previous_track(self): + """ Send previous track command. """ + self.device.skipPrevious({'type': 'video'}) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/mqtt.py b/homeassistant/components/mqtt/__init__.py similarity index 87% rename from homeassistant/components/mqtt.py rename to homeassistant/components/mqtt/__init__.py index 7c7a12c2ac3..71ba0fe0c9c 100644 --- a/homeassistant/components/mqtt.py +++ b/homeassistant/components/mqtt/__init__.py @@ -23,6 +23,7 @@ mqtt: keepalive: 60 username: your_username password: your_secret_password + certificate: /home/paulus/dev/addtrustexternalcaroot.crt Variables: @@ -42,8 +43,13 @@ Default is a random generated one. keepalive *Optional The keep alive in seconds for this client. Default is 60. + +certificate +*Optional +Certificate to use for encrypting the connection to the broker. """ import logging +import os import socket from homeassistant.exceptions import HomeAssistantError @@ -74,6 +80,7 @@ CONF_CLIENT_ID = 'client_id' CONF_KEEPALIVE = 'keepalive' CONF_USERNAME = 'username' CONF_PASSWORD = 'password' +CONF_CERTIFICATE = 'certificate' ATTR_TOPIC = 'topic' ATTR_PAYLOAD = 'payload' @@ -119,11 +126,18 @@ def setup(hass, config): keepalive = util.convert(conf.get(CONF_KEEPALIVE), int, DEFAULT_KEEPALIVE) username = util.convert(conf.get(CONF_USERNAME), str) password = util.convert(conf.get(CONF_PASSWORD), str) + certificate = util.convert(conf.get(CONF_CERTIFICATE), str) + + # For cloudmqtt.com, secured connection, auto fill in certificate + if certificate is None and 19999 < port < 30000 and \ + broker.endswith('.cloudmqtt.com'): + certificate = os.path.join(os.path.dirname(__file__), + 'addtrustexternalcaroot.crt') global MQTT_CLIENT try: MQTT_CLIENT = MQTT(hass, broker, port, client_id, keepalive, username, - password) + password, certificate) except socket.error: _LOGGER.exception("Can't connect to the broker. " "Please check your settings and the broker " @@ -161,7 +175,7 @@ def setup(hass, config): class MQTT(object): # pragma: no cover """ Implements messaging service for MQTT. """ def __init__(self, hass, broker, port, client_id, keepalive, username, - password): + password, certificate): import paho.mqtt.client as mqtt self.hass = hass @@ -172,8 +186,12 @@ class MQTT(object): # pragma: no cover self._mqttc = mqtt.Client() else: self._mqttc = mqtt.Client(client_id) + if username is not None: self._mqttc.username_pw_set(username, password) + if certificate is not None: + self._mqttc.tls_set(certificate) + self._mqttc.on_subscribe = self._mqtt_on_subscribe self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe self._mqttc.on_connect = self._mqtt_on_connect @@ -209,6 +227,17 @@ class MQTT(object): # pragma: no cover def _mqtt_on_connect(self, mqttc, obj, flags, result_code): """ On connect, resubscribe to all topics we were subscribed to. """ + if result_code != 0: + _LOGGER.error('Unable to connect to the MQTT broker: %s', { + 1: 'Incorrect protocol version', + 2: 'Invalid client identifier', + 3: 'Server unavailable', + 4: 'Bad username or password', + 5: 'Not authorised' + }.get(result_code)) + self._mqttc.disconnect() + return + old_topics = self.topics self._progress = {} self.topics = {} diff --git a/homeassistant/components/mqtt/addtrustexternalcaroot.crt b/homeassistant/components/mqtt/addtrustexternalcaroot.crt new file mode 100644 index 00000000000..20585f1c01e --- /dev/null +++ b/homeassistant/components/mqtt/addtrustexternalcaroot.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIENjCCAx6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBvMQswCQYDVQQGEwJTRTEU +MBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFs +IFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290 +MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFowbzELMAkGA1UEBhMCU0Ux +FDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRUcnVzdCBFeHRlcm5h +bCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0EgUm9v +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALf3GjPm8gAELTngTlvt +H7xsD821+iO2zt6bETOXpClMfZOfvUq8k+0DGuOPz+VtUFrWlymUWoCwSXrbLpX9 +uMq/NzgtHj6RQa1wVsfwTz/oMp50ysiQVOnGXw94nZpAPA6sYapeFI+eh6FqUNzX +mk6vBbOmcZSccbNQYArHE504B4YCqOmoaSYYkKtMsE8jqzpPhNjfzp/haW+710LX +a0Tkx63ubUFfclpxCDezeWWkWaCUN/cALw3CknLa0Dhy2xSoRcRdKn23tNbE7qzN +E0S3ySvdQwAl+mG5aWpYIxG3pzOPVnVZ9c0p10a3CitlttNCbxWyuHv77+ldU9U0 +WicCAwEAAaOB3DCB2TAdBgNVHQ4EFgQUrb2YejS0Jvf6xCZU7wO94CTLVBowCwYD +VR0PBAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wgZkGA1UdIwSBkTCBjoAUrb2YejS0 +Jvf6xCZU7wO94CTLVBqhc6RxMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRU +cnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsx +IjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3SCAQEwDQYJKoZIhvcN +AQEFBQADggEBALCb4IUlwtYj4g+WBpKdQZic2YR5gdkeWxQHIzZlj7DYd7usQWxH +YINRsPkyPef89iYTx4AWpb9a/IfPeHmJIZriTAcKhjW88t5RxNKWt9x+Tu5w/Rw5 +6wwCURQtjr0W4MHfRnXnJK3s9EK0hZNwEGe6nQY1ShjTK3rMUUKhemPR5ruhxSvC +Nr4TDea9Y355e6cJDUCrat2PisP29owaQgVR1EX1n6diIWgVIEM8med8vSTYqZEX +c4g/VhsxOBi0cQ+azcgOno4uG+GMmIPLHzHxREzGBHNJdmAPx/i9F4BrLunMTA5a +mnkPIAou1Z5jJh5VkpTYghdae9C8x49OhgQ= +-----END CERTIFICATE----- diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index ee53159d5e6..e6cdf372dc8 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -6,7 +6,9 @@ Provides functionality to notify people. """ from functools import partial import logging +import os +from homeassistant.config import load_yaml_config_file from homeassistant.loader import get_component from homeassistant.helpers import config_per_platform @@ -36,6 +38,9 @@ def setup(hass, config): """ Sets up notify services. """ success = False + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + for platform, p_config in config_per_platform(config, DOMAIN, _LOGGER): # get platform notify_implementation = get_component( @@ -69,7 +74,8 @@ def setup(hass, config): # register service service_call_handler = partial(notify_message, notify_service) service_notify = p_config.get(CONF_NAME, SERVICE_NOTIFY) - hass.services.register(DOMAIN, service_notify, service_call_handler) + hass.services.register(DOMAIN, service_notify, service_call_handler, + descriptions.get(service_notify)) success = True return success diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 0530ac4072d..ee2413eda30 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -140,13 +140,19 @@ class MailNotificationService(BaseNotificationService): self.username = username self.password = password self.recipient = recipient + self.tries = 2 + self.mail = None + + self.connect() + + def connect(self): + """ Connect/Authenticate to SMTP Server """ self.mail = smtplib.SMTP(self._server, self._port) self.mail.ehlo_or_helo_if_needed() if self.starttls == 1: self.mail.starttls() self.mail.ehlo() - self.mail.login(self.username, self.password) def send_message(self, message="", **kwargs): @@ -160,4 +166,12 @@ class MailNotificationService(BaseNotificationService): msg['From'] = self._sender msg['X-Mailer'] = 'HomeAssistant' - self.mail.sendmail(self._sender, self.recipient, msg.as_string()) + for _ in range(self.tries): + try: + self.mail.sendmail(self._sender, self.recipient, + msg.as_string()) + break + except smtplib.SMTPException: + _LOGGER.warning('SMTPException sending mail: ' + 'retrying connection') + self.connect() diff --git a/homeassistant/components/scene.py b/homeassistant/components/scene.py index 579ce1f20fb..4a85adefd17 100644 --- a/homeassistant/components/scene.py +++ b/homeassistant/components/scene.py @@ -33,7 +33,7 @@ ATTR_ACTIVE_REQUESTED = "active_requested" CONF_ENTITIES = "entities" -SceneConfig = namedtuple('SceneConfig', ['name', 'states']) +SceneConfig = namedtuple('SceneConfig', ['name', 'states', 'fuzzy_match']) def setup(hass, config): @@ -71,6 +71,15 @@ def setup(hass, config): def _process_config(scene_config): """ Process passed in config into a format to work with. """ name = scene_config.get('name') + + fuzzy_match = scene_config.get('fuzzy_match') + if fuzzy_match: + # default to 1% + if isinstance(fuzzy_match, int): + fuzzy_match /= 100.0 + else: + fuzzy_match = 0.01 + states = {} c_entities = dict(scene_config.get(CONF_ENTITIES, {})) @@ -91,7 +100,7 @@ def _process_config(scene_config): states[entity_id.lower()] = State(entity_id, state, attributes) - return SceneConfig(name, states) + return SceneConfig(name, states, fuzzy_match) class Scene(ToggleEntity): @@ -179,9 +188,31 @@ class Scene(ToggleEntity): state = self.scene_config.states.get(cur_state and cur_state.entity_id) return (cur_state is not None and state.state == cur_state.state and - all(value == cur_state.attributes.get(key) + all(self._compare_state_attribites( + value, cur_state.attributes.get(key)) for key, value in state.attributes.items())) + def _fuzzy_attribute_compare(self, attr_a, attr_b): + """ + Compare the attributes passed, use fuzzy logic if they are floats. + """ + + if not (isinstance(attr_a, float) and isinstance(attr_b, float)): + return False + diff = abs(attr_a - attr_b) / (abs(attr_a) + abs(attr_b)) + return diff <= self.scene_config.fuzzy_match + + def _compare_state_attribites(self, attr1, attr2): + """ Compare the attributes passed, using fuzzy logic if specified. """ + if attr1 == attr2: + return True + if not self.scene_config.fuzzy_match: + return False + if isinstance(attr1, list): + return all(self._fuzzy_attribute_compare(a, b) + for a, b in zip(attr1, attr2)) + return self._fuzzy_attribute_compare(attr1, attr2) + def _reproduce_state(self, states): """ Wraps reproduce state with Scence specific logic. """ self.ignore_updates = True diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py new file mode 100644 index 00000000000..bb368fe9344 --- /dev/null +++ b/homeassistant/components/sensor/rest.py @@ -0,0 +1,198 @@ +""" +homeassistant.components.sensor.rest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The rest sensor will consume JSON responses sent by an exposed REST API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rest.html +""" +import logging +import requests +from json import loads +from datetime import timedelta + +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'REST Sensor' +DEFAULT_METHOD = 'GET' + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-variable +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the REST sensor. """ + + use_get = False + use_post = False + + resource = config.get('resource', None) + method = config.get('method', DEFAULT_METHOD) + payload = config.get('payload', None) + verify_ssl = config.get('verify_ssl', True) + + if method == 'GET': + use_get = True + elif method == 'POST': + use_post = True + + try: + if use_get: + response = requests.get(resource, timeout=10, verify=verify_ssl) + elif use_post: + response = requests.post(resource, data=payload, timeout=10, + verify=verify_ssl) + if not response.ok: + _LOGGER.error('Response status is "%s"', response.status_code) + return False + except requests.exceptions.MissingSchema: + _LOGGER.error('Missing resource or schema in configuration. ' + 'Add http:// to your URL.') + return False + except requests.exceptions.ConnectionError: + _LOGGER.error('No route to resource/endpoint. ' + 'Please check the URL in the configuration file.') + return False + + try: + data = loads(response.text) + except ValueError: + _LOGGER.error('No valid JSON in the response in: %s', data) + return False + + try: + RestSensor.extract_value(data, config.get('variable')) + except KeyError: + _LOGGER.error('Variable "%s" not found in response: "%s"', + config.get('variable'), data) + return False + + if use_get: + rest = RestDataGet(resource, verify_ssl) + elif use_post: + rest = RestDataPost(resource, payload, verify_ssl) + + add_devices([RestSensor(rest, + config.get('name', DEFAULT_NAME), + config.get('variable'), + config.get('unit_of_measurement'), + config.get('correction_factor', None), + config.get('decimal_places', None))]) + + +# pylint: disable=too-many-arguments +class RestSensor(Entity): + """ Implements a REST sensor. """ + + def __init__(self, rest, name, variable, unit_of_measurement, corr_factor, + decimal_places): + self.rest = rest + self._name = name + self._variable = variable + self._state = 'n/a' + self._unit_of_measurement = unit_of_measurement + self._corr_factor = corr_factor + self._decimal_places = decimal_places + self.update() + + @classmethod + def extract_value(cls, data, variable): + """ Extracts the value using a key name or a path. """ + if isinstance(variable, list): + for variable_item in variable: + data = data[variable_item] + return data + else: + return data[variable] + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit the value is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + """ Gets the latest data from REST API and updates the state. """ + self.rest.update() + value = self.rest.data + + if 'error' in value: + self._state = value['error'] + else: + try: + if value is not None: + value = RestSensor.extract_value(value, self._variable) + if self._corr_factor is not None \ + and self._decimal_places is not None: + self._state = round( + (float(value) * + float(self._corr_factor)), + self._decimal_places) + elif self._corr_factor is not None \ + and self._decimal_places is None: + self._state = round(float(value) * + float(self._corr_factor)) + else: + self._state = value + except ValueError: + self._state = RestSensor.extract_value(value, self._variable) + + +# pylint: disable=too-few-public-methods +class RestDataGet(object): + """ Class for handling the data retrieval with GET method. """ + + def __init__(self, resource, verify_ssl): + self._resource = resource + self._verify_ssl = verify_ssl + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service with GET method. """ + try: + response = requests.get(self._resource, timeout=10, + verify=self._verify_ssl) + if 'error' in self.data: + del self.data['error'] + self.data = response.json() + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to resource/endpoint.") + self.data['error'] = 'N/A' + + +# pylint: disable=too-few-public-methods +class RestDataPost(object): + """ Class for handling the data retrieval with POST method. """ + + def __init__(self, resource, payload, verify_ssl): + self._resource = resource + self._payload = payload + self._verify_ssl = verify_ssl + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service with POST method. """ + try: + response = requests.post(self._resource, data=self._payload, + timeout=10, verify=self._verify_ssl) + if 'error' in self.data: + del self.data['error'] + self.data = response.json() + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to resource/endpoint.") + self.data['error'] = 'N/A' diff --git a/homeassistant/components/sensor/rpi_gpio.py b/homeassistant/components/sensor/rpi_gpio.py index e8482ea56ac..03e3482eb07 100644 --- a/homeassistant/components/sensor/rpi_gpio.py +++ b/homeassistant/components/sensor/rpi_gpio.py @@ -3,7 +3,8 @@ homeassistant.components.sensor.rpi_gpio ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Allows to configure a binary state sensor using RPi GPIO. -Note: To use RPi GPIO, Home Assistant must be run as root. +To avoid having to run Home Assistant as root when using this component, +run a Raspbian version released at or after September 29, 2015. sensor: platform: rpi_gpio diff --git a/homeassistant/components/sensor/tellstick.py b/homeassistant/components/sensor/tellstick.py index 7ee0fc19a99..6ec24d18ef1 100644 --- a/homeassistant/components/sensor/tellstick.py +++ b/homeassistant/components/sensor/tellstick.py @@ -34,7 +34,7 @@ import homeassistant.util as util DatatypeDescription = namedtuple("DatatypeDescription", ['name', 'unit']) -REQUIREMENTS = ['tellcore-py==1.0.4'] +REQUIREMENTS = ['tellcore-py==1.1.2'] # pylint: disable=unused-argument diff --git a/homeassistant/components/sensor/worldclock.py b/homeassistant/components/sensor/worldclock.py new file mode 100644 index 00000000000..01767241a0a --- /dev/null +++ b/homeassistant/components/sensor/worldclock.py @@ -0,0 +1,80 @@ +""" +homeassistant.components.sensor.worldclock +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The Worldclock sensor let you display the current time of a different time +zone. + +Configuration: + +To use the Worldclock sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: worldclock + time_zone: America/New_York + name: New York + +Variables: + +time_zone +*Required +Time zone you want to display. + +name +*Optional +Name of the sensor to use in the frontend. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.worldclock.html +""" +import logging + +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = "Worldclock Sensor" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the Worldclock sensor. """ + + try: + time_zone = dt_util.get_time_zone(config.get('time_zone')) + except AttributeError: + _LOGGER.error("time_zone in platform configuration is missing.") + return False + + if time_zone is None: + _LOGGER.error("Timezone '%s' is not valid.", config.get('time_zone')) + return False + + add_devices([WorldClockSensor( + time_zone, + config.get('name', DEFAULT_NAME) + )]) + + +class WorldClockSensor(Entity): + """ Implements a Worldclock sensor. """ + + def __init__(self, time_zone, name): + self._name = name + self._time_zone = time_zone + self._state = None + self.update() + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + """ Gets the time and updates the states. """ + self._state = dt_util.datetime_to_time_str( + dt_util.now(time_zone=self._time_zone)) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index b6dd31b48c2..0fa345747f9 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -3,9 +3,11 @@ homeassistant.components.switch ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Component to interface with various switches that can be controlled remotely. """ -import logging from datetime import timedelta +import logging +import os +from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity @@ -83,8 +85,12 @@ def setup(hass, config): if switch.should_poll: switch.update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_switch_service) - hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_switch_service) + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_switch_service, + descriptions.get(SERVICE_TURN_OFF)) + hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_switch_service, + descriptions.get(SERVICE_TURN_ON)) return True diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index ae064d4fdb8..1a0f7097b52 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -11,14 +11,14 @@ signal_repetitions: 3 """ import logging - -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, + ATTR_FRIENDLY_NAME) from homeassistant.helpers.entity import ToggleEntity import tellcore.constants as tellcore_constants - +from tellcore.library import DirectCallbackDispatcher SINGAL_REPETITIONS = 1 -REQUIREMENTS = ['tellcore-py==1.0.4'] +REQUIREMENTS = ['tellcore-py==1.1.2'] # pylint: disable=unused-argument @@ -31,16 +31,34 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return + core = telldus.TelldusCore(callback_dispatcher=DirectCallbackDispatcher()) + signal_repetitions = config.get('signal_repetitions', SINGAL_REPETITIONS) - core = telldus.TelldusCore() switches_and_lights = core.devices() switches = [] for switch in switches_and_lights: if not switch.methods(tellcore_constants.TELLSTICK_DIM): - switches.append(TellstickSwitchDevice(switch, signal_repetitions)) + switches.append( + TellstickSwitchDevice(switch, signal_repetitions)) + + def _device_event_callback(id_, method, data, cid): + """ Called from the TelldusCore library to update one device """ + for switch_device in switches: + if switch_device.tellstick_device.id == id_: + switch_device.update_ha_state() + break + + callback_id = core.register_device_event(_device_event_callback) + + def unload_telldus_lib(event): + """ Un-register the callback bindings """ + if callback_id is not None: + core.unregister_callback(callback_id) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, unload_telldus_lib) add_devices_callback(switches) @@ -50,15 +68,20 @@ class TellstickSwitchDevice(ToggleEntity): last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON | tellcore_constants.TELLSTICK_TURNOFF) - def __init__(self, tellstick, signal_repetitions): - self.tellstick = tellstick - self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name} + def __init__(self, tellstick_device, signal_repetitions): + self.tellstick_device = tellstick_device + self.state_attr = {ATTR_FRIENDLY_NAME: tellstick_device.name} self.signal_repetitions = signal_repetitions + @property + def should_poll(self): + """ Tells Home Assistant not to poll this entity. """ + return False + @property def name(self): """ Returns the name of the switch if any. """ - return self.tellstick.name + return self.tellstick_device.name @property def state_attributes(self): @@ -68,7 +91,7 @@ class TellstickSwitchDevice(ToggleEntity): @property def is_on(self): """ True if switch is on. """ - last_command = self.tellstick.last_sent_command( + last_command = self.tellstick_device.last_sent_command( self.last_sent_command_mask) return last_command == tellcore_constants.TELLSTICK_TURNON @@ -76,9 +99,11 @@ class TellstickSwitchDevice(ToggleEntity): def turn_on(self, **kwargs): """ Turns the switch on. """ for _ in range(self.signal_repetitions): - self.tellstick.turn_on() + self.tellstick_device.turn_on() + self.update_ha_state() def turn_off(self, **kwargs): """ Turns the switch off. """ for _ in range(self.signal_repetitions): - self.tellstick.turn_off() + self.tellstick_device.turn_off() + self.update_ha_state() diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index bb7f43522f4..548e6ca89a9 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -122,7 +122,7 @@ class VeraSwitch(ToggleEntity): @property def state_attributes(self): - attr = super().state_attributes + attr = super().state_attributes or {} if self.vera_device.has_battery: attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level + '%' diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 1a78a7d6725..b27d0f58f7f 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.switch import SwitchDevice from homeassistant.const import STATE_ON, STATE_OFF, STATE_STANDBY -REQUIREMENTS = ['pywemo==0.3'] +REQUIREMENTS = ['pywemo==0.3.1'] # pylint: disable=unused-argument @@ -123,9 +123,14 @@ class WemoSwitch(SwitchDevice): def update(self): """ Update WeMo state. """ - self.wemo.get_state(True) - if self.wemo.model_name == 'Insight': - self.insight_params = self.wemo.insight_params - self.insight_params['standby_state'] = self.wemo.get_standby_state - elif self.wemo.model_name == 'Maker': - self.maker_params = self.wemo.maker_params + try: + self.wemo.get_state(True) + if self.wemo.model_name == 'Insight': + self.insight_params = self.wemo.insight_params + self.insight_params['standby_state'] = ( + self.wemo.get_standby_state) + elif self.wemo.model_name == 'Maker': + self.maker_params = self.wemo.maker_params + except AttributeError: + logging.getLogger(__name__).warning( + 'Could not update status for %s', self.name) diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index e9d3c50451b..7d26c78f0f8 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -5,9 +5,11 @@ homeassistant.components.thermostat Provides functionality to interact with thermostats. """ import logging +import os from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.config import load_yaml_config_file import homeassistant.util as util from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import convert @@ -101,11 +103,16 @@ def setup(hass, config): for thermostat in target_thermostats: thermostat.update_ha_state(True) - hass.services.register( - DOMAIN, SERVICE_SET_AWAY_MODE, thermostat_service) + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) hass.services.register( - DOMAIN, SERVICE_SET_TEMPERATURE, thermostat_service) + DOMAIN, SERVICE_SET_AWAY_MODE, thermostat_service, + descriptions.get(SERVICE_SET_AWAY_MODE)) + + hass.services.register( + DOMAIN, SERVICE_SET_TEMPERATURE, thermostat_service, + descriptions.get(SERVICE_SET_TEMPERATURE)) return True diff --git a/homeassistant/components/thermostat/heat_control.py b/homeassistant/components/thermostat/heat_control.py index f77d4285544..c1dab1173d7 100644 --- a/homeassistant/components/thermostat/heat_control.py +++ b/homeassistant/components/thermostat/heat_control.py @@ -190,6 +190,13 @@ class HeatControl(ThermostatDevice): if self._heater_manual_changed: self.set_temperature(None) + @property + def is_away_mode_on(self): + """ + Returns if away mode is on. + """ + return self._away + def turn_away_mode_on(self): """ Turns away mode on. """ self._away = True diff --git a/homeassistant/components/thermostat/services.yaml b/homeassistant/components/thermostat/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py new file mode 100644 index 00000000000..aac3bdbcb8e --- /dev/null +++ b/homeassistant/components/zone.py @@ -0,0 +1,152 @@ +""" +homeassistant.components.zone +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Allows defintion of zones in Home Assistant. + +zone: + name: School + latitude: 32.8773367 + longitude: -117.2494053 + # Optional radius in meters (default: 100) + radius: 250 + # Optional icon to show instead of name + # See https://www.google.com/design/icons/ + # Example: home, work, group-work, shopping-cart, social:people + icon: group-work + +zone 2: + name: Work + latitude: 32.8753367 + longitude: -117.2474053 + +""" +import logging + +from homeassistant.const import ( + ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME) +from homeassistant.helpers import extract_domain_configs, generate_entity_id +from homeassistant.helpers.entity import Entity +from homeassistant.util.location import distance + +DOMAIN = "zone" +DEPENDENCIES = [] +ENTITY_ID_FORMAT = 'zone.{}' +ENTITY_ID_HOME = ENTITY_ID_FORMAT.format('home') +STATE = 'zoning' + +DEFAULT_NAME = 'Unnamed zone' + +ATTR_RADIUS = 'radius' +DEFAULT_RADIUS = 100 + +ATTR_ICON = 'icon' +ICON_HOME = 'home' + + +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 + zones = (hass.states.get(entity_id) for entity_id + in sorted(hass.states.entity_ids(DOMAIN))) + + min_dist = None + closest = None + + for zone in zones: + zone_dist = distance( + latitude, longitude, + zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE]) + + within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] + closer_zone = closest is None or zone_dist < min_dist + smaller_zone = (zone_dist == min_dist and + zone.attributes[ATTR_RADIUS] < + closest.attributes[ATTR_RADIUS]) + + if within_zone and (closer_zone or smaller_zone): + min_dist = zone_dist + closest = zone + + return closest + + +def in_zone(zone, latitude, longitude, radius=0): + """ Test if given latitude, longitude is in given zone. """ + zone_dist = distance( + latitude, longitude, + zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE]) + + return zone_dist - radius < zone.attributes[ATTR_RADIUS] + + +def setup(hass, config): + """ Setup zone. """ + entities = set() + + for key in extract_domain_configs(config, DOMAIN): + entries = config[key] + if not isinstance(entries, list): + entries = entries, + + for entry in entries: + name = entry.get(CONF_NAME, DEFAULT_NAME) + latitude = entry.get(ATTR_LATITUDE) + longitude = entry.get(ATTR_LONGITUDE) + radius = entry.get(ATTR_RADIUS, DEFAULT_RADIUS) + icon = entry.get(ATTR_ICON) + + if None in (latitude, longitude): + logging.getLogger(__name__).error( + 'Each zone needs a latitude and longitude.') + continue + + zone = Zone(hass, name, latitude, longitude, radius, icon) + zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, + entities) + zone.update_ha_state() + 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) + zone.entity_id = ENTITY_ID_HOME + zone.update_ha_state() + + return True + + +class Zone(Entity): + """ Represents a Zone in Home Assistant. """ + # pylint: disable=too-many-arguments + def __init__(self, hass, name, latitude, longitude, radius, icon): + self.hass = hass + self._name = name + self.latitude = latitude + self.longitude = longitude + self.radius = radius + self.icon = icon + + def should_poll(self): + return False + + @property + def name(self): + return self._name + + @property + def state(self): + """ The state property really does nothing for a zone. """ + return STATE + + @property + def state_attributes(self): + attr = { + ATTR_HIDDEN: True, + ATTR_LATITUDE: self.latitude, + ATTR_LONGITUDE: self.longitude, + ATTR_RADIUS: self.radius, + } + if self.icon: + attr[ATTR_ICON] = self.icon + return attr diff --git a/homeassistant/const.py b/homeassistant/const.py index 3f36c27c424..c256aa921d1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,6 +1,7 @@ +# coding: utf-8 """ Constants used by Home Assistant components. """ -__version__ = "0.7.3" +__version__ = "0.7.4dev0" # Can be used to specify a catch all when registering state or event listeners. MATCH_ALL = '*' @@ -100,6 +101,13 @@ ATTR_LAST_TRIP_TIME = "last_tripped_time" # For all entity's, this hold whether or not it should be hidden ATTR_HIDDEN = "hidden" +# Location of the entity +ATTR_LATITUDE = "latitude" +ATTR_LONGITUDE = "longitude" + +# Accuracy of location in meters +ATTR_GPS_ACCURACY = 'gps_accuracy' + # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = "stop" diff --git a/homeassistant/core.py b/homeassistant/core.py index df18d7e7902..b834efce406 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) import homeassistant.util as util import homeassistant.util.dt as date_util +import homeassistant.util.location as location import homeassistant.helpers.temperature as temp_helper from homeassistant.config import get_default_config_dir @@ -445,9 +446,8 @@ class StateMachine(object): domain_filter = domain_filter.lower() - return [state.entity_id for key, state - in self._states.items() - if util.split_entity_id(key)[0] == domain_filter] + return [state.entity_id for state in self._states.values() + if state.domain == domain_filter] def all(self): """ Returns a list of all states. """ @@ -524,6 +524,28 @@ class StateMachine(object): from_state, to_state) +# pylint: disable=too-few-public-methods +class Service(object): + """ Represents a service. """ + + __slots__ = ['func', 'description', 'fields'] + + def __init__(self, func, description, fields): + self.func = func + self.description = description or '' + self.fields = fields or {} + + def as_dict(self): + """ Return dictionary representation of this service. """ + return { + 'description': self.description, + 'fields': self.fields, + } + + def __call__(self, call): + self.func(call) + + # pylint: disable=too-few-public-methods class ServiceCall(object): """ Represents a call to a service. """ @@ -558,20 +580,29 @@ class ServiceRegistry(object): def services(self): """ Dict with per domain a list of available services. """ with self._lock: - return {domain: list(self._services[domain].keys()) + return {domain: {key: value.as_dict() for key, value + in self._services[domain].items()} for domain in self._services} def has_service(self, domain, service): """ Returns True if specified service exists. """ return service in self._services.get(domain, []) - def register(self, domain, service, service_func): - """ Register a service. """ + def register(self, domain, service, service_func, description=None): + """ + Register a service. + + Description is a dict containing key 'description' to describe + the service and a key 'fields' to describe the fields. + """ + description = description or {} + service_obj = Service(service_func, description.get('description'), + description.get('fields', {})) with self._lock: if domain in self._services: - self._services[domain][service] = service_func + self._services[domain][service] = service_obj else: - self._services[domain] = {service: service_func} + self._services[domain] = {service: service_obj} self._bus.fire( EVENT_SERVICE_REGISTERED, @@ -676,6 +707,10 @@ class Config(object): # Directory that holds the configuration self.config_dir = get_default_config_dir() + def distance(self, lat, lon): + """ Calculate distance from Home Assistant in meters. """ + return location.distance(self.latitude, self.longitude, lat, lon) + def path(self, *path): """ Returns path to the file within the config dir. """ return os.path.join(self.config_dir, *path) diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 286eed4654e..021146d1c32 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -1,6 +1,8 @@ """ Helper methods for components within Home Assistant. """ +import re + from homeassistant.loader import get_component from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, DEVICE_DEFAULT_NAME) @@ -73,7 +75,7 @@ def config_per_platform(config, domain, logger): config_key = domain found = 1 - while config_key in config: + for config_key in extract_domain_configs(config, domain): platform_config = config[config_key] if not isinstance(platform_config, list): platform_config = [platform_config] @@ -89,3 +91,9 @@ def config_per_platform(config, domain, logger): found += 1 config_key = "{} {}".format(domain, found) + + +def extract_domain_configs(config, domain): + """ Extract keys from config for given domain name. """ + pattern = re.compile(r'^{}(| .+)$'.format(domain)) + return (key for key in config.keys() if pattern.match(key)) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index d4a18806a17..909b86a67ed 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -9,7 +9,9 @@ import logging from homeassistant.core import State import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) + STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, + STATE_PLAYING, STATE_PAUSED, ATTR_ENTITY_ID) _LOGGER = logging.getLogger(__name__) @@ -55,7 +57,11 @@ def reproduce_state(hass, states, blocking=False): state.entity_id) continue - if state.state == STATE_ON: + if state.domain == 'media_player' and state.state == STATE_PAUSED: + service = SERVICE_MEDIA_PAUSE + elif state.domain == 'media_player' and state.state == STATE_PLAYING: + service = SERVICE_MEDIA_PLAY + elif state.state == STATE_ON: service = SERVICE_TURN_ON elif state.state == STATE_OFF: service = SERVICE_TURN_OFF diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 8cc008613cb..398a0a0c56c 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -2,6 +2,7 @@ import collections import requests +from vincenty import vincenty LocationInfo = collections.namedtuple( @@ -28,3 +29,8 @@ def detect_location_info(): 'BS', 'BZ', 'KY', 'PW', 'US', 'AS', 'VI') return LocationInfo(**data) + + +def distance(lat1, lon1, lat2, lon2): + """ Calculate the distance in meters between two points. """ + return vincenty((lat1, lon1), (lat2, lon2)) * 1000 diff --git a/requirements_all.txt b/requirements_all.txt index 19cc04016a8..2b7074d91cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,6 +3,7 @@ requests>=2,<3 pyyaml>=3.11,<4 pytz>=2015.4 pip>=7.0.0 +vincenty==0.1.2 # Optional, needed for specific components @@ -13,7 +14,7 @@ astral==0.8.1 phue==0.8 # Limitlessled/Easybulb/Milight library (lights.limitlessled) -ledcontroller==1.0.7 +ledcontroller==1.1.0 # Chromecast bindings (media_player.cast) pychromecast==0.6.12 @@ -22,7 +23,7 @@ pychromecast==0.6.12 pyuserinput==0.1.9 # Tellstick bindings (*.tellstick) -tellcore-py==1.0.4 +tellcore-py==1.1.2 # Nmap bindings (device_tracker.nmap) python-nmap==0.4.3 @@ -86,10 +87,10 @@ https://github.com/theolind/pymysensors/archive/35b87d880147a34107da0d40cb815d75 pynetgear==0.3 # Netdisco (discovery) -netdisco==0.4 +netdisco==0.4.2 # Wemo (switch.wemo) -pywemo==0.3 +pywemo==0.3.1 # Wink (*.wink) https://github.com/balloob/python-wink/archive/c2b700e8ca866159566ecf5e644d9c297f69f257.zip#python-wink==0.1 @@ -133,3 +134,6 @@ https://github.com/balloob/home-assistant-vera-api/archive/a8f823066ead6c7da6fb5 # Sonos bindings (media_player.sonos) SoCo==0.11.1 + +# PlexAPI (media_player.plex) +https://github.com/adrienbrault/python-plexapi/archive/df2d0847e801d6d5cda920326d693cf75f304f1a.zip#python-plexapi==1.0.2 diff --git a/script/cibuild b/script/cibuild index b0e0a31fb89..bd8ac963429 100755 --- a/script/cibuild +++ b/script/cibuild @@ -3,6 +3,8 @@ # script/cibuild: Setup environment for CI to run tests. This is primarily # designed to run on the continuous integration server. +cd "$(dirname "$0")/.." + script/test coverage STATUS=$? diff --git a/script/home-assistant@.service b/script/home-assistant@.service new file mode 100644 index 00000000000..983844a95a3 --- /dev/null +++ b/script/home-assistant@.service @@ -0,0 +1,14 @@ +# This is a simple service file for systems with systemd to tun HA as user. +# +[Unit] +Description=Home Assistant for %i +After=network.target + +[Service] +Type=simple +User=%i +WorkingDirectory=%h +ExecStart=/usr/bin/hass --config %h/.homeassistant/ + +[Install] +WantedBy=multi-user.target diff --git a/script/lint b/script/lint index 05178a20ad8..75667ef88a4 100755 --- a/script/lint +++ b/script/lint @@ -5,14 +5,15 @@ cd "$(dirname "$0")/.." echo "Checking style with flake8..." flake8 --exclude www_static homeassistant -STATUS=$? +FLAKE8_STATUS=$? echo "Checking style with pylint..." pylint homeassistant +PYLINT_STATUS=$? -if [ $STATUS -eq 0 ] +if [ $FLAKE8_STATUS -eq 0 ] then - exit $? + exit $PYLINT_STATUS else - exit $STATUS + exit $FLAKE8_STATUS fi diff --git a/script/release b/script/release new file mode 100755 index 00000000000..40d906b17bf --- /dev/null +++ b/script/release @@ -0,0 +1,21 @@ +# Pushes a new version to PyPi + +cd "$(dirname "$0")/.." + +head -n 3 homeassistant/const.py | tail -n 1 | grep dev + +if [ $? -eq 0 ] +then + echo "Release version should not contain dev tag" + exit 1 +fi + +CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` + +if [ "$CURRENT_BRANCH" != "master" ] +then + echo "You have to be on the master branch to release." + exit 1 +fi + +python3 setup.py sdist bdist_wheel upload diff --git a/script/test b/script/test index 2e13bcc4b0e..d407f57a338 100755 --- a/script/test +++ b/script/test @@ -7,19 +7,21 @@ cd "$(dirname "$0")/.." script/lint -STATUS=$? +LINT_STATUS=$? echo "Running tests..." if [ "$1" = "coverage" ]; then py.test --cov --cov-report= + TEST_STATUS=$? else py.test + TEST_STATUS=$? fi -if [ $STATUS -eq 0 ] +if [ $LINT_STATUS -eq 0 ] then - exit $? + exit $TEST_STATUS else - exit $STATUS + exit $LINT_STATUS fi diff --git a/setup.py b/setup.py index fde7f9bf898..044d5428809 100755 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2015.4', 'pip>=7.0.0', + 'vincenty==0.1.2' ] setup( diff --git a/tests/common.py b/tests/common.py index 830b21ed47c..9263cae04e3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -124,14 +124,17 @@ def mock_http_component(hass): hass.config.components.append('http') -def mock_mqtt_component(hass): - with mock.patch('homeassistant.components.mqtt.MQTT'): - mqtt.setup(hass, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } - }) - hass.config.components.append(mqtt.DOMAIN) +@mock.patch('homeassistant.components.mqtt.MQTT') +@mock.patch('homeassistant.components.mqtt.MQTT.publish') +def mock_mqtt_component(hass, mock_mqtt, mock_mqtt_publish): + mqtt.setup(hass, { + mqtt.DOMAIN: { + mqtt.CONF_BROKER: 'mock-broker', + } + }) + hass.config.components.append(mqtt.DOMAIN) + + return mock_mqtt_publish class MockHTTP(object): diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index 174ef91f1c4..516eda53947 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -11,7 +11,7 @@ import homeassistant.components.automation as automation from tests.common import mock_mqtt_component, fire_mqtt_message -class TestAutomationState(unittest.TestCase): +class TestAutomationMQTT(unittest.TestCase): """ Test the event automation. """ def setUp(self): # pylint: disable=invalid-name diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py new file mode 100644 index 00000000000..bfb92bb0b1a --- /dev/null +++ b/tests/components/automation/test_zone.py @@ -0,0 +1,181 @@ +""" +tests.components.automation.test_location +±±±~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests location automation. +""" +import unittest + +from homeassistant.components import automation, zone + +from tests.common import get_test_home_assistant + + +class TestAutomationZone(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = get_test_home_assistant() + zone.setup(self.hass, { + 'zone': { + 'name': 'test', + 'latitude': 32.880837, + 'longitude': -117.237561, + 'radius': 250, + } + }) + + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_if_fires_on_zone_enter(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + 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): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_leave(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_leave_on_zone_enter(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_zone_condition(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event' + }, + 'condition': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 8b086e97c88..fb368bf863a 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -103,12 +103,12 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_reading_yaml_config(self): dev_id = 'test' device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, 'AB:CD:EF:GH:IJ', - 'Test name', 'http://test.picture', True) + self.hass, timedelta(seconds=180), 0, True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', 'http://test.picture', True) device_tracker.update_config(self.yaml_devices, dev_id, device) self.assertTrue(device_tracker.setup(self.hass, {})) config = device_tracker.load_config(self.yaml_devices, self.hass, - device.consider_home)[0] + device.consider_home, 0)[0] self.assertEqual(device.dev_id, config.dev_id) self.assertEqual(device.track, config.track) self.assertEqual(device.mac, config.mac) @@ -126,7 +126,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): self.assertTrue(device_tracker.setup(self.hass, { device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})) config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0))[0] + timedelta(seconds=0), 0)[0] self.assertEqual('dev1', config.dev_id) self.assertEqual(True, config.track) @@ -176,7 +176,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): picture = 'http://placehold.it/200x200' device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, + self.hass, timedelta(seconds=180), 0, True, dev_id, None, friendly_name, picture, away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device) @@ -191,7 +191,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): dev_id = 'test_entity' entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, + self.hass, timedelta(seconds=180), 0, True, dev_id, None, away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device) @@ -208,7 +208,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): dev_id = 'test_entity' entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, + self.hass, timedelta(seconds=180), 0, True, dev_id, None, away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device) diff --git a/tests/components/sensor/__init__.py b/tests/components/sensor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py new file mode 100644 index 00000000000..b59ea867c58 --- /dev/null +++ b/tests/components/sensor/test_mqtt.py @@ -0,0 +1,41 @@ +""" +tests.components.sensor.test_mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests mqtt sensor. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.sensor as sensor +from tests.common import mock_mqtt_component, fire_mqtt_message + + +class TestSensorMQTT(unittest.TestCase): + """ Test the MQTT sensor. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_setting_sensor_value_via_mqtt_message(self): + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'unit_of_measurement': 'fav unit' + } + })) + + fire_mqtt_message(self.hass, 'test-topic', '100') + self.hass.pool.block_till_done() + state = self.hass.states.get('sensor.test') + + self.assertEqual('100', state.state) + self.assertEqual('fav unit', + state.attributes.get('unit_of_measurement')) diff --git a/tests/components/switch/__init__.py b/tests/components/switch/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/test_switch.py b/tests/components/switch/test_init.py similarity index 100% rename from tests/components/test_switch.py rename to tests/components/switch/test_init.py diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py new file mode 100644 index 00000000000..a09fcf86c58 --- /dev/null +++ b/tests/components/switch/test_mqtt.py @@ -0,0 +1,82 @@ +""" +tests.components.switch.test_mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests mqtt switch. +""" +import unittest + +from homeassistant.const import STATE_ON, STATE_OFF +import homeassistant.core as ha +import homeassistant.components.switch as switch +from tests.common import mock_mqtt_component, fire_mqtt_message + + +class TestSensorMQTT(unittest.TestCase): + """ Test the MQTT switch. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_controlling_state_via_topic(self): + self.assertTrue(switch.setup(self.hass, { + 'switch': { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 'beer on', + 'payload_off': 'beer off' + } + })) + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) + + fire_mqtt_message(self.hass, 'state-topic', 'beer on') + self.hass.pool.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_ON, state.state) + + fire_mqtt_message(self.hass, 'state-topic', 'beer off') + self.hass.pool.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) + + def test_sending_mqtt_commands_and_optimistic(self): + self.assertTrue(switch.setup(self.hass, { + 'switch': { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'command-topic', + 'payload_on': 'beer on', + 'payload_off': 'beer off', + 'qos': 2 + } + })) + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) + + switch.turn_on(self.hass, 'switch.test') + self.hass.pool.block_till_done() + + self.assertEqual(('command-topic', 'beer on', 2), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_ON, state.state) + + switch.turn_off(self.hass, 'switch.test') + self.hass.pool.block_till_done() + + self.assertEqual(('command-topic', 'beer off', 2), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index 0e7c310d91f..5899ef3a943 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -8,9 +8,8 @@ Tests component helpers. import unittest import homeassistant.core as ha -import homeassistant.loader as loader +from homeassistant import loader, helpers from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID -from homeassistant.helpers import extract_entity_ids from tests.common import get_test_home_assistant @@ -39,10 +38,22 @@ class TestComponentsCore(unittest.TestCase): {ATTR_ENTITY_ID: 'light.Bowl'}) self.assertEqual(['light.bowl'], - extract_entity_ids(self.hass, call)) + helpers.extract_entity_ids(self.hass, call)) call = ha.ServiceCall('light', 'turn_on', {ATTR_ENTITY_ID: 'group.test'}) self.assertEqual(['light.ceiling', 'light.kitchen'], - extract_entity_ids(self.hass, call)) + helpers.extract_entity_ids(self.hass, call)) + + def test_extract_domain_configs(self): + config = { + 'zone': None, + 'zoner': None, + 'zone ': None, + 'zone Hallo': None, + 'zone 100': None, + } + + self.assertEqual(set(['zone', 'zone Hallo', 'zone 100']), + set(helpers.extract_domain_configs(config, 'zone'))) diff --git a/tests/test_core.py b/tests/test_core.py index 30ef03ac1b4..01ede9e138e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -441,7 +441,7 @@ class TestServiceRegistry(unittest.TestCase): def test_services(self): expected = { - 'test_domain': ['test_service'] + 'test_domain': {'test_service': {'description': '', 'fields': {}}} } self.assertEqual(expected, self.services.services)