diff --git a/.coveragerc b/.coveragerc index 7ebab01d399..81ad15076ec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -26,6 +26,9 @@ omit = homeassistant/components/zwave.py homeassistant/components/*/zwave.py + homeassistant/components/rfxtrx.py + homeassistant/components/*/rfxtrx.py + homeassistant/components/ifttt.py homeassistant/components/browser.py homeassistant/components/camera/* @@ -39,16 +42,20 @@ omit = homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py + homeassistant/components/device_tracker/snmp.py homeassistant/components/discovery.py homeassistant/components/downloader.py homeassistant/components/keyboard.py homeassistant/components/light/hue.py homeassistant/components/light/limitlessled.py + homeassistant/components/light/blinksticklight.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py + homeassistant/components/media_player/firetv.py 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 @@ -59,6 +66,7 @@ omit = homeassistant/components/notify/slack.py homeassistant/components/notify/smtp.py homeassistant/components/notify/syslog.py + homeassistant/components/notify/telegram.py homeassistant/components/notify/xmpp.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py @@ -69,7 +77,7 @@ omit = homeassistant/components/sensor/glances.py homeassistant/components/sensor/mysensors.py homeassistant/components/sensor/openweathermap.py - homeassistant/components/sensor/rfxtrx.py + homeassistant/components/sensor/rest.py homeassistant/components/sensor/rpi_gpio.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/swiss_public_transport.py @@ -77,6 +85,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/MANIFEST.in b/MANIFEST.in index aae95799ac4..8233015e646 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,5 @@ -recursive-exclude tests * +include README.md +include LICENSE +graft homeassistant +prune homeassistant/components/frontend/www_static/home-assistant-polymer +recursive-exclude * *.py[co] diff --git a/README.md b/README.md index 6b1b1353392..2fed012402c 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,12 @@ Check out [the website](https://home-assistant.io) for [a demo][demo], installat Examples of devices it can interface it: - * Monitoring connected devices to a wireless router: [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index), [TPLink](http://www.tp-link.us/), and [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/) + * Monitoring connected devices to a wireless router: [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index), [TPLink](http://www.tp-link.us/), [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/) and any SNMP capable Linksys WAP/WRT + * * [Philips Hue](http://meethue.com) lights, [WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) switches, [Edimax](http://www.edimax.com/) switches, [Efergy](https://efergy.com) energy monitoring, RFXtrx sensors, and [Tellstick](http://www.telldus.se/products/tellstick) devices and sensors - * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), [Kodi (XBMC)](http://kodi.tv/), and iTunes (by way of [itunes-api](https://github.com/maddox/itunes-api)) - * Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/) + * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), [Plex](https://plex.tv/), [Kodi (XBMC)](http://kodi.tv/), iTunes (by way of [itunes-api](https://github.com/maddox/itunes-api)), and Amazon Fire TV (by way of [python-firetv](https://github.com/happyleavesaoc/python-firetv)) + * Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), [RFXtrx](http://www.rfxcom.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/) + * Interaction with [IFTTT](https://ifttt.com/) * Integrate data from the [Bitcoin](https://bitcoin.org) network, meteorological data from [OpenWeatherMap](http://openweathermap.org/) and [Forecast.io](https://forecast.io/), [Transmission](http://www.transmissionbt.com/), or [SABnzbd](http://sabnzbd.org). * [See full list of supported devices](https://home-assistant.io/components/) @@ -29,8 +31,8 @@ Built home automation on top of your devices: * Turn on the lights when people get home after sun set * Turn on lights slowly during sun set to compensate for less light * Turn off all lights and devices when everybody leaves the house - * Offers a [REST API](https://home-assistant.io/developers/api.html) and can interface with MQTT for easy integration with other projects - * Allow sending notifications using [Instapush](https://instapush.im), [Notify My Android (NMA)](http://www.notifymyandroid.com/), [PushBullet](https://www.pushbullet.com/), [PushOver](https://pushover.net/), [Slack](https://slack.com/), and [Jabber (XMPP)](http://xmpp.org) + * Offers a [REST API](https://home-assistant.io/developers/api.html) and can interface with MQTT for easy integration with other projects like [OwnTracks](http://owntracks.org/) + * Allow sending notifications using [Instapush](https://instapush.im), [Notify My Android (NMA)](http://www.notifymyandroid.com/), [PushBullet](https://www.pushbullet.com/), [PushOver](https://pushover.net/), [Slack](https://slack.com/), [Telegram](https://telegram.org/), and [Jabber (XMPP)](http://xmpp.org) The system is built modular so support for other devices or actions can be implemented easily. See also the [section on architecture](https://home-assistant.io/developers/architecture.html) and the [section on creating your own components](https://home-assistant.io/developers/creating_components.html). diff --git a/config/configuration.yaml.example b/config/configuration.yaml.example index 5acca361a30..fae945b05e4 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,39 @@ 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 + condition: use_trigger_values + action: + service: light.turn_off + entity_id: group.all_lights - time_hours: 16 - time_minutes: 0 - time_seconds: 0 - - execute_service: notify.notify - service_data: - message: It's 4, time for beer! +# Sensors need to be added into the configuration.yaml as sensor:, sensor 2:, sensor 3:, etc. +# Each sensor label should be unique or your sensors might not load correctly. sensor: platform: systemmonitor @@ -135,6 +155,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 b2e5fa51540..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. ' 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/frontend/www_static/__init__.py b/homeassistant/components/alarm_control_panel/services.yaml similarity index 100% rename from homeassistant/components/frontend/www_static/__init__.py rename to homeassistant/components/alarm_control_panel/services.yaml 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/state.py b/homeassistant/components/automation/state.py index 8baa0a01d46..5fc36300ed0 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -28,6 +28,11 @@ def trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL + if isinstance(from_state, bool) or isinstance(to_state, bool): + logging.getLogger(__name__).error( + 'Config error. Surround to/from values with quotes.') + return False + def state_automation_listener(entity, from_s, to_s): """ Listens for state changes and calls action. """ action() 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 27e9417ab5b..9fe18585418 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -30,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 @@ -40,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') @@ -70,14 +71,11 @@ 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_GPS_ACCURACY = 'gps_accuracy' ATTR_BATTERY = 'battery' DISCOVERY_PLATFORMS = { @@ -116,6 +114,8 @@ def setup(hass, config): os.remove(csv_path) conf = config.get(DOMAIN, {}) + if isinstance(conf, list): + conf = conf[0] consider_home = timedelta( seconds=util.convert(conf.get(CONF_CONSIDER_HOME), int, DEFAULT_CONSIDER_HOME)) @@ -175,7 +175,10 @@ def setup(hass, config): 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 @@ -338,7 +341,7 @@ class Device(Entity): self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name - self.gps_accuracy = gps_accuracy + self.gps_accuracy = gps_accuracy or 0 self.battery = battery if gps is None: self.gps = None @@ -363,7 +366,15 @@ class Device(Entity): elif self.location_name: self._state = self.location_name elif self.gps is not None: - self._state = STATE_HOME if self.gps_home else STATE_NOT_HOME + 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 diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 1e3ac20b6f2..5284d45835b 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -161,9 +161,10 @@ class AsusWrtDeviceScanner(object): # For leases where the client doesn't set a hostname, ensure # it is blank and not '*', which breaks the entity_id down # the line - host = match.group('host') - if host == '*': - host = '' + if match: + host = match.group('host') + if host == '*': + host = '' devices[match.group('ip')] = { 'host': host, @@ -174,6 +175,6 @@ class AsusWrtDeviceScanner(object): for neighbor in neighbors: match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) - if match.group('ip') in devices: + if match and match.group('ip') in devices: devices[match.group('ip')]['status'] = match.group('status') return devices diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index a9a4ac8e3f5..947876c85b5 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -46,6 +46,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) _DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') +_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') # pylint: disable=unused-argument @@ -77,7 +78,7 @@ class DdWrtDeviceScanner(object): self.last_results = {} - self.mac2name = None + self.mac2name = {} # Test the router is accessible url = 'http://{}/Status_Wireless.live.asp'.format(self.host) @@ -98,30 +99,33 @@ class DdWrtDeviceScanner(object): with self.lock: # if not initialised and not already scanned and not found - if self.mac2name is None or device not in self.mac2name: + if device not in self.mac2name: url = 'http://{}/Status_Lan.live.asp'.format(self.host) data = self.get_ddwrt_data(url) if not data: - return + return None dhcp_leases = data.get('dhcp_leases', None) - if dhcp_leases: - # remove leading and trailing single quotes - cleaned_str = dhcp_leases.strip().strip('"') - elements = cleaned_str.split('","') - num_clients = int(len(elements)/5) - self.mac2name = {} - for idx in range(0, num_clients): - # this is stupid but the data is a single array - # every 5 elements represents one hosts, the MAC - # is the third element and the name is the first - mac_index = (idx * 5) + 2 - if mac_index < len(elements): - mac = elements[mac_index] - self.mac2name[mac] = elements[idx * 5] - return self.mac2name.get(device, None) + if not dhcp_leases: + return None + + # remove leading and trailing single quotes + cleaned_str = dhcp_leases.strip().strip('"') + elements = cleaned_str.split('","') + num_clients = int(len(elements)/5) + self.mac2name = {} + for idx in range(0, num_clients): + # this is stupid but the data is a single array + # every 5 elements represents one hosts, the MAC + # is the third element and the name is the first + mac_index = (idx * 5) + 2 + if mac_index < len(elements): + mac = elements[mac_index] + self.mac2name[mac] = elements[idx * 5] + + return self.mac2name.get(device) @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): @@ -141,29 +145,25 @@ class DdWrtDeviceScanner(object): if not data: return False - if data: - self.last_results = [] - active_clients = data.get('active_wireless', None) - if active_clients: - # This is really lame, instead of using JSON the DD-WRT UI - # uses its own data format for some reason and then - # regex's out values so I guess I have to do the same, - # LAME!!! + self.last_results = [] - # remove leading and trailing single quotes - clean_str = active_clients.strip().strip("'") - elements = clean_str.split("','") + active_clients = data.get('active_wireless', None) + if not active_clients: + return False - num_clients = int(len(elements)/9) - for idx in range(0, num_clients): - # get every 9th element which is the MAC address - index = idx * 9 - if index < len(elements): - self.last_results.append(elements[index]) + # This is really lame, instead of using JSON the DD-WRT UI + # uses its own data format for some reason and then + # regex's out values so I guess I have to do the same, + # LAME!!! - return True + # remove leading and trailing single quotes + clean_str = active_clients.strip().strip("'") + elements = clean_str.split("','") - return False + self.last_results.extend(item for item in elements + if _MAC_REGEX.match(item)) + + return True def get_ddwrt_data(self, url): """ Retrieve data from DD-WRT and return parsed result. """ diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 8d9c2e72c20..6f993f0fc7e 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -117,15 +117,18 @@ class NmapDeviceScanner(object): scanner = PortScanner() options = "-F --host-timeout 5" - exclude_targets = set() + if self.home_interval: - now = dt_util.now() - for host in self.last_results: - if host.last_update + self.home_interval > now: - exclude_targets.add(host) - if len(exclude_targets) > 0: - target_list = [t.ip for t in exclude_targets] - options += " --exclude {}".format(",".join(target_list)) + boundary = dt_util.now() - self.home_interval + last_results = [device for device in self.last_results + if device.last_update > boundary] + if last_results: + # Pylint is confused here. + # pylint: disable=no-member + options += " --exclude {}".format(",".join(device.ip for device + in last_results)) + else: + last_results = [] try: result = scanner.scan(hosts=self.hosts, arguments=options) @@ -133,18 +136,17 @@ class NmapDeviceScanner(object): return False now = dt_util.now() - self.last_results = [] for ipv4, info in result['scan'].items(): if info['status']['state'] != 'up': continue - name = info['hostnames'][0] if info['hostnames'] else ipv4 + name = info['hostnames'][0]['name'] if info['hostnames'] else ipv4 # Mac address only returned if nmap ran as root mac = info['addresses'].get('mac') or _arp(ipv4) if mac is None: continue - device = Device(mac.upper(), name, ipv4, now) - self.last_results.append(device) - self.last_results.extend(exclude_targets) + last_results.append(Device(mac.upper(), name, ipv4, now)) + + self.last_results = last_results _LOGGER.info("nmap scan successful") return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 9ef227909e1..505fd6b7ad2 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -33,7 +33,7 @@ def setup_scanner(hass, config, see): 'Unable to parse payload as JSON: %s', payload) return - if data.get('_type') != 'location': + if not isinstance(data, dict) or data.get('_type') != 'location': return parts = topic.split('/') diff --git a/homeassistant/components/frontend/www_static/images/__init__.py b/homeassistant/components/device_tracker/services.yaml similarity index 100% rename from homeassistant/components/frontend/www_static/images/__init__.py rename to homeassistant/components/device_tracker/services.yaml diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py new file mode 100644 index 00000000000..21bcdfb2a93 --- /dev/null +++ b/homeassistant/components/device_tracker/snmp.py @@ -0,0 +1,119 @@ +""" +homeassistant.components.device_tracker.snmp +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports fetching WiFi associations +through SNMP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.snmp.html +""" +import logging +from datetime import timedelta +import threading +import binascii + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import DOMAIN + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['pysnmp==4.2.5'] + +CONF_COMMUNITY = "community" +CONF_BASEOID = "baseoid" + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """ Validates config and returns an snmp scanner """ + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_COMMUNITY, CONF_BASEOID]}, + _LOGGER): + return None + + scanner = SnmpScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class SnmpScanner(object): + """ + This class queries any SNMP capable Acces Point for connected devices. + """ + def __init__(self, config): + self.host = config[CONF_HOST] + self.community = config[CONF_COMMUNITY] + self.baseoid = config[CONF_BASEOID] + + self.lock = threading.Lock() + + self.last_results = [] + + # Test the router is accessible + data = self.get_snmp_data() + self.success_init = data is not None + + def scan_devices(self): + """ + Scans for new devices and return a list containing found device IDs. + """ + + self._update_info() + return [client['mac'] for client in self.last_results] + + # Supressing no-self-use warning + # pylint: disable=R0201 + def get_device_name(self, device): + """ Returns the name of the given device or None if we don't know. """ + # We have no names + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the WAP is up to date. + Returns boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + data = self.get_snmp_data() + if not data: + return False + + self.last_results = data + return True + + def get_snmp_data(self): + """ Fetch mac addresses from WAP via SNMP. """ + from pysnmp.entity.rfc3413.oneliner import cmdgen + + devices = [] + + snmp = cmdgen.CommandGenerator() + errindication, errstatus, errindex, restable = snmp.nextCmd( + cmdgen.CommunityData(self.community), + cmdgen.UdpTransportTarget((self.host, 161)), + cmdgen.MibVariable(self.baseoid) + ) + + if errindication: + _LOGGER.error("SNMPLIB error: %s", errindication) + return + if errstatus: + _LOGGER.error('SNMP error: %s at %s', errstatus.prettyPrint(), + errindex and restable[-1][int(errindex)-1] + or '?') + return + + for resrow in restable: + for _, val in resrow: + mac = binascii.hexlify(val.asOctets()).decode('utf-8') + mac = ':'.join([mac[i:i+2] for i in range(0, len(mac), 2)]) + devices.append({'mac': mac}) + return devices diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 450019022e1..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.1'] +REQUIREMENTS = ['netdisco==0.4.2'] SCAN_INTERVAL = 300 # seconds diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 419e48d55b5..b327e510cd8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -11,6 +11,7 @@ import logging from . import version import homeassistant.util as util from homeassistant.const import URL_ROOT, HTTP_OK +from homeassistant.config import get_default_config_dir DOMAIN = 'frontend' DEPENDENCIES = ['api'] @@ -19,7 +20,6 @@ INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template') _LOGGER = logging.getLogger(__name__) - FRONTEND_URLS = [ URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', '/devEvent'] @@ -44,6 +44,9 @@ def setup(hass, config): hass.http.register_path( 'HEAD', re.compile(r'/static/(?P[a-zA-Z\._\-0-9/]+)'), _handle_get_static, False) + hass.http.register_path( + 'GET', re.compile(r'/local/(?P[a-zA-Z\._\-0-9/]+)'), + _handle_get_local, False) return True @@ -84,3 +87,16 @@ def _handle_get_static(handler, path_match, data): path = os.path.join(os.path.dirname(__file__), 'www_static', req_file) handler.write_file(path) + + +def _handle_get_local(handler, path_match, data): + """ + Returns a static file from the hass.config.path/www for the frontend. + """ + req_file = util.sanitize_path(path_match.group('file')) + + path = os.path.join(get_default_config_dir(), 'www', req_file) + if not os.path.isfile(path): + return False + + handler.write_file(path) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 5f913eae674..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 = "3a3ed81f9d66bf24e17f1d02b8403335" +VERSION = "c4722afa376379bc4457d54bb9a38cee" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 9277184b8a2..73fdb905114 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -4081,67 +4081,7 @@ subject to an additional IP rights grant found at http://polymer.github.io/PATEN font-size: 24px; padding: 24px 16px 16px; text-transform: capitalize; - } \ 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 6989009b2d5..3d6792691a3 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 6989009b2d59e39fd39b3025ff5899877f618bd3 +Subproject commit 3d6792691a3d6beae5d446a6fbeb83c9025d040d 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..c1b1579b4b5 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" @@ -246,6 +246,7 @@ def setup(hass, config): rgb_color = dat.get(ATTR_RGB_COLOR) if len(rgb_color) == 3: + params[ATTR_RGB_COLOR] = [int(val) for val in rgb_color] params[ATTR_XY_COLOR] = \ color_util.color_RGB_to_xy(int(rgb_color[0]), int(rgb_color[1]), @@ -275,11 +276,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/blinksticklight.py b/homeassistant/components/light/blinksticklight.py new file mode 100644 index 00000000000..3e8ba8b505d --- /dev/null +++ b/homeassistant/components/light/blinksticklight.py @@ -0,0 +1,76 @@ +""" +homeassistant.components.light.blinksticklight +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Blinkstick lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.blinksticklight.html +""" +import logging + +from blinkstick import blinkstick + +from homeassistant.components.light import (Light, ATTR_RGB_COLOR) + +_LOGGER = logging.getLogger(__name__) + + +REQUIREMENTS = ["blinkstick==1.1.7"] +DEPENDENCIES = [] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Add device specified by serial number. """ + stick = blinkstick.find_by_serial(config['serial']) + + add_devices_callback([BlinkStickLight(stick, config['name'])]) + + +class BlinkStickLight(Light): + """ Represents a BlinkStick light. """ + + def __init__(self, stick, name): + self._stick = stick + self._name = name + self._serial = stick.get_serial() + self._rgb_color = stick.get_color() + + @property + def should_poll(self): + """ Polling needed. """ + return True + + @property + def name(self): + """ The name of the light. """ + return self._name + + @property + def rgb_color(self): + """ Read back the color of the light. """ + return self._rgb_color + + @property + def is_on(self): + """ Check whether any of the LEDs colors are non-zero. """ + return sum(self._rgb_color) > 0 + + def update(self): + """ Read back the device state """ + self._rgb_color = self._stick.get_color() + + def turn_on(self, **kwargs): + """ Turn the device on. """ + if ATTR_RGB_COLOR in kwargs: + self._rgb_color = kwargs[ATTR_RGB_COLOR] + else: + self._rgb_color = [255, 255, 255] + + self._stick.set_color(red=self._rgb_color[0], + green=self._rgb_color[1], + blue=self._rgb_color[2]) + + def turn_off(self, **kwargs): + """ Turn the device off """ + self._stick.turn_off() 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/rfxtrx.py b/homeassistant/components/light/rfxtrx.py new file mode 100644 index 00000000000..5d6f41fe509 --- /dev/null +++ b/homeassistant/components/light/rfxtrx.py @@ -0,0 +1,112 @@ +""" +homeassistant.components.light.rfxtrx +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for RFXtrx lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.rfxtrx.html +""" +import logging +import homeassistant.components.rfxtrx as rfxtrx +import RFXtrx as rfxtrxmod + +from homeassistant.components.light import Light +from homeassistant.util import slugify + +DEPENDENCIES = ['rfxtrx'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Setup the RFXtrx platform. """ + lights = [] + devices = config.get('devices', None) + if devices: + for entity_id, entity_info in devices.items(): + if entity_id not in rfxtrx.RFX_DEVICES: + _LOGGER.info("Add %s rfxtrx.light", entity_info['name']) + rfxobject = rfxtrx.get_rfx_object(entity_info['packetid']) + new_light = RfxtrxLight(entity_info['name'], rfxobject, False) + rfxtrx.RFX_DEVICES[entity_id] = new_light + lights.append(new_light) + + add_devices_callback(lights) + + def light_update(event): + """ Callback for light updates from the RFXtrx gateway. """ + if not isinstance(event.device, rfxtrxmod.LightingDevice): + return + + # Add entity if not exist and the automatic_add is True + entity_id = slugify(event.device.id_string.lower()) + if entity_id not in rfxtrx.RFX_DEVICES: + automatic_add = config.get('automatic_add', False) + if not automatic_add: + return + + _LOGGER.info( + "Automatic add %s rfxtrx.light (Class: %s Sub: %s)", + entity_id, + event.device.__class__.__name__, + event.device.subtype + ) + pkt_id = "".join("{0:02x}".format(x) for x in event.data) + entity_name = "%s : %s" % (entity_id, pkt_id) + new_light = RfxtrxLight(entity_name, event, False) + rfxtrx.RFX_DEVICES[entity_id] = new_light + add_devices_callback([new_light]) + + # Check if entity exists or previously added automatically + if entity_id in rfxtrx.RFX_DEVICES: + if event.values['Command'] == 'On'\ + or event.values['Command'] == 'Off': + if event.values['Command'] == 'On': + rfxtrx.RFX_DEVICES[entity_id].turn_on() + else: + rfxtrx.RFX_DEVICES[entity_id].turn_off() + + # Subscribe to main rfxtrx events + if light_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: + rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(light_update) + + +class RfxtrxLight(Light): + """ Provides a RFXtrx light. """ + def __init__(self, name, event, state): + self._name = name + self._event = event + self._state = state + + @property + def should_poll(self): + """ No polling needed for a light. """ + return False + + @property + def name(self): + """ Returns the name of the light if any. """ + return self._name + + @property + def is_on(self): + """ True if light is on. """ + return self._state + + def turn_on(self, **kwargs): + """ Turn the light on. """ + + if hasattr(self, '_event') and self._event: + self._event.device.send_on(rfxtrx.RFXOBJECT.transport) + + self._state = True + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the light off. """ + + if hasattr(self, '_event') and self._event: + self._event.device.send_off(rfxtrx.RFXOBJECT.transport) + + self._state = False + 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 19ce1a06d4a..819dce499e9 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -6,10 +6,11 @@ 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 from tellcore.library import DirectCallbackDispatcher -REQUIREMENTS = ['tellcore-py==1.0.4'] +REQUIREMENTS = ['tellcore-py==1.1.2'] # pylint: disable=unused-argument @@ -23,12 +24,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return [] - # pylint: disable=no-member - if telldus.TelldusCore.callback_dispatcher is None: - dispatcher = DirectCallbackDispatcher() - core = telldus.TelldusCore(callback_dispatcher=dispatcher) - else: - core = telldus.TelldusCore() + core = telldus.TelldusCore(callback_dispatcher=DirectCallbackDispatcher()) switches_and_lights = core.devices() lights = [] @@ -41,9 +37,18 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ 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 - core.register_device_event(_device_event_callback) + 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) 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..294fccbb1f5 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 ( @@ -29,6 +31,7 @@ DISCOVERY_PLATFORMS = { } SERVICE_YOUTUBE_VIDEO = 'play_youtube_video' +SERVICE_PLAY_MEDIA = 'play_media' ATTR_MEDIA_VOLUME_LEVEL = 'volume_level' ATTR_MEDIA_VOLUME_MUTED = 'is_volume_muted' @@ -44,6 +47,8 @@ ATTR_MEDIA_TRACK = 'media_track' ATTR_MEDIA_SERIES_TITLE = 'media_series_title' ATTR_MEDIA_SEASON = 'media_season' ATTR_MEDIA_EPISODE = 'media_episode' +ATTR_MEDIA_CHANNEL = 'media_channel' +ATTR_MEDIA_PLAYLIST = 'media_playlist' ATTR_APP_ID = 'app_id' ATTR_APP_NAME = 'app_name' ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands' @@ -51,6 +56,9 @@ ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands' MEDIA_TYPE_MUSIC = 'music' MEDIA_TYPE_TVSHOW = 'tvshow' MEDIA_TYPE_VIDEO = 'movie' +MEDIA_TYPE_EPISODE = 'episode' +MEDIA_TYPE_CHANNEL = 'channel' +MEDIA_TYPE_PLAYLIST = 'playlist' SUPPORT_PAUSE = 1 SUPPORT_SEEK = 2 @@ -61,6 +69,7 @@ SUPPORT_NEXT_TRACK = 32 SUPPORT_YOUTUBE = 64 SUPPORT_TURN_ON = 128 SUPPORT_TURN_OFF = 256 +SUPPORT_PLAY_MEDIA = 512 YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/1.jpg' @@ -74,6 +83,7 @@ SERVICE_TO_METHOD = { SERVICE_MEDIA_PAUSE: 'media_pause', SERVICE_MEDIA_NEXT_TRACK: 'media_next_track', SERVICE_MEDIA_PREVIOUS_TRACK: 'media_previous_track', + SERVICE_PLAY_MEDIA: 'play_media', } ATTR_TO_PROPERTY = [ @@ -90,6 +100,8 @@ ATTR_TO_PROPERTY = [ ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SEASON, ATTR_MEDIA_EPISODE, + ATTR_MEDIA_CHANNEL, + ATTR_MEDIA_PLAYLIST, ATTR_APP_ID, ATTR_APP_NAME, ATTR_SUPPORTED_MEDIA_COMMANDS, @@ -178,6 +190,16 @@ def media_previous_track(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) +def play_media(hass, media_type, media_id, entity_id=None): + """ Send the media player the command for playing media. """ + data = {"media_type": media_type, "media_id": media_id} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data) + + def setup(hass, config): """ Track states and offer events for media_players. """ component = EntityComponent( @@ -186,6 +208,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 +224,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 +242,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 +260,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 +278,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. """ @@ -266,16 +295,40 @@ def setup(hass, config): if player.should_poll: player.update_ha_state(True) + def play_media_service(service): + """ Plays specified media_id on the media player. """ + media_type = service.data.get('media_type') + media_id = service.data.get('media_id') + + if media_type is None: + return + + if media_id is None: + return + + for player in component.extract_from_service(service): + player.play_media(media_type, media_id) + + if player.should_poll: + player.update_ha_state(True) + 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)) + + hass.services.register( + DOMAIN, SERVICE_PLAY_MEDIA, play_media_service, + descriptions.get(SERVICE_PLAY_MEDIA)) return True @@ -361,6 +414,16 @@ class MediaPlayerDevice(Entity): """ Episode of current playing media. (TV Show only) """ return None + @property + def media_channel(self): + """ Channel currently playing. """ + return None + + @property + def media_playlist(self): + """ Title of Playlist currently playing. """ + return None + @property def app_id(self): """ ID of the current running app. """ @@ -421,6 +484,10 @@ class MediaPlayerDevice(Entity): """ Plays a YouTube media. """ raise NotImplementedError() + def play_media(self, media_type, media_id): + """ Plays a piece of media. """ + raise NotImplementedError() + # No need to overwrite these. @property def support_pause(self): @@ -457,6 +524,11 @@ class MediaPlayerDevice(Entity): """ Boolean if YouTube is supported. """ return bool(self.supported_media_commands & SUPPORT_YOUTUBE) + @property + def support_play_media(self): + """ Boolean if play media command supported. """ + return bool(self.supported_media_commands & SUPPORT_PLAY_MEDIA) + def volume_up(self): """ volume_up media player. """ if self.volume_level < 1: diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 61223446e5f..6f622c9e0cc 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -90,6 +90,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class CastDevice(MediaPlayerDevice): """ Represents a Cast device on the network. """ + # pylint: disable=abstract-method # pylint: disable=too-many-public-methods def __init__(self, host): diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py new file mode 100644 index 00000000000..9db17416bda --- /dev/null +++ b/homeassistant/components/media_player/firetv.py @@ -0,0 +1,233 @@ +""" +homeassistant.components.media_player.firetv +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides control over an Amazon Fire TV (/stick) via +python-firetv, a Python 2.x module with a helper script +that exposes a HTTP server to fetch state and perform +actions. + +Steps to configure your Amazon Fire TV stick with Home Assistant: + +1. Turn on ADB Debugging on your Amazon Fire TV: + a. From the main (Launcher) screen, select Settings. + b. Select System > Developer Options. + c. Select ADB Debugging. +2. Find Amazon Fire TV device IP: + a. From the main (Launcher) screen, select Settings. + b. Select System > About > Network. +3. `pip install firetv[firetv-server]` into a Python 2.x environment +4. `firetv-server -d :5555`, background the process +5. Configure Home Assistant as follows: + +media_player: + platform: firetv + # optional: where firetv-server is running (default is 'localhost:5556') + host: localhost:5556 + # optional: device id (default is 'default') + device: livingroom-firetv + # optional: friendly name (default is 'Amazon Fire TV') + name: My Amazon Fire TV + +Note that python-firetv has support for multiple Amazon Fire TV devices. +If you have more than one configured, be sure to specify the device id used. +Run `firetv-server -h` and/or view the source for complete capabilities. + +Possible states are: + - off (TV screen is dark) + - standby (standard UI is active - not apps) + - idle (screen saver is active) + - play (video is playing) + - pause (video is paused) + - disconnected (can't communicate with device) +""" + +import logging +import requests + +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF, + STATE_UNKNOWN, STATE_STANDBY) + +from homeassistant.components.media_player import ( + MediaPlayerDevice, + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK) + +SUPPORT_FIRETV = SUPPORT_PAUSE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET + +DOMAIN = 'firetv' +DEVICE_LIST_URL = 'http://{0}/devices/list' +DEVICE_STATE_URL = 'http://{0}/devices/state/{1}' +DEVICE_ACTION_URL = 'http://{0}/devices/action/{1}/{2}' + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the firetv platform. """ + host = config.get('host', 'localhost:5556') + device_id = config.get('device', 'default') + try: + response = requests.get(DEVICE_LIST_URL.format(host)).json() + if device_id in response['devices'].keys(): + add_devices([ + FireTVDevice( + host, + device_id, + config.get('name', 'Amazon Fire TV') + ) + ]) + _LOGGER.info( + 'Device %s accessible and ready for control', device_id) + else: + _LOGGER.warn( + 'Device %s is not registered with firetv-server', device_id) + except requests.exceptions.RequestException: + _LOGGER.error('Could not connect to firetv-server at %s', host) + + +class FireTV(object): + """ firetv-server client. + + Should a native Python 3 ADB module become available, + python-firetv can support Python 3, it can be added + as a dependency, and this class can be dispensed of. + + For now, it acts as a client to the firetv-server + HTTP server (which must be running via Python 2). + """ + + def __init__(self, host, device_id): + self.host = host + self.device_id = device_id + + @property + def state(self): + """ Get the device state. + + An exception means UNKNOWN state. + """ + try: + response = requests.get( + DEVICE_STATE_URL.format( + self.host, + self.device_id + ) + ).json() + return response.get('state', STATE_UNKNOWN) + except requests.exceptions.RequestException: + _LOGGER.error( + 'Could not retrieve device state for %s', self.device_id) + return STATE_UNKNOWN + + def action(self, action_id): + """ Perform an action on the device. + + There is no action acknowledgment, so exceptions + result in a pass. + """ + try: + requests.get( + DEVICE_ACTION_URL.format( + self.host, + self.device_id, + action_id + ) + ) + except requests.exceptions.RequestException: + _LOGGER.error( + 'Action request for %s was not accepted for device %s', + action_id, self.device_id) + + +class FireTVDevice(MediaPlayerDevice): + """ Represents an Amazon Fire TV device on the network. """ + + def __init__(self, host, device, name): + self._firetv = FireTV(host, device) + self._name = name + self._state = STATE_UNKNOWN + + @property + def name(self): + """ Get the device name. """ + return self._name + + @property + def should_poll(self): + """ Device should be polled. """ + return True + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_FIRETV + + @property + def state(self): + """ State of the player. """ + return self._state + + def update(self): + """ Update device state. """ + self._state = { + 'idle': STATE_IDLE, + 'off': STATE_OFF, + 'play': STATE_PLAYING, + 'pause': STATE_PAUSED, + 'standby': STATE_STANDBY, + 'disconnected': STATE_UNKNOWN, + }.get(self._firetv.state, STATE_UNKNOWN) + + def turn_on(self): + """ Turns on the device. """ + self._firetv.action('turn_on') + + def turn_off(self): + """ Turns off the device. """ + self._firetv.action('turn_off') + + def media_play(self): + """ Send play commmand. """ + self._firetv.action('media_play') + + def media_pause(self): + """ Send pause command. """ + self._firetv.action('media_pause') + + def media_play_pause(self): + """ Send play/pause command. """ + self._firetv.action('media_play_pause') + + def volume_up(self): + """ Send volume up command. """ + self._firetv.action('volume_up') + + def volume_down(self): + """ Send volume down command. """ + self._firetv.action('volume_down') + + def media_previous_track(self): + """ Send previous track command (results in rewind). """ + self._firetv.action('media_previous') + + def media_next_track(self): + """ Send next track command (results in fast-forward). """ + self._firetv.action('media_next') + + def media_seek(self, position): + raise NotImplementedError() + + def mute_volume(self, mute): + raise NotImplementedError() + + def play_youtube(self, media_id): + raise NotImplementedError() + + def set_volume_level(self, volume): + raise NotImplementedError() diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py index ecbb144e033..70def719146 100644 --- a/homeassistant/components/media_player/itunes.py +++ b/homeassistant/components/media_player/itunes.py @@ -35,9 +35,10 @@ URL of your running version of iTunes-API. Example: http://192.168.1.50:8181 import logging from homeassistant.components.media_player import ( - MediaPlayerDevice, MEDIA_TYPE_MUSIC, SUPPORT_PAUSE, SUPPORT_SEEK, - SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + MediaPlayerDevice, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_PAUSE, + SUPPORT_SEEK, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, + SUPPORT_TURN_OFF, SUPPORT_PLAY_MEDIA, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_MEDIA_COMMANDS) from homeassistant.const import ( STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_ON) @@ -47,7 +48,8 @@ import requests _LOGGER = logging.getLogger(__name__) SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ + SUPPORT_PLAY_MEDIA SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_TURN_OFF @@ -118,6 +120,20 @@ class Itunes(object): """ Skips back and returns the current state. """ return self._command('previous') + def play_playlist(self, playlist_id_or_name): + """ Sets a playlist to be current and returns the current state. """ + response = self._request('GET', '/playlists') + playlists = response.get('playlists', []) + + found_playlists = \ + [playlist for playlist in playlists if + (playlist_id_or_name in [playlist["name"], playlist["id"]])] + + if len(found_playlists) > 0: + playlist = found_playlists[0] + path = '/playlists/' + playlist['id'] + '/play' + return self._request('PUT', path) + def artwork_url(self): """ Returns a URL of the current track's album art. """ return self._base_url + '/artwork' @@ -294,6 +310,11 @@ class ItunesDevice(MediaPlayerDevice): """ Album of current playing media. (Music track only) """ return self.current_album + @property + def media_playlist(self): + """ Title of the currently playing playlist. """ + return self.current_playlist + @property def supported_media_commands(self): """ Flags of media commands that are supported. """ @@ -329,6 +350,12 @@ class ItunesDevice(MediaPlayerDevice): response = self.client.previous() self.update_state(response) + def play_media(self, media_type, media_id): + """ play_media media player. """ + if media_type == MEDIA_TYPE_PLAYLIST: + response = self.client.play_playlist(media_id) + self.update_state(response) + class AirPlayDevice(MediaPlayerDevice): """ Represents an AirPlay device via an iTunes-API instance. """ diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 7bfd385f65b..2fe42e2e707 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -167,7 +167,7 @@ class KodiDevice(MediaPlayerDevice): def media_content_id(self): """ Content ID of current playing media. """ if self._item is not None: - return self._item['uniqueid'] + return self._item.get('uniqueid', None) @property def media_content_type(self): diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a43916f10e3..5fac9ecb0f0 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -1,117 +1,148 @@ """ homeassistant.components.media_player.plex ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Provides an interface to the Plex API. -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 +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.plex.html """ - 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_UNKNOWN) + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) +import homeassistant.util as util -REQUIREMENTS = ['https://github.com/miniconfig/python-plexapi/archive/' - '437e36dca3b7780dc0cb73941d662302c0cd2fa9.zip' +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 - +# pylint: disable=abstract-method, 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() - dev = plexserver.clients() - for device in dev: - if "PlayStation" not in device.name: - add_devices([PlexClient(device.name, plexserver)]) + 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 __init__(self, name, plexserver): - self.client = plexserver.client(name) - self._name = name - self._media = None - self.update() - self.server = plexserver + 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._name + return self.device.name or self.device.product or self.device.device @property def state(self): """ Returns the state of the device. """ - if self._media is None: - return STATE_IDLE - else: - state = self._media.get('state') + 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): - timeline = self.client.timeline() - for timeline_item in timeline: - if timeline_item.get('state') in ('playing', 'paused'): - self._media = timeline_item + 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._media is not None: - return self._media.get('ratingKey') + if self.session is not None: + return self.session.ratingKey @property def media_content_type(self): """ Content type of current playing media. """ - if self._media is None: + if self.session is None: return None - media_type = self.server.library.getByKey( - self.media_content_id).type + media_type = self.session.type if media_type == 'episode': return MEDIA_TYPE_TVSHOW elif media_type == 'movie': @@ -121,50 +152,42 @@ class PlexClient(MediaPlayerDevice): @property def media_duration(self): """ Duration of current playing media in seconds. """ - if self._media is not None: - total_time = self._media.get('duration') - return total_time + if self.session is not None: + return self.session.duration @property def media_image_url(self): """ Image url of current playing media. """ - if self._media is not None: - return self.server.library.getByKey(self.media_content_id).thumbUrl - return None + 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._media is not None: - return self.server.library.getByKey(self.media_content_id).title + if self.session is not None: + return self.session.title @property def media_season(self): - """ Season of curent playing media. (TV Show only) """ - if self._media is not None: - show_season = self.server.library.getByKey( - self.media_content_id).season().index - return show_season - return None + """ 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)""" - if self._media is not None: - series_title = self.server.library.getByKey( - self.media_content_id).show().title - return series_title - return None + """ 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) """ - if self._media is not None: - show_episode = self.server.library.getByKey( - self.media_content_id).index - return show_episode - return None + """ 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): @@ -173,16 +196,16 @@ class PlexClient(MediaPlayerDevice): def media_play(self): """ media_play media player. """ - self.client.play() + self.device.play({'type': 'video'}) def media_pause(self): """ media_pause media player. """ - self.client.pause() + self.device.pause({'type': 'video'}) def media_next_track(self): """ Send next track command. """ - self.client.skipNext() + self.device.skipNext({'type': 'video'}) def media_previous_track(self): """ Send previous track command. """ - self.client.skipPrevious() + self.device.skipPrevious({'type': 'video'}) diff --git a/homeassistant/startup/__init__.py b/homeassistant/components/media_player/services.yaml similarity index 100% rename from homeassistant/startup/__init__.py rename to homeassistant/components/media_player/services.yaml 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..fbddd8d1d26 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -1,15 +1,15 @@ """ -homeassistant.components.notify.mail +homeassistant.components.notify.smtp ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Mail (SMTP) notification service. Configuration: -To use the Mail notifier you will need to add something like the following +To use the smtp notifier you will need to add something like the following to your configuration.yaml file. notify: - platform: mail + platform: smtp server: MAIL_SERVER port: YOUR_SMTP_PORT sender: SENDER_EMAIL_ADDRESS @@ -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/notify/telegram.py b/homeassistant/components/notify/telegram.py new file mode 100644 index 00000000000..23b915baf1e --- /dev/null +++ b/homeassistant/components/notify/telegram.py @@ -0,0 +1,66 @@ +""" +homeassistant.components.notify.telegram +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Telegram platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.telegram.html +""" +import logging +import urllib + +from homeassistant.helpers import validate_config +from homeassistant.components.notify import ( + DOMAIN, ATTR_TITLE, BaseNotificationService) +from homeassistant.const import CONF_API_KEY + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['python-telegram-bot==2.8.7'] + + +def get_service(hass, config): + """ Get the Telegram notification service. """ + + if not validate_config(config, + {DOMAIN: [CONF_API_KEY, 'chat_id']}, + _LOGGER): + return None + + try: + import telegram + except ImportError: + _LOGGER.exception( + "Unable to import python-telegram-bot. " + "Did you maybe not install the 'python-telegram-bot' package?") + return None + + try: + bot = telegram.Bot(token=config[DOMAIN][CONF_API_KEY]) + username = bot.getMe()['username'] + _LOGGER.info("Telegram bot is' %s'", username) + except urllib.error.HTTPError: + _LOGGER.error("Please check your access token.") + return None + + return TelegramNotificationService( + config[DOMAIN][CONF_API_KEY], + config[DOMAIN]['chat_id']) + + +# pylint: disable=too-few-public-methods +class TelegramNotificationService(BaseNotificationService): + """ Implements notification service for Telegram. """ + + def __init__(self, api_key, chat_id): + import telegram + self._api_key = api_key + self._chat_id = chat_id + self.bot = telegram.Bot(token=self._api_key) + + def send_message(self, message="", **kwargs): + """ Send a message to a user. """ + + title = kwargs.get(ATTR_TITLE) + + self.bot.sendMessage(chat_id=self._chat_id, + text=title + " " + message) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py new file mode 100644 index 00000000000..0788986c91d --- /dev/null +++ b/homeassistant/components/rfxtrx.py @@ -0,0 +1,89 @@ +""" +homeassistant.components.rfxtrx +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Provides support for RFXtrx components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rfxtrx.html +""" +import logging +from homeassistant.util import slugify + +DEPENDENCIES = [] +REQUIREMENTS = ['https://github.com/Danielhiversen/pyRFXtrx/archive/0.2.zip' + + '#RFXtrx==0.2'] + +DOMAIN = "rfxtrx" +CONF_DEVICE = 'device' +CONF_DEBUG = 'debug' +RECEIVED_EVT_SUBSCRIBERS = [] +RFX_DEVICES = {} +_LOGGER = logging.getLogger(__name__) +RFXOBJECT = None + + +def setup(hass, config): + """ Setup the RFXtrx component. """ + + # Declare the Handle event + def handle_receive(event): + """ Callback all subscribers for RFXtrx gateway. """ + + # Log RFXCOM event + entity_id = slugify(event.device.id_string.lower()) + packet_id = "".join("{0:02x}".format(x) for x in event.data) + entity_name = "%s : %s" % (entity_id, packet_id) + _LOGGER.info("Receive RFXCOM event from %s => %s", + event.device, entity_name) + + # Callback to HA registered components + for subscriber in RECEIVED_EVT_SUBSCRIBERS: + subscriber(event) + + # Try to load the RFXtrx module + try: + import RFXtrx as rfxtrxmod + except ImportError: + _LOGGER.exception("Failed to import rfxtrx") + return False + + # Init the rfxtrx module + global RFXOBJECT + + if CONF_DEVICE not in config[DOMAIN]: + _LOGGER.exception( + "can found device parameter in %s YAML configuration section", + DOMAIN + ) + return False + + device = config[DOMAIN][CONF_DEVICE] + debug = config[DOMAIN].get(CONF_DEBUG, False) + + RFXOBJECT = rfxtrxmod.Core(device, handle_receive, debug=debug) + + return True + + +def get_rfx_object(packetid): + """ Return the RFXObject with the packetid. """ + try: + import RFXtrx as rfxtrxmod + except ImportError: + _LOGGER.exception("Failed to import rfxtrx") + return False + + binarypacket = bytearray.fromhex(packetid) + + pkt = rfxtrxmod.lowlevel.parse(binarypacket) + if pkt is not None: + if isinstance(pkt, rfxtrxmod.lowlevel.SensorPacket): + obj = rfxtrxmod.SensorEvent(pkt) + elif isinstance(pkt, rfxtrxmod.lowlevel.Status): + obj = rfxtrxmod.StatusEvent(pkt) + else: + obj = rfxtrxmod.ControlEvent(pkt) + + return obj + + return None 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/arest.py b/homeassistant/components/sensor/arest.py index cfe88e0f0d6..6a11aa7189c 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -3,51 +3,11 @@ homeassistant.components.sensor.arest ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The arest sensor will consume an exposed aREST API of a device. -Configuration: - -To use the arest sensor you will need to add something like the following -to your configuration.yaml file. - -sensor: - platform: arest - resource: http://IP_ADDRESS - monitored_variables: - - name: temperature - unit: '°C' - - name: humidity - unit: '%' - -Variables: - -resource: -*Required -IP address of the device that is exposing an aREST API. - -These are the variables for the monitored_variables array: - -name -*Required -The name of the variable you wish to monitor. - -unit -*Optional -Defines the units of measurement of the sensor, if any. - -Details for the API: http://arest.io - -Format of a default JSON response by aREST: -{ - "variables":{ - "temperature":21, - "humidity":89 - }, - "id":"device008", - "name":"Bedroom", - "connected":true -} +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.arest.html """ import logging -from requests import get, exceptions +import requests from datetime import timedelta from homeassistant.util import Throttle @@ -58,36 +18,42 @@ _LOGGER = logging.getLogger(__name__) # Return cached results if last scan was less then this time ago MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +CONF_RESOURCE = 'resource' +CONF_MONITORED_VARIABLES = 'monitored_variables' + def setup_platform(hass, config, add_devices, discovery_info=None): """ Get the aREST sensor. """ - resource = config.get('resource', None) + resource = config.get(CONF_RESOURCE) + var_conf = config.get(CONF_MONITORED_VARIABLES) + + if None in (resource, var_conf): + _LOGGER.error('Not all required config keys present: %s', + ', '.join((CONF_RESOURCE, CONF_MONITORED_VARIABLES))) + return False try: - response = get(resource, timeout=10) - except exceptions.MissingSchema: + response = requests.get(resource, timeout=10).json() + except requests.exceptions.MissingSchema: _LOGGER.error("Missing resource or schema in configuration. " "Add http:// to your URL.") return False - except exceptions.ConnectionError: + except requests.exceptions.ConnectionError: _LOGGER.error("No route to device. " "Please check the IP address in the configuration file.") return False - rest = ArestData(resource) + arest = ArestData(resource) dev = [] for variable in config['monitored_variables']: - if 'unit' not in variable: - variable['unit'] = ' ' - if variable['name'] not in response.json()['variables']: + if variable['name'] not in response['variables']: _LOGGER.error('Variable: "%s" does not exist', variable['name']) - else: - dev.append(ArestSensor(rest, - response.json()['name'], - variable['name'], - variable['unit'])) + continue + + dev.append(ArestSensor(arest, response['name'], variable['name'], + variable.get('unit'))) add_devices(dev) @@ -95,8 +61,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ArestSensor(Entity): """ Implements an aREST sensor. """ - def __init__(self, rest, location, variable, unit_of_measurement): - self.rest = rest + def __init__(self, arest, location, variable, unit_of_measurement): + self.arest = arest self._name = '{} {}'.format(location.title(), variable.title()) self._variable = variable self._state = 'n/a' @@ -116,17 +82,16 @@ class ArestSensor(Entity): @property def state(self): """ Returns the state of the device. """ - return self._state - - def update(self): - """ Gets the latest data from aREST API and updates the state. """ - self.rest.update() - values = self.rest.data + values = self.arest.data if 'error' in values: - self._state = values['error'] + return values['error'] else: - self._state = values[self._variable] + return values.get(self._variable, 'n/a') + + def update(self): + """ Gets the latest data from aREST API. """ + self.arest.update() # pylint: disable=too-few-public-methods @@ -135,16 +100,14 @@ class ArestData(object): def __init__(self, resource): self.resource = resource - self.data = dict() + self.data = {} @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """ Gets the latest data from aREST device. """ try: - response = get(self.resource, timeout=10) - if 'error' in self.data: - del self.data['error'] + response = requests.get(self.resource, timeout=10) self.data = response.json()['variables'] - except exceptions.ConnectionError: + except requests.exceptions.ConnectionError: _LOGGER.error("No route to device. Is device offline?") - self.data['error'] = 'n/a' + self.data = {'error': 'error fetching'} diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index 60e84059cad..84c62b26469 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -17,6 +17,26 @@ Variables: port *Required Port of your connection to your MySensors device. + +debug +*Optional +Enable or disable verbose debug logging. + +persistence +*Optional +Enable or disable local persistence of sensor information. +Note: If this is disabled, then each sensor will need to send presentation + messages after Home Assistant starts + +persistence_file +*Optional +Path to a file to save sensor information. +Note: The file extension determines the file type. Currently supported file + types are 'pickle' and 'json'. + +version +*Optional +Specifies the MySensors protocol version to use (ex. 1.4, 1.5). """ import logging @@ -30,14 +50,16 @@ from homeassistant.const import ( CONF_PORT = "port" CONF_DEBUG = "debug" CONF_PERSISTENCE = "persistence" +CONF_PERSISTENCE_FILE = "persistence_file" +CONF_VERSION = "version" ATTR_NODE_ID = "node_id" ATTR_CHILD_ID = "child_id" _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/theolind/pymysensors/archive/' - '35b87d880147a34107da0d40cb815d75e6cb4af7.zip' - '#pymysensors==0.2'] + 'd4b809c2167650691058d1e29bfd2c4b1792b4b0.zip' + '#pymysensors==0.3'] def setup_platform(hass, config, add_devices, discovery_info=None): @@ -86,9 +108,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False persistence = config.get(CONF_PERSISTENCE, True) + persistence_file = config.get(CONF_PERSISTENCE_FILE, 'mysensors.pickle') + version = config.get(CONF_VERSION, '1.4') gateway = mysensors.SerialGateway(port, sensor_update, - persistence=persistence) + persistence=persistence, + persistence_file=persistence_file, + protocol_version=version) gateway.metric = is_metric gateway.debug = config.get(CONF_DEBUG, False) gateway.start() 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/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 4cb8a939d5e..07912b719d2 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -3,30 +3,19 @@ homeassistant.components.sensor.rfxtrx ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Shows sensor values from RFXtrx sensors. -Configuration: - -To use the rfxtrx sensors you will need to add something like the following to -your configuration.yaml file. - -sensor: - platform: rfxtrx - device: PATH_TO_DEVICE - -Variables: - -device -*Required -Path to your RFXtrx device. -E.g. /dev/serial/by-id/usb-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0 +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rfxtrx.html """ import logging from collections import OrderedDict from homeassistant.const import (TEMP_CELCIUS) from homeassistant.helpers.entity import Entity +import homeassistant.components.rfxtrx as rfxtrx +from RFXtrx import SensorEvent +from homeassistant.util import slugify -REQUIREMENTS = ['https://github.com/Danielhiversen/pyRFXtrx/archive/' + - 'ec7a1aaddf8270db6e5da1c13d58c1547effd7cf.zip#RFXtrx==0.15'] +DEPENDENCIES = ['rfxtrx'] DATA_TYPES = OrderedDict([ ('Temperature', TEMP_CELCIUS), @@ -34,32 +23,30 @@ DATA_TYPES = OrderedDict([ ('Barometer', ''), ('Wind direction', ''), ('Rain rate', '')]) +_LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Setup the RFXtrx platform. """ - logger = logging.getLogger(__name__) - - sensors = {} # keep track of sensors added to HA def sensor_update(event): """ Callback for sensor updates from the RFXtrx gateway. """ - if event.device.id_string in sensors: - sensors[event.device.id_string].event = event - else: - logger.info("adding new sensor: %s", event.device.type_string) - new_sensor = RfxtrxSensor(event) - sensors[event.device.id_string] = new_sensor - add_devices([new_sensor]) - try: - import RFXtrx as rfxtrx - except ImportError: - logger.exception( - "Failed to import rfxtrx") - return False + if isinstance(event.device, SensorEvent): + entity_id = slugify(event.device.id_string.lower()) - device = config.get("device", "") - rfxtrx.Core(device, sensor_update) + # Add entity if not exist and the automatic_add is True + if entity_id not in rfxtrx.RFX_DEVICES: + automatic_add = config.get('automatic_add', True) + if automatic_add: + _LOGGER.info("Automatic add %s rfxtrx.sensor", entity_id) + new_sensor = RfxtrxSensor(event) + rfxtrx.RFX_DEVICES[entity_id] = new_sensor + add_devices_callback([new_sensor]) + else: + rfxtrx.RFX_DEVICES[entity_id].event = event + + if sensor_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: + rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(sensor_update) class RfxtrxSensor(Entity): @@ -67,7 +54,6 @@ class RfxtrxSensor(Entity): def __init__(self, event): self.event = event - self._unit_of_measurement = None self._data_type = None for data_type in DATA_TYPES: @@ -86,13 +72,14 @@ class RfxtrxSensor(Entity): @property def state(self): + """ Returns the state of the device. """ if self._data_type: return self.event.values[self._data_type] return None @property def name(self): - """ Get the mame of the sensor. """ + """ Get the name of the sensor. """ return self._name @property 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/shell_command.py b/homeassistant/components/shell_command.py new file mode 100644 index 00000000000..2fceaf71519 --- /dev/null +++ b/homeassistant/components/shell_command.py @@ -0,0 +1,48 @@ +""" +homeassistant.components.shell_command +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Component to expose shell commands as services. + +shell_command: + restart_pow: touch ~/.pow/restart.txt + +""" +import logging +import subprocess + +from homeassistant.util import slugify + +DOMAIN = 'shell_command' +DEPENDENCIES = [] + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ Sets up the shell_command component. """ + conf = config.get(DOMAIN) + + if not isinstance(conf, dict): + _LOGGER.error('Expected configuration to be a dictionary') + return False + + for name in conf.keys(): + if name != slugify(name): + _LOGGER.error('Invalid service name: %s. Try %s', + name, slugify(name)) + return False + + def service_handler(call): + """ Execute a shell command service. """ + try: + subprocess.call(conf[call.service].split(' '), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except subprocess.SubprocessError: + _LOGGER.exception('Error running command') + + for name in conf.keys(): + hass.services.register(DOMAIN, name, service_handler) + + return True 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/arest.py b/homeassistant/components/switch/arest.py index 239e24a4925..ec04e3c210f 100644 --- a/homeassistant/components/switch/arest.py +++ b/homeassistant/components/switch/arest.py @@ -3,38 +3,9 @@ homeassistant.components.switch.arest ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The arest switch can control the digital pins of a device running with the aREST RESTful framework for Arduino, the ESP8266, and the Raspberry Pi. -Only tested with Arduino boards so far. -Configuration: - -To use the arest switch you will need to add something like the following -to your configuration.yaml file. - -sensor: - platform: arest - resource: http://IP_ADDRESS - pins: - 11: - name: Fan Office - 12: - name: Light Desk - -Variables: - -resource: -*Required -IP address of the device that is exposing an aREST API. - -pins: -The number of the digital pin to switch. - -These are the variables for the pins array: - -name -*Required -The name for the pin that will be used in the frontend. - -Details for the API: http://arest.io +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.arest.html """ import logging from requests import get, exceptions diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py new file mode 100644 index 00000000000..49a788d0d04 --- /dev/null +++ b/homeassistant/components/switch/rfxtrx.py @@ -0,0 +1,112 @@ +""" +homeassistant.components.switch.rfxtrx +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for RFXtrx switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.rfxtrx.html +""" +import logging +import homeassistant.components.rfxtrx as rfxtrx +from RFXtrx import LightingDevice + +from homeassistant.components.switch import SwitchDevice +from homeassistant.util import slugify + +DEPENDENCIES = ['rfxtrx'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Setup the RFXtrx platform. """ + + # Add switch from config file + switchs = [] + devices = config.get('devices') + if devices: + for entity_id, entity_info in devices.items(): + if entity_id not in rfxtrx.RFX_DEVICES: + _LOGGER.info("Add %s rfxtrx.switch", entity_info['name']) + rfxobject = rfxtrx.get_rfx_object(entity_info['packetid']) + newswitch = RfxtrxSwitch(entity_info['name'], rfxobject, False) + rfxtrx.RFX_DEVICES[entity_id] = newswitch + switchs.append(newswitch) + + add_devices_callback(switchs) + + def switch_update(event): + """ Callback for sensor updates from the RFXtrx gateway. """ + if isinstance(event.device, LightingDevice): + return + + # Add entity if not exist and the automatic_add is True + entity_id = slugify(event.device.id_string.lower()) + if entity_id not in rfxtrx.RFX_DEVICES: + automatic_add = config.get('automatic_add', False) + if not automatic_add: + return + + _LOGGER.info( + "Automatic add %s rfxtrx.switch (Class: %s Sub: %s)", + entity_id, + event.device.__class__.__name__, + event.device.subtype + ) + pkt_id = "".join("{0:02x}".format(x) for x in event.data) + entity_name = "%s : %s" % (entity_id, pkt_id) + new_switch = RfxtrxSwitch(entity_name, event, False) + rfxtrx.RFX_DEVICES[entity_id] = new_switch + add_devices_callback([new_switch]) + + # Check if entity exists or previously added automatically + if entity_id in rfxtrx.RFX_DEVICES: + if event.values['Command'] == 'On'\ + or event.values['Command'] == 'Off': + if event.values['Command'] == 'On': + rfxtrx.RFX_DEVICES[entity_id].turn_on() + else: + rfxtrx.RFX_DEVICES[entity_id].turn_off() + + # Subscribe to main rfxtrx events + if switch_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: + rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(switch_update) + + +class RfxtrxSwitch(SwitchDevice): + """ Provides a RFXtrx switch. """ + def __init__(self, name, event, state): + self._name = name + self._event = event + self._state = state + + @property + def should_poll(self): + """ No polling needed for a RFXtrx switch. """ + 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_on(self, **kwargs): + """ Turn the device on. """ + if self._event: + self._event.device.send_on(rfxtrx.RFXOBJECT.transport) + + self._state = True + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + if self._event: + self._event.device.send_off(rfxtrx.RFXOBJECT.transport) + + self._state = False + self.update_ha_state() 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 96b10a0a977..1a0f7097b52 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -11,13 +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 @@ -30,12 +31,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return - # pylint: disable=no-member - if telldus.TelldusCore.callback_dispatcher is None: - dispatcher = DirectCallbackDispatcher() - core = telldus.TelldusCore(callback_dispatcher=dispatcher) - else: - core = telldus.TelldusCore() + core = telldus.TelldusCore(callback_dispatcher=DirectCallbackDispatcher()) signal_repetitions = config.get('signal_repetitions', SINGAL_REPETITIONS) @@ -52,9 +48,17 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ 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(True) + switch_device.update_ha_state() + break - core.register_device_event(_device_event_callback) + 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) 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..b021ec86c35 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 @@ -129,22 +136,16 @@ class ThermostatDevice(Entity): def state_attributes(self): """ Returns optional state attributes. """ - thermostat_unit = self.unit_of_measurement - user_unit = self.hass.config.temperature_unit - data = { - ATTR_CURRENT_TEMPERATURE: round(convert( - self.current_temperature, thermostat_unit, user_unit), 1), - ATTR_MIN_TEMP: round(convert( - self.min_temp, thermostat_unit, user_unit), 0), - ATTR_MAX_TEMP: round(convert( - self.max_temp, thermostat_unit, user_unit), 0), - ATTR_TEMPERATURE: round(convert( - self.target_temperature, thermostat_unit, user_unit), 0), - ATTR_TEMPERATURE_LOW: round(convert( - self.target_temperature_low, thermostat_unit, user_unit), 0), - ATTR_TEMPERATURE_HIGH: round(convert( - self.target_temperature_high, thermostat_unit, user_unit), 0), + ATTR_CURRENT_TEMPERATURE: + self._convert(self.current_temperature, 1), + ATTR_MIN_TEMP: self._convert(self.min_temp, 0), + ATTR_MAX_TEMP: self._convert(self.max_temp, 0), + ATTR_TEMPERATURE: self._convert(self.target_temperature, 0), + ATTR_TEMPERATURE_LOW: + self._convert(self.target_temperature_low, 0), + ATTR_TEMPERATURE_HIGH: + self._convert(self.target_temperature_high, 0), } operation = self.operation @@ -221,3 +222,14 @@ class ThermostatDevice(Entity): def max_temp(self): """ Return maxmum temperature. """ return convert(35, TEMP_CELCIUS, self.unit_of_measurement) + + def _convert(self, temp, round_dec=None): + """ Convert temperature from this thermost into user preferred + temperature. """ + if temp is None: + return None + + value = convert(temp, self.unit_of_measurement, + self.hass.config.temperature_unit) + + return value if round_dec is None else round(value, round_dec) 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 c644f6883d1..278ffea218a 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.4dev0" +__version__ = "0.7.6.dev0" # 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 d0494e070f6..b834efce406 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -446,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. """ @@ -525,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. """ @@ -559,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, 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..24a37c5b5ea 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -9,7 +9,11 @@ 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) + +from homeassistant.components.media_player import (SERVICE_PLAY_MEDIA) _LOGGER = logging.getLogger(__name__) @@ -55,7 +59,15 @@ def reproduce_state(hass, states, blocking=False): state.entity_id) continue - if state.state == STATE_ON: + if state.domain == 'media_player' and state.attributes and \ + 'media_type' in state.attributes and \ + 'media_id' in state.attributes: + service = SERVICE_PLAY_MEDIA + elif 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/__init__.py b/homeassistant/util/__init__.py index 805937376a0..ada6d150188 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -233,35 +233,55 @@ class Throttle(object): self.limit_no_throttle = limit_no_throttle def __call__(self, method): - lock = threading.Lock() - if self.limit_no_throttle is not None: method = Throttle(self.limit_no_throttle)(method) + # Different methods that can be passed in: + # - a function + # - an unbound function on a class + # - a method (bound function on a class) + + # We want to be able to differentiate between function and unbound + # methods (which are considered functions). + # All methods have the classname in their qualname seperated by a '.' + # Functions have a '.' in their qualname if defined inline, but will + # be prefixed by '..' so we strip that out. + is_func = (not hasattr(method, '__self__') and + '.' not in method.__qualname__.split('..')[-1]) + @wraps(method) def wrapper(*args, **kwargs): """ Wrapper that allows wrapped to be called only once per min_time. If we cannot acquire the lock, it is running so return None. """ - if not lock.acquire(False): + # pylint: disable=protected-access + if hasattr(method, '__self__'): + host = method.__self__ + elif is_func: + host = wrapper + else: + host = args[0] if args else wrapper + + if not hasattr(host, '_throttle_lock'): + host._throttle_lock = threading.Lock() + + if not host._throttle_lock.acquire(False): return None + + last_call = getattr(host, '_throttle_last_call', None) + # Check if method is never called or no_throttle is given + force = not last_call or kwargs.pop('no_throttle', False) + try: - last_call = wrapper.last_call - - # Check if method is never called or no_throttle is given - force = not last_call or kwargs.pop('no_throttle', False) - if force or utcnow() - last_call > self.min_time: result = method(*args, **kwargs) - wrapper.last_call = utcnow() + host._throttle_last_call = utcnow() return result else: return None finally: - lock.release() - - wrapper.last_call = None + host._throttle_lock.release() return wrapper diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index ade15131a8f..398a0a0c56c 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -1,8 +1,8 @@ """Module with location helpers.""" import collections -from math import radians, cos, sin, asin, sqrt import requests +from vincenty import vincenty LocationInfo = collections.namedtuple( @@ -31,18 +31,6 @@ def detect_location_info(): return LocationInfo(**data) -# From: http://stackoverflow.com/a/4913653/646416 -def distance(lon1, lat1, lon2, lat2): - """ - Calculate the great circle distance in meters between two points specified - in decimal degrees on the earth using the Haversine algorithm. - """ - # convert decimal degrees to radians - lon1, lat1, lon2, lat2 = (radians(val) for val in (lon1, lat1, lon2, lat2)) - - dlon = lon2 - lon1 - dlat = lat2 - lat1 - angle = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 - # Radius of earth in meters. - radius = 6371000 - return 2 * radius * asin(sqrt(angle)) +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 77f725ed4f0..12797da4e5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,93 +3,94 @@ requests>=2,<3 pyyaml>=3.11,<4 pytz>=2015.4 pip>=7.0.0 +vincenty==0.1.2 # Optional, needed for specific components # Sun (sun) astral==0.8.1 -# Philips Hue library (lights.hue) +# Philips Hue (lights.hue) phue==0.8 -# Limitlessled/Easybulb/Milight library (lights.limitlessled) -ledcontroller==1.0.7 +# Limitlessled/Easybulb/Milight (lights.limitlessled) +ledcontroller==1.1.0 -# Chromecast bindings (media_player.cast) +# Chromecast (media_player.cast) pychromecast==0.6.12 # Keyboard (keyboard) pyuserinput==0.1.9 -# Tellstick bindings (*.tellstick) -tellcore-py==1.0.4 +# Tellstick (*.tellstick) +tellcore-py==1.1.2 -# Nmap bindings (device_tracker.nmap) +# Nmap (device_tracker.nmap) python-nmap==0.4.3 -# PushBullet bindings (notify.pushbullet) +# PushBullet (notify.pushbullet) pushbullet.py==0.7.1 -# Nest Thermostat bindings (thermostat.nest) +# Nest Thermostat (thermostat.nest) python-nest==2.6.0 # Z-Wave (*.zwave) pydispatcher==2.0.5 -# ISY994 bindings (*.isy994) +# ISY994 (isy994) PyISY==1.0.5 # PSutil (sensor.systemmonitor) psutil==3.0.0 -# Pushover bindings (notify.pushover) +# Pushover (notify.pushover) python-pushover==0.2 # Transmission Torrent Client (*.transmission) transmissionrpc==0.11 -# OpenWeatherMap Web API (sensor.openweathermap) +# OpenWeatherMap (sensor.openweathermap) pyowm==2.2.1 -# XMPP Bindings (notify.xmpp) +# XMPP (notify.xmpp) sleekxmpp==1.3.1 dnspython3==1.12.0 # Blockchain (sensor.bitcoin) blockchain==1.1.2 -# MPD Bindings (media_player.mpd) +# Music Player Daemon (media_player.mpd) python-mpd2==0.5.4 # Hikvision (switch.hikvisioncam) hikvision==0.4 -# console log coloring +# Console log coloring colorlog==2.6.0 # JSON-RPC interface (media_player.kodi) jsonrpc-requests==0.1 -# Forecast.io Bindings (sensor.forecast) +# Forecast.io (sensor.forecast) python-forecastio==1.3.3 -# Firmata Bindings (*.arduino) +# Firmata (*.arduino) PyMata==2.07a -# Rfxtrx sensor (sensor.rfxtrx) +# Rfxtrx (rfxtrx) https://github.com/Danielhiversen/pyRFXtrx/archive/ec7a1aaddf8270db6e5da1c13d58c1547effd7cf.zip#RFXtrx==0.15 -# Mysensors -https://github.com/theolind/pymysensors/archive/35b87d880147a34107da0d40cb815d75e6cb4af7.zip#pymysensors==0.2 +# Mysensors (sensor.mysensors) +https://github.com/theolind/pymysensors/archive/d4b809c2167650691058d1e29bfd2c4b1792b4b0.zip#pymysensors==0.3 # Netgear (device_tracker.netgear) pynetgear==0.3 # Netdisco (discovery) -netdisco==0.4.1 +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 @@ -100,18 +101,18 @@ slacker==0.6.8 # Temper sensors (sensor.temper) https://github.com/rkabadi/temper-python/archive/3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip#temperusb==1.2.3 -# PyEdimax +# PyEdimax (switch.edimax) https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 # RPI-GPIO platform (*.rpi_gpio) # Uncomment for Raspberry Pi # RPi.GPIO==0.5.11 -# Adafruit temperature/humidity sensor -# uncomment on a Raspberry Pi / Beaglebone +# Adafruit temperature/humidity sensor (sensor.dht) +# Uncomment on a Raspberry Pi / Beaglebone # http://github.com/mala-zaba/Adafruit_Python_DHT/archive/4101340de8d2457dd194bca1e8d11cbfc237e919.zip#Adafruit_DHT==1.1.0 -# PAHO MQTT Binding (mqtt) +# PAHO MQTT (mqtt) paho-mqtt==1.1 # PyModbus (modbus) @@ -120,19 +121,26 @@ https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b6 # Verisure (verisure) https://github.com/persandstrom/python-verisure/archive/9873c4527f01b1ba1f72ae60f7f35854390d59be.zip#python-verisure==0.2.6 -# Python tools for interacting with IFTTT Maker Channel (ifttt) +# IFTTT Maker Channel (ifttt) pyfttt==0.3 -# sensor.sabnzbd +# SABnzbd (sensor.sabnzbd) https://github.com/balloob/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 -# switch.vera -# sensor.vera -# light.vera +# Vera (*.vera) https://github.com/balloob/home-assistant-vera-api/archive/a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip#python-vera==0.1 -# Sonos bindings (media_player.sonos) +# Sonos (media_player.sonos) SoCo==0.11.1 # PlexAPI (media_player.plex) -https://github.com/miniconfig/python-plexapi/archive/437e36dca3b7780dc0cb73941d662302c0cd2fa9.zip#python-plexapi==1.0.2 +https://github.com/adrienbrault/python-plexapi/archive/df2d0847e801d6d5cda920326d693cf75f304f1a.zip#python-plexapi==1.0.2 + +# SNMP (device_tracker.snmp) +pysnmp==4.2.5 + +# Blinkstick (light.blinksticklight) +blinkstick==1.1.7 + +# Telegram (notify.telegram) +python-telegram-bot==2.8.7 diff --git a/setup.py b/setup.py index fde7f9bf898..b9b5cdd0d5d 100755 --- a/setup.py +++ b/setup.py @@ -9,19 +9,22 @@ DOWNLOAD_URL = ('https://github.com/balloob/home-assistant/archive/' PACKAGES = find_packages(exclude=['tests', 'tests.*']) -PACKAGE_DATA = \ - {'homeassistant.components.frontend': ['index.html.template'], - 'homeassistant.components.frontend.www_static': ['*.*'], - 'homeassistant.components.frontend.www_static.images': ['*.*'], - 'homeassistant.startup': ['*.*']} +# PACKAGE_DATA = \ +# {'homeassistant.components.frontend': ['index.html.template'], +# 'homeassistant.components.frontend.www_static': ['*.*'], +# 'homeassistant.components.frontend.www_static.images': ['*.*'], +# 'homeassistant.components.mqtt': ['*.crt'], +# 'homeassistant.startup': ['*.*']} REQUIRES = [ 'requests>=2,<3', 'pyyaml>=3.11,<4', 'pytz>=2015.4', 'pip>=7.0.0', + 'vincenty==0.1.2' ] + # package_data=PACKAGE_DATA, setup( name=PACKAGE_NAME, version=__version__, @@ -33,7 +36,6 @@ setup( description='Open-source home automation platform running on Python 3.', packages=PACKAGES, include_package_data=True, - package_data=PACKAGE_DATA, zip_safe=False, platforms='any', install_requires=REQUIRES, 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_state.py b/tests/components/automation/test_state.py index a7c13e866c6..a31f694f8c0 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -8,6 +8,7 @@ import unittest import homeassistant.core as ha import homeassistant.components.automation as automation +import homeassistant.components.automation.state as state class TestAutomationState(unittest.TestCase): @@ -334,3 +335,19 @@ class TestAutomationState(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + + def test_if_fails_setup_if_to_boolean_value(self): + self.assertFalse(state.trigger( + self.hass, { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': True, + }, lambda x: x)) + + def test_if_fails_setup_if_from_boolean_value(self): + self.assertFalse(state.trigger( + self.hass, { + 'platform': 'state', + 'entity_id': 'test.entity', + 'from': True, + }, lambda x: x)) 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/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/components/test_light.py b/tests/components/test_light.py index 515b79b6fc0..156ba51e59a 100644 --- a/tests/components/test_light.py +++ b/tests/components/test_light.py @@ -152,9 +152,13 @@ class TestLight(unittest.TestCase): data) method, data = dev2.last_call('turn_on') - self.assertEqual( - {light.ATTR_XY_COLOR: color_util.color_RGB_to_xy(255, 255, 255)}, - data) + self.assertEquals( + data[light.ATTR_XY_COLOR], + color_util.color_RGB_to_xy(255, 255, 255)) + + self.assertEquals( + data[light.ATTR_RGB_COLOR], + [255, 255, 255]) method, data = dev3.last_call('turn_on') self.assertEqual({light.ATTR_XY_COLOR: [.4, .6]}, data) diff --git a/tests/components/test_media_player.py b/tests/components/test_media_player.py index 28d39206c47..211626ea3fb 100644 --- a/tests/components/test_media_player.py +++ b/tests/components/test_media_player.py @@ -40,7 +40,7 @@ class TestMediaPlayer(unittest.TestCase): def test_services(self): """ - Test if the call service methods conver to correct service calls. + Test if the call service methods convert to correct service calls. """ services = { SERVICE_TURN_ON: media_player.turn_on, diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py new file mode 100644 index 00000000000..d9248d8f861 --- /dev/null +++ b/tests/components/test_shell_command.py @@ -0,0 +1,71 @@ +""" +tests.test_shell_command +~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import os +import tempfile +import unittest +from unittest.mock import patch +from subprocess import SubprocessError + +from homeassistant import core +from homeassistant.components import shell_command + + +class TestShellCommand(unittest.TestCase): + """ Test the demo module. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = core.HomeAssistant() + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_executing_service(self): + """ Test if able to call a configured service. """ + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, 'called.txt') + self.assertTrue(shell_command.setup(self.hass, { + 'shell_command': { + 'test_service': "touch {}".format(path) + } + })) + + self.hass.services.call('shell_command', 'test_service', + blocking=True) + + self.assertTrue(os.path.isfile(path)) + + def test_config_not_dict(self): + """ Test if config is not a dict. """ + self.assertFalse(shell_command.setup(self.hass, { + 'shell_command': ['some', 'weird', 'list'] + })) + + def test_config_not_valid_service_names(self): + """ Test if config contains invalid service names. """ + self.assertFalse(shell_command.setup(self.hass, { + 'shell_command': { + 'this is invalid because space': 'touch bla.txt' + }})) + + @patch('homeassistant.components.shell_command.subprocess.call', + side_effect=SubprocessError) + @patch('homeassistant.components.shell_command._LOGGER.error') + def test_subprocess_raising_error(self, mock_call, mock_error): + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, 'called.txt') + self.assertTrue(shell_command.setup(self.hass, { + 'shell_command': { + 'test_service': "touch {}".format(path) + } + })) + + self.hass.services.call('shell_command', 'test_service', + blocking=True) + + self.assertFalse(os.path.isfile(path)) + self.assertEqual(1, mock_error.call_count) 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) diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 94358f5eb51..2e520ac4980 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -218,3 +218,27 @@ class TestUtil(unittest.TestCase): self.assertEqual(3, len(calls1)) self.assertEqual(2, len(calls2)) + + def test_throttle_per_instance(self): + """ Test that the throttle method is done per instance of a class. """ + + class Tester(object): + @util.Throttle(timedelta(seconds=1)) + def hello(self): + return True + + self.assertTrue(Tester().hello()) + self.assertTrue(Tester().hello()) + + def test_throttle_on_method(self): + """ Test that throttle works when wrapping a method. """ + + class Tester(object): + def hello(self): + return True + + tester = Tester() + throttled = util.Throttle(timedelta(seconds=1))(tester.hello) + + self.assertTrue(throttled()) + self.assertIsNone(throttled())