diff --git a/.coveragerc b/.coveragerc index 9a1a31257404dd..26ccff9accb05c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,9 @@ omit = homeassistant/helpers/signal.py # omit pieces of code that rely on external devices being present + homeassistant/components/android_ip_webcam.py + homeassistant/components/*/android_ip_webcam.py + homeassistant/components/apcupsd.py homeassistant/components/*/apcupsd.py diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py new file mode 100644 index 00000000000000..777d962d7a1250 --- /dev/null +++ b/homeassistant/components/android_ip_webcam.py @@ -0,0 +1,365 @@ +""" +Support for IP Webcam, an Android app that acts as a full-featured webcam. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/android_ip_webcam/ +""" +import asyncio +import logging +from datetime import datetime, timedelta +import xml.etree.ElementTree as ET +from urllib.parse import quote +import aiohttp +import async_timeout + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +import homeassistant.util.dt as dt_util +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT, + CONF_USERNAME, CONF_PASSWORD, CONF_SENSORS, + CONF_SWITCHES) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'android_ip_webcam' + +DATA_IP_WEBCAM = 'android_ip_webcam' + +DEFAULT_NAME = 'IP Webcam' + +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 10 + +ATTR_VID_CONNS = 'Video Connections' +ATTR_AUD_CONNS = 'Audio Connections' + +KEY_MAP = { + 'audio_connections': 'Audio Connections', + 'adet_limit': 'Audio Trigger Limit', + 'antibanding': 'Anti-banding', + 'audio_only': 'Audio Only', + 'battery_level': 'Battery Level', + 'battery_temp': 'Battery Temperature', + 'battery_voltage': 'Battery Voltage', + 'coloreffect': 'Color Effect', + 'exposure': 'Exposure Level', + 'exposure_lock': 'Exposure Lock', + 'ffc': 'Front-facing Camera', + 'flashmode': 'Flash Mode', + 'focus': 'Focus', + 'focus_homing': 'Focus Homing', + 'focus_region': 'Focus Region', + 'focusmode': 'Focus Mode', + 'gps_active': 'GPS Active', + 'idle': 'Idle', + 'ip_address': 'IPv4 Address', + 'ipv6_address': 'IPv6 Address', + 'ivideon_streaming': 'Ivideon Streaming', + 'light': 'Light Level', + 'mirror_flip': 'Mirror Flip', + 'motion': 'Motion', + 'motion_active': 'Motion Active', + 'motion_detect': 'Motion Detection', + 'motion_event': 'Motion Event', + 'motion_limit': 'Motion Limit', + 'night_vision': 'Night Vision', + 'night_vision_average': 'Night Vision Average', + 'night_vision_gain': 'Night Vision Gain', + 'orientation': 'Orientation', + 'overlay': 'Overlay', + 'photo_size': 'Photo Size', + 'pressure': 'Pressure', + 'proximity': 'Proximity', + 'quality': 'Quality', + 'scenemode': 'Scene Mode', + 'sound': 'Sound', + 'sound_event': 'Sound Event', + 'sound_timeout': 'Sound Timeout', + 'torch': 'Torch', + 'video_connections': 'Video Connections', + 'video_chunk_len': 'Video Chunk Length', + 'video_recording': 'Video Recording', + 'video_size': 'Video Size', + 'whitebalance': 'White Balance', + 'whitebalance_lock': 'White Balance Lock', + 'zoom': 'Zoom' +} + +ICON_MAP = { + 'audio_connections': 'mdi:speaker', + 'battery_level': 'mdi:battery', + 'battery_temp': 'mdi:thermometer', + 'battery_voltage': 'mdi:battery-charging-100', + 'exposure_lock': 'mdi:camera', + 'ffc': 'mdi:camera-front-variant', + 'focus': 'mdi:image-filter-center-focus', + 'gps_active': 'mdi:crosshairs-gps', + 'light': 'mdi:flashlight', + 'motion': 'mdi:run', + 'night_vision': 'mdi:weather-night', + 'overlay': 'mdi:monitor', + 'pressure': 'mdi:gauge', + 'proximity': 'mdi:map-marker-radius', + 'quality': 'mdi:quality-high', + 'sound': 'mdi:speaker', + 'sound_event': 'mdi:speaker', + 'sound_timeout': 'mdi:speaker', + 'torch': 'mdi:white-balance-sunny', + 'video_chunk_len': 'mdi:video', + 'video_connections': 'mdi:eye', + 'video_recording': 'mdi:record-rec', + 'whitebalance_lock': 'mdi:white-balance-auto' +} + +SWITCHES = ['exposure_lock', 'ffc', 'focus', 'gps_active', 'night_vision', + 'overlay', 'torch', 'whitebalance_lock', 'video_recording'] + +SENSORS = ['audio_connections', 'battery_level', 'battery_temp', + 'battery_voltage', 'light', 'motion', 'pressure', 'proximity', + 'sound', 'video_connections'] + +CONF_MOTION_BINARY_SENSOR = 'motion_binary_sensor' + +DEFAULT_MOTION_BINARY_SENSOR = True + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_SWITCHES, + default=SWITCHES): vol.All(cv.ensure_list, + [vol.In(SWITCHES)]), + vol.Optional(CONF_SENSORS, + default=SENSORS): vol.All(cv.ensure_list, + [vol.In(SENSORS)]), + vol.Optional(CONF_MOTION_BINARY_SENSOR, + default=DEFAULT_MOTION_BINARY_SENSOR): bool + }) +}, extra=vol.ALLOW_EXTRA) + +ALLOWED_ORIENTATIONS = ['landscape', 'upsidedown', 'portrait', + 'upsidedown_portrait'] + + +def setup(hass, config): + """Setup the IP Webcam component.""" + conf = config[DOMAIN] + host = conf[CONF_HOST] + ip_webcam = hass.data.get(DATA_IP_WEBCAM) + if ip_webcam is None: + hass.data[DATA_IP_WEBCAM] = {} + hass.data[DATA_IP_WEBCAM][host] = IPWebcam(hass, conf) + + if conf.get(CONF_MOTION_BINARY_SENSOR, False) is True: + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + + discovery.load_platform(hass, 'camera', DOMAIN, {}, config) + + sensor_config = conf.get(CONF_SENSORS, []) + discovery.load_platform(hass, 'sensor', DOMAIN, sensor_config, config) + + switch_config = conf.get(CONF_SWITCHES, []) + discovery.load_platform(hass, 'switch', DOMAIN, switch_config, config) + + return True + + +class IPWebcam(object): + """The Android device running IP Webcam.""" + + def __init__(self, hass, config): + """Initialize the data oject.""" + self.hass = hass + self.websession = async_get_clientsession(hass) + self._config = config + self._name = self._config.get(CONF_NAME) + self.host = self._config.get(CONF_HOST) + self.port = self._config.get(CONF_PORT) + self.username = self._config.get(CONF_USERNAME) + self.password = self._config.get(CONF_PASSWORD) + self.status_data = None + self.sensor_data = None + self._sensor_updated_at = (datetime.now() - timedelta(seconds=5)) + + @property + def base_url(self): + """Return the base url for endpoints.""" + return 'http://{}:{}'.format(self.host, self.port) + + @asyncio.coroutine + def _request(self, path): + """Make the actual request and return the parsed response.""" + url = '{}{}'.format(self.base_url, path) + + auth = None if self.username is None else aiohttp.BasicAuth( + self.username, self.password) + + resp = 'json' if '.json' in path else 'xml' + + if '/startvideo' in path or '/stopvideo' in path: + resp = 'json' + + response = None + + data = None + + try: + with async_timeout.timeout(10, loop=self.hass.loop): + response = yield from self.websession.get(url, auth=auth) + + if response.status == 200: + if resp == 'xml': + data = yield from response.text() + elif resp == 'json': + data = yield from response.json() + except (asyncio.TimeoutError, + aiohttp.errors.ClientError, + aiohttp.errors.ClientDisconnectedError) as error: + _LOGGER.error('Failed to communicate with IP Webcam: %s', + type(error)) + return False + finally: + if response is not None: + yield from response.release() + + try: + if resp == 'xml': + return ET.fromstring(data) + else: + return data + except AttributeError: + _LOGGER.error("Received invalid response: %s", data) + return False + + @asyncio.coroutine + def async_update(self): + """Fetch the latest data from IP Webcam.""" + self.status_data = yield from self._request('/status.json') + + utime = int(dt_util.as_timestamp(self._sensor_updated_at) * 1000) + sensor_url = '/sensors.json?from={}' + self.sensor_data = yield from self._request(sensor_url.format(utime)) + self._sensor_updated_at = datetime.now() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + if self.status_data is not None: + vid_conns = 'video_connections' + aud_conns = 'audio_connections' + state_attr[ATTR_VID_CONNS] = self.status_data.get(vid_conns) + state_attr[ATTR_AUD_CONNS] = self.status_data.get(aud_conns) + for (key, val) in self.status_data.get('curvals', {}).items(): + try: + val = float(val) + except ValueError: + val = val + + if val == 'on' or val == 'off': + val = (val == 'on') + + state_attr[KEY_MAP.get(key, key)] = val + return state_attr + + @property + def enabled_sensors(self): + """Return the enabled sensors.""" + return list(self.sensor_data.keys()) + + @property + def current_settings(self): + """Return a dictionary of the current settings.""" + settings = {} + if self.status_data is not None: + for (key, val) in self.status_data.get('curvals', {}).items(): + try: + val = float(val) + except ValueError: + val = val + + if val == 'on' or val == 'off': + val = (val == 'on') + + settings[key] = val + return settings + + @asyncio.coroutine + def async_change_setting(self, key, val): + """Change a setting.""" + if isinstance(val, bool): + payload = 'on' if val else 'off' + else: + payload = val + data = yield from self._request('/settings/{}?set={}'.format(key, + payload)) + return data + + def torch(self, activate=True): + """Enable/disable the torch.""" + path = '/enabletorch' if activate else '/disabletorch' + data = yield from self._request(path) + return data + + def focus(self, activate=True): + """Enable/disable camera focus.""" + path = '/focus' if activate else '/nofocus' + data = yield from self._request(path) + return data + + def record(self, record=True, tag=None): + """Enable/disable recording.""" + path = '/startvideo?force=1' if record else '/stopvideo?force=1' + if record and tag is not None: + path = '/startvideo?force=1&tag={}'.format(quote(tag)) + data = yield from self._request(path) + return data + + def set_front_facing_camera(self, activate=True): + """Enable/disable the front-facing camera.""" + data = yield from self.async_change_setting('ffc', activate) + return data + + def set_night_vision(self, activate=True): + """Enable/disable night vision.""" + data = yield from self.async_change_setting('night_vision', activate) + return data + + def set_overlay(self, activate=True): + """Enable/disable the video overlay.""" + data = yield from self.async_change_setting('overlay', activate) + return data + + def set_gps_active(self, activate=True): + """Enable/disable GPS.""" + data = yield from self.async_change_setting('gps_active', activate) + return data + + def set_quality(self, quality: int=100): + """Set the video quality.""" + data = yield from self.async_change_setting('quality', quality) + return data + + def set_orientation(self, orientation: str='landscape'): + """Set the video orientation.""" + if orientation not in ALLOWED_ORIENTATIONS: + _LOGGER.debug('%s is not a valid orientation', orientation) + return False + data = yield from self.async_change_setting('orientation', orientation) + return data + + def set_zoom(self, zoom: int): + """Set the zoom level.""" + data = yield from self._request('/settings/ptz?zoom={}'.format(zoom)) + return data diff --git a/homeassistant/components/binary_sensor/android_ip_webcam.py b/homeassistant/components/binary_sensor/android_ip_webcam.py new file mode 100644 index 00000000000000..4270fb9cdee8e6 --- /dev/null +++ b/homeassistant/components/binary_sensor/android_ip_webcam.py @@ -0,0 +1,62 @@ +""" +Support for IP Webcam binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.android_ip_webcam/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.android_ip_webcam import (KEY_MAP, + DATA_IP_WEBCAM) + +DEPENDENCIES = ['android_ip_webcam'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup IP Webcam binary sensors.""" + if discovery_info is None: + return + + ip_webcam = hass.data[DATA_IP_WEBCAM] + + for device in ip_webcam.values(): + add_devices([IPWebcamBinarySensor(device, 'motion_active')], True) + + +class IPWebcamBinarySensor(BinarySensorDevice): + """Represents an IP Webcam binary sensor.""" + + def __init__(self, device, variable): + """Initialize the sensor.""" + self._device = device + self.variable = variable + self._mapped_name = KEY_MAP.get(self.variable, self.variable) + self._name = '{} {}'.format(self._device.name, self._mapped_name) + self._state = None + self.update() + + @property + def name(self): + """Return the name of the binary sensor, if any.""" + return self._name + + @property + def is_on(self): + """True if the binary sensor is on.""" + return self._state + + def update(self): + """Retrieve latest state.""" + self._device.async_update() + if self._device.status_data is not None: + container = self._device.sensor_data.get(self.variable) + data_point = container.get('data', [[0, [0.0]]]) + self._state = data_point[0][-1][0] == 1.0 + + @property + def icon(self): + """Return the icon for the sensor.""" + return 'mdi:run' if self._state else 'mdi:walk' diff --git a/homeassistant/components/camera/android_ip_webcam.py b/homeassistant/components/camera/android_ip_webcam.py new file mode 100644 index 00000000000000..3aa7729f6b6abf --- /dev/null +++ b/homeassistant/components/camera/android_ip_webcam.py @@ -0,0 +1,97 @@ +""" +Support for IP Webcam, an Android app that acts as a full-featured webcam. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.android_ip_webcam/ +""" +import asyncio +import logging + +import aiohttp +import async_timeout + +from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_stream) +from homeassistant.components import android_ip_webcam + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}) + +DEPENDENCIES = ['android_ip_webcam'] + + +@asyncio.coroutine +# pylint: disable=unused-argument +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup an IP Webcam Camera.""" + if discovery_info is None: + return + devices = hass.data[android_ip_webcam.DATA_IP_WEBCAM] + cameras = [IPWebcamCamera(hass, device) + for key, device in devices.items()] + yield from async_add_devices(cameras, True) + + +class IPWebcamCamera(Camera): + """An implementation of an IP camera that is reachable over a URL.""" + + def __init__(self, hass, device): + """Initialize a IP Webcam camera.""" + super(IPWebcamCamera, self).__init__() + self._device = device + self._name = self._device.name + self._username = self._device.username + self._password = self._device.password + self._mjpeg_url = '{}/{}'.format(self._device.base_url, 'video') + self._still_image_url = '{}/{}'.format(self._device.base_url, + 'photo.jpg') + + self._auth = None + if self._username and self._password: + self._auth = aiohttp.BasicAuth(self._username, + password=self._password) + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + websession = async_get_clientsession(self.hass) + response = None + try: + with async_timeout.timeout(10, loop=self.hass.loop): + response = yield from websession.get( + self._still_image_url, auth=self._auth) + + image = yield from response.read() + return image + + except asyncio.TimeoutError: + _LOGGER.error('Timeout getting camera image') + + except (aiohttp.errors.ClientError, + aiohttp.errors.ClientDisconnectedError) as err: + _LOGGER.error('Error getting new camera image: %s', err) + + finally: + if response is not None: + yield from response.release() + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + # connect to stream + websession = async_get_clientsession(self.hass) + stream_coro = websession.get(self._mjpeg_url, auth=self._auth) + + yield from async_aiohttp_proxy_stream(self.hass, request, stream_coro) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device.device_state_attributes diff --git a/homeassistant/components/sensor/android_ip_webcam.py b/homeassistant/components/sensor/android_ip_webcam.py new file mode 100644 index 00000000000000..0d5d0c72b6e85c --- /dev/null +++ b/homeassistant/components/sensor/android_ip_webcam.py @@ -0,0 +1,100 @@ +""" +Support for IP Webcam sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.android_ip_webcam/ +""" +import logging + +from homeassistant.components.android_ip_webcam import (KEY_MAP, ICON_MAP, + DATA_IP_WEBCAM) +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['android_ip_webcam'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the IP Webcam Sensor.""" + if discovery_info is None: + return + + ip_webcam = hass.data[DATA_IP_WEBCAM] + + all_sensors = [] + + for device in ip_webcam.values(): + for sensor in discovery_info: + all_sensors.append(IPWebcamSensor(device, sensor)) + + add_devices(all_sensors, True) + + return True + + +class IPWebcamSensor(Entity): + """Representation of a IP Webcam sensor.""" + + def __init__(self, device, variable): + """Initialize the sensor.""" + self._device = device + self.variable = variable + + # device specific + self._mapped_name = KEY_MAP.get(self.variable, self.variable) + self._name = '{} {}'.format(self._device.name, self._mapped_name) + self._state = None + self._unit = None + self.update() + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Retrieve latest state.""" + self._device.async_update() + if (self._device.status_data is not None and + self._device.sensor_data is not None): + if self.variable in ('audio_connections', 'video_connections'): + self._state = self._device.status_data.get(self.variable) + self._unit = 'Connections' + else: + container = self._device.sensor_data.get(self.variable) + self._unit = container.get('unit', self._unit) + data_point = container.get('data', [[0, [0.0]]]) + if data_point and data_point[0]: + self._state = data_point[0][-1][0] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device.device_state_attributes + + @property + def icon(self): + """Return the icon for the sensor.""" + if self.variable == 'battery_level' and self._state is not None: + rounded_level = round(int(self._state), -1) + returning_icon = 'mdi:battery' + if rounded_level < 10: + returning_icon = 'mdi:battery-outline' + elif self._state == 100: + returning_icon = 'mdi:battery' + else: + returning_icon = 'mdi:battery-{}'.format(str(rounded_level)) + + return returning_icon + return ICON_MAP.get(self.variable, 'mdi:eye') diff --git a/homeassistant/components/switch/android_ip_webcam.py b/homeassistant/components/switch/android_ip_webcam.py new file mode 100644 index 00000000000000..13d431edb77286 --- /dev/null +++ b/homeassistant/components/switch/android_ip_webcam.py @@ -0,0 +1,97 @@ +""" +Support for IP Webcam settings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.android_ip_webcam/ +""" +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.android_ip_webcam import (KEY_MAP, ICON_MAP, + DATA_IP_WEBCAM) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['android_ip_webcam'] +DOMAIN = 'switch' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the IP Webcam switch platform.""" + if discovery_info is None: + return + + ip_webcam = hass.data[DATA_IP_WEBCAM] + + all_switches = [] + + for device in ip_webcam.values(): + for setting in discovery_info: + all_switches.append(IPWebcamSettingsSwitch(device, setting)) + + add_devices(all_switches, True) + + return True + + +class IPWebcamSettingsSwitch(SwitchDevice): + """An abstract class for an IP Webcam setting.""" + + def __init__(self, device, setting): + """Initialize the settings switch.""" + self._device = device + self._setting = setting + self._mapped_name = KEY_MAP.get(self._setting, self._setting) + self._name = '{} {}'.format(self._device.name, self._mapped_name) + self._state = False + self.update() + + @property + def name(self): + """Return the the name of the node.""" + return self._name + + def update(self): + """Get the updated status of the switch.""" + self._device.async_update() + if self._device.status_data is not None: + self._state = self._device.current_settings.get(self._setting) + + @property + def is_on(self): + """Return the boolean response if the node is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn device on.""" + if self._setting is 'torch': + self._device.torch(activate=True) + elif self._setting is 'focus': + self._device.focus(activate=True) + elif self._setting is 'video_recording': + self._device.record(record=True) + else: + self._device.change_setting(self._setting, True) + self._state = True + + def turn_off(self, **kwargs): + """Turn device off.""" + if self._setting is 'torch': + self._device.torch(activate=False) + elif self._setting is 'focus': + self._device.focus(activate=False) + elif self._setting is 'video_recording': + self._device.record(record=False) + else: + self._device.change_setting(self._setting, False) + self._state = False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device.device_state_attributes + + @property + def icon(self): + """Return the icon for the switch.""" + return ICON_MAP.get(self._setting, 'mdi:flash')