Introducing Ring Door Bell Camera (including StickUp cameras) and WiFi sensors (#9962)

*   Extended Ring DoorBell to support camera playback and wifi sensors

   * Bump python-ringdoorbell to version 0.1.6
   * Support to camera playback via ffmpeg
   * Extended ringdoorbell sensors to report WiFi attributes
   * Extended unittests

* Makes lint happy

* Added support to stickup cameras and fixed logic

* Fixed unittests for stickup cameras

* Makes lint happy

* Refactored attributions and removed extra refresh method.
This commit is contained in:
Marcelo Moreira de Mello 2017-10-21 10:08:40 -04:00 committed by Pascal Vizeli
parent 222cc4c393
commit 51a65ee8e9
12 changed files with 399 additions and 19 deletions

View file

@ -272,6 +272,7 @@ omit =
homeassistant/components/camera/mjpeg.py
homeassistant/components/camera/rpi_camera.py
homeassistant/components/camera/onvif.py
homeassistant/components/camera/ring.py
homeassistant/components/camera/synology.py
homeassistant/components/camera/yi.py
homeassistant/components/climate/eq3btsmart.py

View file

@ -11,7 +11,7 @@ import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.components.ring import (
CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE)
CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING)
from homeassistant.const import (
ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS)
@ -27,21 +27,21 @@ SCAN_INTERVAL = timedelta(seconds=5)
# Sensor types: Name, category, device_class
SENSOR_TYPES = {
'ding': ['Ding', ['doorbell'], 'occupancy'],
'motion': ['Motion', ['doorbell'], 'motion'],
'ding': ['Ding', ['doorbell', 'stickup_cams'], 'occupancy'],
'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE):
cv.string,
vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
})
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up a sensor for a Ring device."""
ring = hass.data.get('ring')
ring = hass.data[DATA_RING]
sensors = []
for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
@ -50,6 +50,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
sensors.append(RingBinarySensor(hass,
device,
sensor_type))
for device in ring.stickup_cams:
if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingBinarySensor(hass,
device,
sensor_type))
add_devices(sensors, True)
return True

View file

@ -0,0 +1,141 @@
"""
This component provides support to the Ring Door Bell camera.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/camera.ring/
"""
import asyncio
import logging
from datetime import datetime, timedelta
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from homeassistant.components.ring import DATA_RING, CONF_ATTRIBUTION
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
from homeassistant.components.ffmpeg import DATA_FFMPEG
from homeassistant.const import ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.util import dt as dt_util
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
DEPENDENCIES = ['ring', 'ffmpeg']
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=90)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string,
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
cv.time_period,
})
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Set up a Ring Door Bell and StickUp Camera."""
ring = hass.data[DATA_RING]
cams = []
for camera in ring.doorbells:
cams.append(RingCam(hass, camera, config))
for camera in ring.stickup_cams:
cams.append(RingCam(hass, camera, config))
async_add_devices(cams, True)
return True
class RingCam(Camera):
"""An implementation of a Ring Door Bell camera."""
def __init__(self, hass, camera, device_info):
"""Initialize a Ring Door Bell camera."""
super(RingCam, self).__init__()
self._camera = camera
self._hass = hass
self._name = self._camera.name
self._ffmpeg = hass.data[DATA_FFMPEG]
self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS)
self._last_video_id = self._camera.last_recording_id
self._video_url = self._camera.recording_url(self._last_video_id)
self._expires_at = None
self._utcnow = None
@property
def name(self):
"""Return the name of this camera."""
return self._name
@property
def device_state_attributes(self):
"""Return the state attributes."""
return {
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
'device_id': self._camera.id,
'firmware': self._camera.firmware,
'kind': self._camera.kind,
'timezone': self._camera.timezone,
'type': self._camera.family,
'video_url': self._video_url,
'video_id': self._last_video_id
}
@asyncio.coroutine
def async_camera_image(self):
"""Return a still image response from the camera."""
from haffmpeg import ImageFrame, IMAGE_JPEG
ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop)
if self._video_url is None:
return
image = yield from asyncio.shield(ffmpeg.get_image(
self._video_url, output_format=IMAGE_JPEG,
extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop)
return image
@asyncio.coroutine
def handle_async_mjpeg_stream(self, request):
"""Generate an HTTP MJPEG stream from the camera."""
from haffmpeg import CameraMjpeg
if self._video_url is None:
return
stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
yield from stream.open_camera(
self._video_url, extra_cmd=self._ffmpeg_arguments)
yield from async_aiohttp_proxy_stream(
self.hass, request, stream,
'multipart/x-mixed-replace;boundary=ffserver')
yield from stream.close()
@property
def should_poll(self):
"""Update the image periodically."""
return True
def update(self):
"""Update camera entity and refresh attributes."""
# extract the video expiration from URL
x_amz_expires = int(self._video_url.split('&')[0].split('=')[-1])
x_amz_date = self._video_url.split('&')[1].split('=')[-1]
self._utcnow = dt_util.utcnow()
self._expires_at = \
timedelta(seconds=x_amz_expires) + \
dt_util.as_utc(datetime.strptime(x_amz_date, "%Y%m%dT%H%M%SZ"))
if self._last_video_id != self._camera.last_recording_id:
_LOGGER.debug("Updated Ring DoorBell last_video_id")
self._last_video_id = self._camera.last_recording_id
if self._utcnow >= self._expires_at:
_LOGGER.debug("Updated Ring DoorBell video_url")
self._video_url = self._camera.recording_url(self._last_video_id)

View file

@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
from requests.exceptions import HTTPError, ConnectTimeout
REQUIREMENTS = ['ring_doorbell==0.1.4']
REQUIREMENTS = ['ring_doorbell==0.1.6']
_LOGGER = logging.getLogger(__name__)
@ -21,6 +21,7 @@ CONF_ATTRIBUTION = "Data provided by Ring.com"
NOTIFICATION_ID = 'ring_notification'
NOTIFICATION_TITLE = 'Ring Sensor Setup'
DATA_RING = 'ring'
DOMAIN = 'ring'
DEFAULT_CACHEDB = '.ring_cache.pickle'
DEFAULT_ENTITY_NAMESPACE = 'ring'

View file

@ -11,7 +11,7 @@ import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.components.ring import (
CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE)
CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING)
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS,
@ -27,24 +27,43 @@ SCAN_INTERVAL = timedelta(seconds=30)
# Sensor types: Name, category, units, icon, kind
SENSOR_TYPES = {
'battery': ['Battery', ['doorbell'], '%', 'battery-50', None],
'last_activity': ['Last Activity', ['doorbell'], None, 'history', None],
'last_ding': ['Last Ding', ['doorbell'], None, 'history', 'ding'],
'last_motion': ['Last Motion', ['doorbell'], None, 'history', 'motion'],
'volume': ['Volume', ['chime', 'doorbell'], None, 'bell-ring', None],
'battery': [
'Battery', ['doorbell', 'stickup_cams'], '%', 'battery-50', None],
'last_activity': [
'Last Activity', ['doorbell', 'stickup_cams'], None, 'history', None],
'last_ding': [
'Last Ding', ['doorbell', 'stickup_cams'], None, 'history', 'ding'],
'last_motion': [
'Last Motion', ['doorbell', 'stickup_cams'], None,
'history', 'motion'],
'volume': [
'Volume', ['chime', 'doorbell', 'stickup_cams'], None,
'bell-ring', None],
'wifi_signal_category': [
'WiFi Signal Category', ['chime', 'doorbell', 'stickup_cams'], None,
'wifi', None],
'wifi_signal_strength': [
'WiFi Signal Strength', ['chime', 'doorbell', 'stickup_cams'], 'dBm',
'wifi', None],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE):
cv.string,
vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
})
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up a sensor for a Ring device."""
ring = hass.data.get('ring')
ring = hass.data[DATA_RING]
sensors = []
for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
@ -56,6 +75,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
if 'doorbell' in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingSensor(hass, device, sensor_type))
for device in ring.stickup_cams:
if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingSensor(hass, device, sensor_type))
add_devices(sensors, True)
return True
@ -97,6 +120,7 @@ class RingSensor(Entity):
attrs['kind'] = self._data.kind
attrs['timezone'] = self._data.timezone
attrs['type'] = self._data.family
attrs['wifi_name'] = self._data.wifi_name
if self._extra and self._sensor_type.startswith('last_'):
attrs['created_at'] = self._extra['created_at']
@ -132,10 +156,18 @@ class RingSensor(Entity):
self._state = self._data.battery_life
if self._sensor_type.startswith('last_'):
history = self._data.history(timezone=self._tz,
kind=self._kind)
history = self._data.history(limit=5,
timezone=self._tz,
kind=self._kind,
enforce_limit=True)
if history:
self._extra = history[0]
created_at = self._extra['created_at']
self._state = '{0:0>2}:{1:0>2}'.format(
created_at.hour, created_at.minute)
if self._sensor_type == 'wifi_signal_category':
self._state = self._data.wifi_signal_category
if self._sensor_type == 'wifi_signal_strength':
self._state = self._data.wifi_signal_strength

View file

@ -900,7 +900,7 @@ restrictedpython==4.0a3
rflink==0.0.34
# homeassistant.components.ring
ring_doorbell==0.1.4
ring_doorbell==0.1.6
# homeassistant.components.notify.rocketchat
rocketchat-API==0.6.1

View file

@ -130,7 +130,7 @@ restrictedpython==4.0a3
rflink==0.0.34
# homeassistant.components.ring
ring_doorbell==0.1.4
ring_doorbell==0.1.6
# homeassistant.components.media_player.yamaha
rxv==0.5.1

View file

@ -50,6 +50,8 @@ class TestRingBinarySensorSetup(unittest.TestCase):
text=load_fixture('ring_devices.json'))
mock.get('https://api.ring.com/clients_api/dings/active',
text=load_fixture('ring_ding_active.json'))
mock.get('https://api.ring.com/clients_api/doorbots/987652/health',
text=load_fixture('ring_doorboot_health_attrs.json'))
base_ring.setup(self.hass, VALID_CONFIG)
ring.setup_platform(self.hass,

View file

@ -38,7 +38,9 @@ class TestRingSensorSetup(unittest.TestCase):
'last_activity',
'last_ding',
'last_motion',
'volume']
'volume',
'wifi_signal_category',
'wifi_signal_strength']
}
def tearDown(self):
@ -55,6 +57,10 @@ class TestRingSensorSetup(unittest.TestCase):
text=load_fixture('ring_devices.json'))
mock.get('https://api.ring.com/clients_api/doorbots/987652/history',
text=load_fixture('ring_doorbots.json'))
mock.get('https://api.ring.com/clients_api/doorbots/987652/health',
text=load_fixture('ring_doorboot_health_attrs.json'))
mock.get('https://api.ring.com/clients_api/chimes/999999/health',
text=load_fixture('ring_chime_health_attrs.json'))
base_ring.setup(self.hass, VALID_CONFIG)
ring.setup_platform(self.hass,
self.config,
@ -63,6 +69,12 @@ class TestRingSensorSetup(unittest.TestCase):
for device in self.DEVICES:
device.update()
if device.name == 'Front Battery':
self.assertEqual(80, device.state)
self.assertEqual('hp_cam_v1',
device.device_state_attributes['kind'])
self.assertEqual('stickup_cams',
device.device_state_attributes['type'])
if device.name == 'Front Door Battery':
self.assertEqual(100, device.state)
self.assertEqual('lpd_v1',
@ -73,6 +85,8 @@ class TestRingSensorSetup(unittest.TestCase):
self.assertEqual(2, device.state)
self.assertEqual('1.2.3',
device.device_state_attributes['firmware'])
self.assertEqual('ring_mock_wifi',
device.device_state_attributes['wifi_name'])
self.assertEqual('mdi:bell-ring', device.icon)
self.assertEqual('chimes',
device.device_state_attributes['type'])
@ -81,6 +95,15 @@ class TestRingSensorSetup(unittest.TestCase):
self.assertEqual('America/New_York',
device.device_state_attributes['timezone'])
if device.name == 'Downstairs WiFi Signal Strength':
self.assertEqual(-39, device.state)
if device.name == 'Front Door WiFi Signal Category':
self.assertEqual('good', device.state)
if device.name == 'Front Door WiFi Signal Strength':
self.assertEqual(-58, device.state)
self.assertIsNone(device.entity_picture)
self.assertEqual(ATTRIBUTION,
device.device_state_attributes['attribution'])

View file

@ -0,0 +1,18 @@
{
"device_health": {
"average_signal_category": "good",
"average_signal_strength": -39,
"battery_percentage": 100,
"battery_percentage_category": null,
"battery_voltage": null,
"battery_voltage_category": null,
"firmware": "1.2.3",
"firmware_out_of_date": false,
"id": 999999,
"latest_signal_category": "good",
"latest_signal_strength": -39,
"updated_at": "2017-09-30T07:05:03Z",
"wifi_is_ring_network": false,
"wifi_name": "ring_mock_wifi"
}
}

View file

@ -75,5 +75,143 @@
"high"]},
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"}],
"stickup_cams": [
{
"address": "123 Main St",
"alerts": {"connection": "online"},
"battery_life": 80,
"description": "Front",
"device_id": "aacdef123",
"external_connection": false,
"features": {
"advanced_motion_enabled": false,
"motion_message_enabled": false,
"motions_enabled": true,
"night_vision_enabled": false,
"people_only_enabled": false,
"shadow_correction_enabled": false,
"show_recordings": true},
"firmware_version": "1.9.3",
"id": 987652,
"kind": "hp_cam_v1",
"latitude": 12.000000,
"led_status": "off",
"location_id": null,
"longitude": -70.12345,
"motion_snooze": {"scheduled": true},
"night_mode_status": "false",
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Foo",
"id": 999999,
"last_name": "Bar"},
"ring_cam_light_installed": "false",
"ring_id": null,
"settings": {
"chime_settings": {
"duration": 10,
"enable": true,
"type": 0},
"doorbell_volume": 11,
"enable_vod": true,
"floodlight_settings": {
"duration": 30,
"priority": 0},
"light_schedule_settings": {
"end_hour": 0,
"end_minute": 0,
"start_hour": 0,
"start_minute": 0},
"live_view_preset_profile": "highest",
"live_view_presets": [
"low",
"middle",
"high",
"highest"],
"motion_announcement": false,
"motion_snooze_preset_profile": "low",
"motion_snooze_presets": [
"none",
"low",
"medium",
"high"],
"motion_zones": {
"active_motion_filter": 1,
"advanced_object_settings": {
"human_detection_confidence": {
"day": 0.7,
"night": 0.7},
"motion_zone_overlap": {
"day": 0.1,
"night": 0.2},
"object_size_maximum": {
"day": 0.8,
"night": 0.8},
"object_size_minimum": {
"day": 0.03,
"night": 0.05},
"object_time_overlap": {
"day": 0.1,
"night": 0.6}
},
"enable_audio": false,
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"sensitivity": 5,
"zone1": {
"name": "Zone 1",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone2": {
"name": "Zone 2",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone3": {
"name": "Zone 3",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}}},
"pir_motion_zones": [0, 1, 1],
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"stream_setting": 0,
"video_settings": {
"ae_level": 0,
"birton": null,
"brightness": 0,
"contrast": 64,
"saturation": 80}},
"siren_status": {"seconds_remaining": 0},
"stolen": false,
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"}]
}

View file

@ -0,0 +1,18 @@
{
"device_health": {
"average_signal_category": "good",
"average_signal_strength": -39,
"battery_percentage": 100,
"battery_percentage_category": null,
"battery_voltage": null,
"battery_voltage_category": null,
"firmware": "1.9.2",
"firmware_out_of_date": false,
"id": 987652,
"latest_signal_category": "good",
"latest_signal_strength": -58,
"updated_at": "2017-09-30T07:05:03Z",
"wifi_is_ring_network": false,
"wifi_name": "ring_mock_wifi"
}
}