From 93118fcadeeeb2a7c5adcdc4bc0ad9f930abfd75 Mon Sep 17 00:00:00 2001
From: pvizeli <pvizeli@syshack.ch>
Date: Wed, 8 Mar 2017 16:46:41 +0100
Subject: [PATCH] Android IP Cam support

---
 homeassistant/components/android_ip_webcam.py | 265 ++++++++++++++++++
 .../binary_sensor/android_ip_webcam.py        |  65 +++++
 .../components/sensor/android_ip_webcam.py    |  93 ++++++
 .../components/switch/android_ip_webcam.py    |  95 +++++++
 requirements_all.txt                          |   3 +
 5 files changed, 521 insertions(+)
 create mode 100644 homeassistant/components/android_ip_webcam.py
 create mode 100644 homeassistant/components/binary_sensor/android_ip_webcam.py
 create mode 100644 homeassistant/components/sensor/android_ip_webcam.py
 create mode 100644 homeassistant/components/switch/android_ip_webcam.py

diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py
new file mode 100644
index 00000000000..be318c7c558
--- /dev/null
+++ b/homeassistant/components/android_ip_webcam.py
@@ -0,0 +1,265 @@
+"""
+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
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import (
+    CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD,
+    CONF_SENSORS, CONF_SWITCHES, CONF_TIMEOUT, CONF_SCAN_INTERVAL)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+    async_dispatcher_send, async_dispatcher_connect)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+DOMAIN = 'android_ip_webcam'
+REQUIREMENTS = ["pydroid-ipcam==0.1"]
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+DATA_IP_WEBCAM = 'android_ip_webcam'
+
+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']
+
+SIGNAL_UPDATE_DATA = 'android_ip_webcam_update'
+
+CONF_MOTION_SENSOR = 'motion_sensor'
+
+DEFAULT_MOTION_SENSOR = True
+DEFAULT_NAME = 'IP Webcam'
+DEFAULT_PORT = 8080
+DEFAULT_TIMEOUT = 10
+
+
+CONFIG_SCHEMA = vol.Schema({
+    DOMAIN: vol.All(cv.ensure_list, [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.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+        vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+            cv.time_period,
+        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_SENSOR, default=DEFAULT_MOTION_SENSOR): bool
+    })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+@asyncio.coroutine
+def async_setup(hass, config):
+    """Setup the IP Webcam component."""
+    from pydroid_ipcam import PyDroidIPCam
+
+    webcams = hass.data[DATA_IP_WEBCAM] = {}
+    websession = async_get_clientsession(hass)
+
+    @asyncio.coroutine
+    def async_setup_ipcamera(cam_config):
+        """Setup a ip camera."""
+        host = cam_config[CONF_HOST]
+        username = cam_config.get(CONF_USERNAME)
+        password = cam_config.get(CONF_PASSWORD)
+        name = cam_config[CONF_NAME]
+        interval = cam_config[SCAN_INTERVAL]
+
+        cam = PyDroidIPCam(
+            hass.loop, websession, host, cam_config[CONF_PORT],
+            username=username, password=password,
+            timeout=cam_config[CONF_TIMEOUT]
+        )
+
+        @asyncio.coroutine
+        def async_update_data(now):
+            """Update data from ipcam in SCAN_INTERVAL."""
+            yield from cam.update()
+            async_dispatcher_send(hass, SIGNAL_UPDATE_DATA, host)
+
+            async_track_point_in_utc_time(
+                hass, utcnow() + interval, async_update_data)
+
+        yield from async_update_data(None)
+        webcams[host] = cam
+
+        mjpeg_camera = {
+            'mjpeg_url': cam.mjpeg_url,
+            'still_image_url': cam.image_url,
+            CONF_NAME: name,
+        }
+        if username and password:
+            mjpeg_camera.update({
+                CONF_USERNAME: username,
+                CONF_PASSWORD: password
+            })
+
+        if cam_config[CONF_MOTION_SENSOR]:
+            hass.async_add_job(discovery.async_load_platform(
+                hass, 'binary_sensor', DOMAIN, {
+                    CONF_HOST: host,
+                    CONF_NAME: name,
+                }, config))
+
+        hass.async_add_job(discovery.async_load_platform(
+            hass, 'camera', 'mjpeg', mjpeg_camera, config))
+
+        hass.async_add_job(discovery.async_load_platform(
+            hass, 'sensor', DOMAIN, {
+                CONF_NAME: name,
+                CONF_HOST: host,
+                CONF_SENSORS: cam_config[CONF_SENSORS],
+            }, config))
+
+        hass.async_add_job(discovery.async_load_platform(
+            hass, 'switch', DOMAIN, {
+                CONF_NAME: name,
+                CONF_HOST: host,
+                CONF_SWITCHES: cam_config[CONF_SWITCHES],
+            }, config))
+
+    tasks = [async_setup_ipcamera(conf) for conf in config[DOMAIN]]
+    if tasks:
+        yield from asyncio.wait(tasks, loop=hass.loop)
+
+    return True
+
+
+class AndroidIPCamEntity(Entity):
+    """The Android device running IP Webcam."""
+
+    def __init__(self, host, ipcam):
+        """Initialize the data oject."""
+        self._host = host
+        self._ipcam = ipcam
+
+    @asyncio.coroutine
+    def async_added_to_hass(self):
+        """Register update dispatcher."""
+        @callback
+        def async_ipcam_update(host):
+            """Update callback."""
+            if self._host != host:
+                return
+            self.hass.async_add_job(self.async_update_ha_state(True))
+
+        async_dispatcher_connect(
+            self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update)
+
+    @property
+    def should_poll(self):
+        """Is update over central callback."""
+        return False
+
+    @property
+    def device_state_attributes(self):
+        """Return the state attributes."""
+        state_attr = {}
+        if self._ipcam.status_data is not None:
+            return state_attr
+
+        state_attr[ATTR_VID_CONNS] = \
+            self._ipcam.status_data.get('video_connections')
+        state_attr[ATTR_AUD_CONNS] = \
+            self._ipcam.status_data.get('audio_connections')
+        state_attr.update(self._ipcam.current_settings)
+
+        return state_attr
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 00000000000..2feb21f2ec5
--- /dev/null
+++ b/homeassistant/components/binary_sensor/android_ip_webcam.py
@@ -0,0 +1,65 @@
+"""
+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 asyncio
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.components.android_ip_webcam import (
+    KEY_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST, CONF_NAME)
+
+DEPENDENCIES = ['android_ip_webcam']
+
+
+@asyncio.coroutine
+def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
+    """Setup IP Webcam binary sensors."""
+    if discovery_info is None:
+        return
+
+    host = discovery_info[CONF_HOST]
+    name = discovery_info[CONF_NAME]
+    ipcam = hass.data[DATA_IP_WEBCAM][host]
+
+    async_add_devices(
+        [IPWebcamBinarySensor(name, host, ipcam, 'motion_active')], True)
+
+
+class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorDevice):
+    """Represents an IP Webcam binary sensor."""
+
+    def __init__(self, name, host, ipcam, sensor):
+        """Initialize the binary sensor."""
+        super().__init__(host, ipcam)
+
+        self._sensor = sensor
+        self._mapped_name = KEY_MAP.get(self._sensor, self._sensor)
+        self._name = '{} {}'.format(name, self._mapped_name)
+        self._state = None
+        self._unit = None
+
+    @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."""
+        if self._ipcam.status_data not None:
+            return
+
+        container = self._ipcam.sensor_data.get(self._sensor)
+        data_point = container.get('data', [[0, [0.0]]])
+        self._state = data_point[0][-1][0] == 1.0
+
+    @property
+    def sensor_class(self):
+        """Return the class of this device, from component DEVICE_CLASSES."""
+        return 'motion'
diff --git a/homeassistant/components/sensor/android_ip_webcam.py b/homeassistant/components/sensor/android_ip_webcam.py
new file mode 100644
index 00000000000..f0151758b48
--- /dev/null
+++ b/homeassistant/components/sensor/android_ip_webcam.py
@@ -0,0 +1,93 @@
+"""
+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 asyncio
+
+from homeassistant.components.android_ip_webcam import (
+    KEY_MAP, ICON_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST,
+    CONF_NAME, CONF_SENSORS)
+
+DEPENDENCIES = ['android_ip_webcam']
+
+
+@asyncio.coroutine
+def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
+    """Setup the IP Webcam Sensor."""
+    if discovery_info is None:
+        return
+
+    host = discovery_info[CONF_HOST]
+    name = discovery_info[CONF_NAME]
+    sensors = discovery_info[CONF_SENSORS]
+    ipcam = hass.data[DATA_IP_WEBCAM][host]
+
+    all_sensors = []
+
+    for sensor in sensors:
+        all_sensors.append(IPWebcamSensor(name, host, ipcam, sensor))
+
+    async_add_devices(all_sensors, True)
+
+
+class IPWebcamSensor(AndroidIPCamEntity):
+    """Representation of a IP Webcam sensor."""
+
+    def __init__(self, name, host, ipcam, sensor):
+        """Initialize the sensor."""
+        super().__init__(host, ipcam)
+
+        self._sensor = sensor
+        self._mapped_name = KEY_MAP.get(self._sensor, self._sensor)
+        self._name = '{} {}'.format(name, self._mapped_name)
+        self._state = None
+        self._unit = None
+
+    @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
+
+    @asyncio.coroutine
+    def async_update(self):
+        """Retrieve latest state."""
+        if self._ipcam.status_data is None or self._ipcam.sensor_data is None:
+            return
+
+        if self._sensor in ('audio_connections', 'video_connections'):
+            self._state = self._ipcam.status_data.get(self._sensor)
+            self._unit = 'Connections'
+        else:
+            container = self._ipcam.sensor_data.get(self._sensor)
+            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 icon(self):
+        """Return the icon for the sensor."""
+        if self._sensor == '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._sensor, '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 00000000000..04d11be93fa
--- /dev/null
+++ b/homeassistant/components/switch/android_ip_webcam.py
@@ -0,0 +1,95 @@
+"""
+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 asyncio
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.components.android_ip_webcam import (
+    KEY_MAP, ICON_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST,
+    CONF_NAME, CONF_SWITCHES)
+
+DEPENDENCIES = ['android_ip_webcam']
+
+
+@asyncio.coroutine
+def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
+    """Set up the IP Webcam switch platform."""
+    if discovery_info is None:
+        return
+
+    host = discovery_info[CONF_HOST]
+    name = discovery_info[CONF_NAME]
+    switches = discovery_info[CONF_SWITCHES]
+    ipcam = hass.data[DATA_IP_WEBCAM][host]
+
+    all_switches = []
+
+    for setting in switches:
+        all_switches.append(IPWebcamSettingsSwitch(name, host, ipcam, setting))
+
+    async_add_devices(all_switches, True)
+
+
+class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice):
+    """An abstract class for an IP Webcam setting."""
+
+    def __init__(self, name, host, ipcam, setting):
+        """Initialize the settings switch."""
+        super().__init__(host, ipcam)
+
+        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
+
+    @property
+    def name(self):
+        """Return the the name of the node."""
+        return self._name
+
+    @asyncio.coroutine
+    def async_update(self):
+        """Get the updated status of the switch."""
+        if self._ipcam.status_data is not None:
+            self._state = self._ipcam.current_settings.get(self._setting)
+
+    @property
+    def is_on(self):
+        """Return the boolean response if the node is on."""
+        return self._state
+
+    @asyncio.coroutine
+    def async_turn_on(self, **kwargs):
+        """Turn device on."""
+        if self._setting is 'torch':
+            yield from self._ipcam.torch(activate=True)
+        elif self._setting is 'focus':
+            yield from self._ipcam.focus(activate=True)
+        elif self._setting is 'video_recording':
+            yield from self._ipcam.record(record=True)
+        else:
+            yield from self._ipcam.change_setting(self._setting, True)
+        self._state = True
+        self.hass.async_add_job(self.async_update_ha_state())
+
+    @asyncio.coroutine
+    def async_turn_off(self, **kwargs):
+        """Turn device off."""
+        if self._setting is 'torch':
+            yield from self._ipcam.torch(activate=False)
+        elif self._setting is 'focus':
+            yield from self._ipcam.focus(activate=False)
+        elif self._setting is 'video_recording':
+            yield from self._ipcam.record(record=False)
+        else:
+            yield from self._ipcam.change_setting(self._setting, False)
+        self._state = False
+        self.hass.async_add_job(self.async_update_ha_state())
+
+    @property
+    def icon(self):
+        """Return the icon for the switch."""
+        return ICON_MAP.get(self._setting, 'mdi:flash')
diff --git a/requirements_all.txt b/requirements_all.txt
index 6e4f3912ab2..886fb65e936 100755
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -481,6 +481,9 @@ pycmus==0.1.0
 # homeassistant.components.zwave
 pydispatcher==2.0.5
 
+# homeassistant.components.android_ip_webcam
+pydroid-ipcam==0.1
+
 # homeassistant.components.sensor.ebox
 pyebox==0.1.0
 
-- 
GitLab