diff --git a/.coveragerc b/.coveragerc index f9646e689fc..068624b929a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,10 +6,14 @@ omit = # omit pieces of code that rely on external devices being present homeassistant/components/alarm_control_panel/alarmdotcom.py + homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/arduino.py homeassistant/components/*/arduino.py + homeassistant/components/bloomsky.py + homeassistant/components/*/bloomsky.py + homeassistant/components/insteon_hub.py homeassistant/components/*/insteon_hub.py @@ -102,6 +106,7 @@ omit = homeassistant/components/notify/pushbullet.py homeassistant/components/notify/pushetta.py homeassistant/components/notify/pushover.py + homeassistant/components/notify/rest.py homeassistant/components/notify/slack.py homeassistant/components/notify/smtp.py homeassistant/components/notify/syslog.py diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index d7cfd0a2f00..5351bcf7983 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -7,10 +7,12 @@ import sys import threading import os import argparse +import time from homeassistant import bootstrap import homeassistant.config as config_util -from homeassistant.const import __version__, EVENT_HOMEASSISTANT_START +from homeassistant.const import (__version__, EVENT_HOMEASSISTANT_START, + RESTART_EXIT_CODE) def validate_python(): @@ -76,6 +78,11 @@ def get_arguments(): '--demo-mode', action='store_true', help='Start Home Assistant in demo mode') + parser.add_argument( + '--debug', + action='store_true', + help='Start Home Assistant in debug mode. Runs in single process to ' + 'enable use of interactive debuggers.') parser.add_argument( '--open-ui', action='store_true', @@ -207,8 +214,11 @@ def uninstall_osx(): print("Home Assistant has been uninstalled.") -def setup_and_run_hass(config_dir, args): - """ Setup HASS and run. Block until stopped. """ +def setup_and_run_hass(config_dir, args, top_process=False): + """ + Setup HASS and run. Block until stopped. Will assume it is running in a + subprocess unless top_process is set to true. + """ if args.demo_mode: config = { 'frontend': {}, @@ -235,7 +245,11 @@ def setup_and_run_hass(config_dir, args): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser) hass.start() - sys.exit(int(hass.block_till_stopped())) + exit_code = int(hass.block_till_stopped()) + + if not top_process: + sys.exit(exit_code) + return exit_code def run_hass_process(hass_proc): @@ -243,8 +257,8 @@ def run_hass_process(hass_proc): requested_stop = threading.Event() hass_proc.daemon = True - def request_stop(): - """ request hass stop """ + def request_stop(*args): + """ request hass stop, *args is for signal handler callback """ requested_stop.set() hass_proc.terminate() @@ -262,7 +276,10 @@ def run_hass_process(hass_proc): hass_proc.join() except KeyboardInterrupt: return False - return not requested_stop.isSet() and hass_proc.exitcode == 100 + + return (not requested_stop.isSet() and + hass_proc.exitcode == RESTART_EXIT_CODE, + hass_proc.exitcode) def main(): @@ -277,14 +294,16 @@ def main(): # os x launchd functions if args.install_osx: install_osx() - return + return 0 if args.uninstall_osx: uninstall_osx() - return + return 0 if args.restart_osx: uninstall_osx() + # A small delay is needed on some systems to let the unload finish. + time.sleep(0.5) install_osx() - return + return 0 # daemon functions if args.pid_file: @@ -294,12 +313,23 @@ def main(): if args.pid_file: write_pid(args.pid_file) + # Run hass in debug mode if requested + if args.debug: + sys.stderr.write('Running in debug mode. ' + 'Home Assistant will not be able to restart.\n') + exit_code = setup_and_run_hass(config_dir, args, top_process=True) + if exit_code == RESTART_EXIT_CODE: + sys.stderr.write('Home Assistant requested a ' + 'restart in debug mode.\n') + return exit_code + # Run hass as child process. Restart if necessary. keep_running = True while keep_running: hass_proc = Process(target=setup_and_run_hass, args=(config_dir, args)) - keep_running = run_hass_process(hass_proc) + keep_running, exit_code = run_hass_process(hass_proc) + return exit_code if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 3f5e6362fb6..310a65c6184 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -61,6 +61,8 @@ def setup(hass, config): for alarm in target_alarms: getattr(alarm, method)(code) + if alarm.should_poll: + alarm.update_ha_state(True) descriptions = load_yaml_config_file( os.path.join(os.path.dirname(__file__), 'services.yaml')) diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index f1b2fe76238..da74c02da54 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -90,7 +90,6 @@ class AlarmDotCom(alarm.AlarmControlPanel): # Open another session to alarm.com to fire off the command _alarm = Alarmdotcom(self._username, self._password, timeout=10) _alarm.disarm() - self.update_ha_state() def alarm_arm_home(self, code=None): """ Send arm home command. """ @@ -100,7 +99,6 @@ class AlarmDotCom(alarm.AlarmControlPanel): # Open another session to alarm.com to fire off the command _alarm = Alarmdotcom(self._username, self._password, timeout=10) _alarm.arm_stay() - self.update_ha_state() def alarm_arm_away(self, code=None): """ Send arm away command. """ @@ -110,7 +108,6 @@ class AlarmDotCom(alarm.AlarmControlPanel): # Open another session to alarm.com to fire off the command _alarm = Alarmdotcom(self._username, self._password, timeout=10) _alarm.arm_away() - self.update_ha_state() def _validate_code(self, code, state): """ Validate given code. """ diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py new file mode 100644 index 00000000000..55ad9b25b9f --- /dev/null +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -0,0 +1,105 @@ +""" +homeassistant.components.alarm_control_panel.nx584 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for NX584 alarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.nx584/ +""" +import logging +import requests + +from homeassistant.const import (STATE_UNKNOWN, STATE_ALARM_DISARMED, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY) +import homeassistant.components.alarm_control_panel as alarm + +REQUIREMENTS = ['pynx584==0.1'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Setup nx584. """ + host = config.get('host', 'localhost:5007') + + try: + add_devices([NX584Alarm(hass, host, config.get('name', 'NX584'))]) + except requests.exceptions.ConnectionError as ex: + _LOGGER.error('Unable to connect to NX584: %s', str(ex)) + return False + + +class NX584Alarm(alarm.AlarmControlPanel): + """ NX584-based alarm panel. """ + def __init__(self, hass, host, name): + from nx584 import client + self._hass = hass + self._host = host + self._name = name + self._alarm = client.Client('http://%s' % host) + # Do an initial list operation so that we will try to actually + # talk to the API and trigger a requests exception for setup_platform() + # to catch + self._alarm.list_zones() + + @property + def should_poll(self): + """ Polling needed. """ + return True + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def code_format(self): + """ Characters if code is defined. """ + return '[0-9]{4}([0-9]{2})?' + + @property + def state(self): + """ Returns the state of the device. """ + try: + part = self._alarm.list_partitions()[0] + zones = self._alarm.list_zones() + except requests.exceptions.ConnectionError as ex: + _LOGGER.error('Unable to connect to %(host)s: %(reason)s', + dict(host=self._host, reason=ex)) + return STATE_UNKNOWN + except IndexError: + _LOGGER.error('nx584 reports no partitions') + return STATE_UNKNOWN + + bypassed = False + for zone in zones: + if zone['bypassed']: + _LOGGER.debug('Zone %(zone)s is bypassed, ' + 'assuming HOME', + dict(zone=zone['number'])) + bypassed = True + break + + if not part['armed']: + return STATE_ALARM_DISARMED + elif bypassed: + return STATE_ALARM_ARMED_HOME + else: + return STATE_ALARM_ARMED_AWAY + + def alarm_disarm(self, code=None): + """ Send disarm command. """ + self._alarm.disarm(code) + + def alarm_arm_home(self, code=None): + """ Send arm home command. """ + self._alarm.arm('home') + + def alarm_arm_away(self, code=None): + """ Send arm away command. """ + self._alarm.arm('auto') + + def alarm_trigger(self, code=None): + """ Alarm trigger command. """ + raise NotImplementedError() diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py new file mode 100644 index 00000000000..fe2ae1cf3ba --- /dev/null +++ b/homeassistant/components/bloomsky.py @@ -0,0 +1,77 @@ +""" +homeassistant.components.bloomsky +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for BloomSky weather station. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/bloomsky/ +""" +import logging +from datetime import timedelta +import requests +from homeassistant.util import Throttle +from homeassistant.helpers import validate_config +from homeassistant.const import CONF_API_KEY + +DOMAIN = "bloomsky" +BLOOMSKY = None + +_LOGGER = logging.getLogger(__name__) + +# The BloomSky only updates every 5-8 minutes as per the API spec so there's +# no point in polling the API more frequently +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) + + +# pylint: disable=unused-argument,too-few-public-methods +def setup(hass, config): + """ Setup BloomSky component. """ + if not validate_config( + config, + {DOMAIN: [CONF_API_KEY]}, + _LOGGER): + return False + + api_key = config[DOMAIN][CONF_API_KEY] + + global BLOOMSKY + try: + BLOOMSKY = BloomSky(api_key) + except RuntimeError: + return False + + return True + + +class BloomSky(object): + """ Handle all communication with the BloomSky API. """ + + # API documentation at http://weatherlution.com/bloomsky-api/ + + API_URL = "https://api.bloomsky.com/api/skydata" + + def __init__(self, api_key): + self._api_key = api_key + self.devices = {} + _LOGGER.debug("Initial bloomsky device load...") + self.refresh_devices() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_devices(self): + """ + Uses the API to retreive a list of devices associated with an + account along with all the sensors on the device. + """ + _LOGGER.debug("Fetching bloomsky update") + response = requests.get(self.API_URL, + headers={"Authorization": self._api_key}, + timeout=10) + if response.status_code == 401: + raise RuntimeError("Invalid API_KEY") + elif response.status_code != 200: + _LOGGER.error("Invalid HTTP response: %s", response.status_code) + return + # create dictionary keyed off of the device unique id + self.devices.update({ + device["DeviceID"]: device for device in response.json() + }) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index fc5c739c888..9aefe4b3b66 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -33,8 +33,6 @@ SWITCH_ACTION_SNAPSHOT = 'snapshot' SERVICE_CAMERA = 'camera_service' -STATE_RECORDING = 'recording' - DEFAULT_RECORDING_SECONDS = 30 # Maps discovered services to their platforms @@ -46,6 +44,7 @@ DIR_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S' REC_DIR_PREFIX = 'recording-' REC_IMG_PREFIX = 'recording_image-' +STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' @@ -121,33 +120,7 @@ def setup(hass, config): try: camera.is_streaming = True camera.update_ha_state() - - handler.request.sendall(bytes('HTTP/1.1 200 OK\r\n', 'utf-8')) - handler.request.sendall(bytes( - 'Content-type: multipart/x-mixed-replace; \ - boundary=--jpgboundary\r\n\r\n', 'utf-8')) - handler.request.sendall(bytes('--jpgboundary\r\n', 'utf-8')) - - # MJPEG_START_HEADER.format() - - while True: - img_bytes = camera.camera_image() - if img_bytes is None: - continue - headers_str = '\r\n'.join(( - 'Content-length: {}'.format(len(img_bytes)), - 'Content-type: image/jpeg', - )) + '\r\n\r\n' - - handler.request.sendall( - bytes(headers_str, 'utf-8') + - img_bytes + - bytes('\r\n', 'utf-8')) - - handler.request.sendall( - bytes('--jpgboundary\r\n', 'utf-8')) - - time.sleep(0.5) + camera.mjpeg_stream(handler) except (requests.RequestException, IOError): camera.is_streaming = False @@ -190,6 +163,34 @@ class Camera(Entity): """ Return bytes of camera image. """ raise NotImplementedError() + def mjpeg_stream(self, handler): + """ Generate an HTTP MJPEG stream from camera images. """ + handler.request.sendall(bytes('HTTP/1.1 200 OK\r\n', 'utf-8')) + handler.request.sendall(bytes( + 'Content-type: multipart/x-mixed-replace; \ + boundary=--jpgboundary\r\n\r\n', 'utf-8')) + handler.request.sendall(bytes('--jpgboundary\r\n', 'utf-8')) + + # MJPEG_START_HEADER.format() + while True: + img_bytes = self.camera_image() + if img_bytes is None: + continue + headers_str = '\r\n'.join(( + 'Content-length: {}'.format(len(img_bytes)), + 'Content-type: image/jpeg', + )) + '\r\n\r\n' + + handler.request.sendall( + bytes(headers_str, 'utf-8') + + img_bytes + + bytes('\r\n', 'utf-8')) + + handler.request.sendall( + bytes('--jpgboundary\r\n', 'utf-8')) + + time.sleep(0.5) + @property def state(self): """ Returns the state of the entity. """ diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py new file mode 100644 index 00000000000..5c9314963bd --- /dev/null +++ b/homeassistant/components/camera/bloomsky.py @@ -0,0 +1,60 @@ +""" +homeassistant.components.camera.bloomsky +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for a camera of a BloomSky weather station. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/camera.bloomsky/ +""" +import logging +import requests +import homeassistant.components.bloomsky as bloomsky +from homeassistant.components.camera import Camera + +DEPENDENCIES = ["bloomsky"] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ set up access to BloomSky cameras """ + for device in bloomsky.BLOOMSKY.devices.values(): + add_devices_callback([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) + + +class BloomSkyCamera(Camera): + """ Represents the images published from the BloomSky's camera. """ + + def __init__(self, bs, device): + """ set up for access to the BloomSky camera images """ + super(BloomSkyCamera, self).__init__() + self._name = device["DeviceName"] + self._id = device["DeviceID"] + self._bloomsky = bs + self._url = "" + self._last_url = "" + # _last_image will store images as they are downloaded so that the + # frequent updates in home-assistant don't keep poking the server + # to download the same image over and over + self._last_image = "" + self._logger = logging.getLogger(__name__) + + def camera_image(self): + """ Update the camera's image if it has changed. """ + try: + self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] + self._bloomsky.refresh_devices() + # if the url hasn't changed then the image hasn't changed + if self._url != self._last_url: + response = requests.get(self._url, timeout=10) + self._last_url = self._url + self._last_image = response.content + except requests.exceptions.RequestException as error: + self._logger.error("Error getting bloomsky image: %s", error) + return None + + return self._last_image + + @property + def name(self): + """ The name of this BloomSky device. """ + return self._name diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 0d59c8d60c7..7bbaa4846b5 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -14,6 +14,9 @@ from requests.auth import HTTPBasicAuth from homeassistant.helpers import validate_config from homeassistant.components.camera import DOMAIN, Camera +from homeassistant.const import HTTP_OK + +CONTENT_TYPE_HEADER = 'Content-Type' _LOGGER = logging.getLogger(__name__) @@ -41,6 +44,17 @@ class MjpegCamera(Camera): self._password = device_info.get('password') self._mjpeg_url = device_info['mjpeg_url'] + def camera_stream(self): + """ Return a mjpeg stream image response directly from the camera. """ + if self._username and self._password: + return requests.get(self._mjpeg_url, + auth=HTTPBasicAuth(self._username, + self._password), + stream=True) + else: + return requests.get(self._mjpeg_url, + stream=True) + def camera_image(self): """ Return a still image response from the camera. """ @@ -55,16 +69,22 @@ class MjpegCamera(Camera): jpg = data[jpg_start:jpg_end + 2] return jpg - if self._username and self._password: - with closing(requests.get(self._mjpeg_url, - auth=HTTPBasicAuth(self._username, - self._password), - stream=True)) as response: - return process_response(response) - else: - with closing(requests.get(self._mjpeg_url, - stream=True)) as response: - return process_response(response) + with closing(self.camera_stream()) as response: + return process_response(response) + + def mjpeg_stream(self, handler): + """ Generate an HTTP MJPEG stream from the camera. """ + response = self.camera_stream() + content_type = response.headers[CONTENT_TYPE_HEADER] + + handler.send_response(HTTP_OK) + handler.send_header(CONTENT_TYPE_HEADER, content_type) + handler.end_headers() + + for chunk in response.iter_content(chunk_size=1024): + if not chunk: + break + handler.wfile.write(chunk) @property def name(self): diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py new file mode 100644 index 00000000000..eeb447be05a --- /dev/null +++ b/homeassistant/components/camera/uvc.py @@ -0,0 +1,91 @@ +""" +homeassistant.components.camera.uvc +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Ubiquiti's UVC cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.uvc/ +""" +import logging +import socket + +import requests + +from homeassistant.helpers import validate_config +from homeassistant.components.camera import DOMAIN, Camera + +REQUIREMENTS = ['uvcclient==0.5'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Discover cameras on a Unifi NVR. """ + if not validate_config({DOMAIN: config}, {DOMAIN: ['nvr', 'key']}, + _LOGGER): + return None + + addr = config.get('nvr') + port = int(config.get('port', 7080)) + key = config.get('key') + + from uvcclient import nvr + nvrconn = nvr.UVCRemote(addr, port, key) + try: + cameras = nvrconn.index() + except nvr.NotAuthorized: + _LOGGER.error('Authorization failure while connecting to NVR') + return False + except nvr.NvrError: + _LOGGER.error('NVR refuses to talk to me') + return False + except requests.exceptions.ConnectionError as ex: + _LOGGER.error('Unable to connect to NVR: %s', str(ex)) + return False + + for camera in cameras: + add_devices([UnifiVideoCamera(nvrconn, + camera['uuid'], + camera['name'])]) + + +class UnifiVideoCamera(Camera): + """ A Ubiquiti Unifi Video Camera. """ + + def __init__(self, nvr, uuid, name): + super(UnifiVideoCamera, self).__init__() + self._nvr = nvr + self._uuid = uuid + self._name = name + self.is_streaming = False + + @property + def name(self): + return self._name + + @property + def is_recording(self): + caminfo = self._nvr.get_camera(self._uuid) + return caminfo['recordingSettings']['fullTimeRecordEnabled'] + + def camera_image(self): + from uvcclient import camera as uvc_camera + + caminfo = self._nvr.get_camera(self._uuid) + camera = None + for addr in [caminfo['host'], caminfo['internalHost']]: + try: + camera = uvc_camera.UVCCameraClient(addr, + caminfo['username'], + 'ubnt') + _LOGGER.debug('Logged into UVC camera %(name)s via %(addr)s', + dict(name=self._name, addr=addr)) + except socket.error: + pass + + if not camera: + _LOGGER.error('Unable to login to camera') + return None + + camera.login() + return camera.get_snapshot() diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 6fb584635f9..591cdc0dc61 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -141,7 +141,7 @@ class Configurator(object): state = self.hass.states.get(entity_id) - new_data = state.attributes + new_data = dict(state.attributes) new_data[ATTR_ERRORS] = error self.hass.states.set(entity_id, STATE_CONFIGURE, new_data) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 5e7bc01b149..dcd9fe29e65 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 = "b5daaa4815050f90f6c996a429bfeae1" +VERSION = "e310ed31e0c6d96def74b44c90ff5878" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index a55f96ac931..58c3479aa9a 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,6 +1,7 @@ --