From 51a65ee8e9a6f4bf43c6c5213d30cc75c52467be Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Sat, 21 Oct 2017 10:08:40 -0400 Subject: [PATCH] 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. --- .coveragerc | 1 + .../components/binary_sensor/ring.py | 16 +- homeassistant/components/camera/ring.py | 141 ++++++++++++++++++ homeassistant/components/ring.py | 3 +- homeassistant/components/sensor/ring.py | 52 +++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/binary_sensor/test_ring.py | 2 + tests/components/sensor/test_ring.py | 25 +++- tests/fixtures/ring_chime_health_attrs.json | 18 +++ tests/fixtures/ring_devices.json | 138 +++++++++++++++++ .../fixtures/ring_doorboot_health_attrs.json | 18 +++ 12 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/camera/ring.py create mode 100644 tests/fixtures/ring_chime_health_attrs.json create mode 100644 tests/fixtures/ring_doorboot_health_attrs.json diff --git a/.coveragerc b/.coveragerc index b47616973f6..0cd866b321e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 5c9a644f6b7..1e926f00a2f 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -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 diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py new file mode 100644 index 00000000000..70569825764 --- /dev/null +++ b/homeassistant/components/camera/ring.py @@ -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) diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index a1529fddbd6..701889d60b5 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -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' diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py index 606b049b7e4..6c8794d096f 100644 --- a/homeassistant/components/sensor/ring.py +++ b/homeassistant/components/sensor/ring.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 37201e35920..f0cb207f451 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdd6a55bc0c..30a19dd117b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/binary_sensor/test_ring.py b/tests/components/binary_sensor/test_ring.py index 58a357be1b6..889282b56dd 100644 --- a/tests/components/binary_sensor/test_ring.py +++ b/tests/components/binary_sensor/test_ring.py @@ -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, diff --git a/tests/components/sensor/test_ring.py b/tests/components/sensor/test_ring.py index ada1992ac0c..fb31dc7c53c 100644 --- a/tests/components/sensor/test_ring.py +++ b/tests/components/sensor/test_ring.py @@ -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']) diff --git a/tests/fixtures/ring_chime_health_attrs.json b/tests/fixtures/ring_chime_health_attrs.json new file mode 100644 index 00000000000..027470b480e --- /dev/null +++ b/tests/fixtures/ring_chime_health_attrs.json @@ -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" + } +} diff --git a/tests/fixtures/ring_devices.json b/tests/fixtures/ring_devices.json index 4d204ba5250..4248bbf812d 100644 --- a/tests/fixtures/ring_devices.json +++ b/tests/fixtures/ring_devices.json @@ -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"}] } diff --git a/tests/fixtures/ring_doorboot_health_attrs.json b/tests/fixtures/ring_doorboot_health_attrs.json new file mode 100644 index 00000000000..f84678d9ab0 --- /dev/null +++ b/tests/fixtures/ring_doorboot_health_attrs.json @@ -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" + } +}