Ringface: Accessing the Ring Device

Ring does not provide an official API, but it uses one internally. In this article we will try to address that.

The python-ring-doorbell project has reverse engineered this API into a nice Python library, which we will use here. We will wrap this library for two use cases

  1. Collecting the an OAUTH2 access and refresh tokens to call the actual APIs
  2. Wrapping it into a Flask server to expose a REST API to trigger video downloads

The component responsible in our architecture for these tasks is the connector

Setting up the python project

If you are familiar with python, you can jump to the next chapter.

We will use python 3 here, so you will need to install that. You can check the details for your OS  here for a mac with brew simply run 

brew install python3

Python provides a concept of virtual envs which is a sort of superset of the project level package management known from javascript or other languages. It is advisable to activate this by

python3 -m venv venv
source venv/bin/activate

Packages in python are managed via the requirements.txt file, and we will create one with the dependencies we need here. We will only use two here, the aforementioned ring_doorbell and config managment via python-decpouple.

#requirements.txt
ring_doorbell==0.7.2
python-decouple

To install these requirements into your python virtual env, run pip3 install -r requirements.txt

We will create the package (direcxtory) ringConnector to scope the modules we are creating next.

mkdir ringConnector

Authorization module

To call the internal Ring API we will need to provide an OAUTH2 token. Here, the python-ring-doorbell lib already provides most of the code, so we have little to do. You can find the final code of this module in GitHub.

@startuml

participant "User" as U
participant "Console Script" as D
participant "Authorization-Module" as A
participant "Ring-OAuth" as R
database "File System" as FS

D -> A: 
D <- U: Enter user and password
D -> A:
A -> R: Send entered user and password
R -> U: 2FA challenge
U -> D: Enter 2FA response
D -> A: 
A -> R: Send 2FA response
R -> A: Negotiate tokens
A -> FS: Write authorization json file

@enduml

Create the module (file) ringConnector/authorization.py and add the following content. Basically we create new Auth and provide it a callback for token refresh (token_updated) and second factor authorisation (otp_callback)

# ringConnector/authorization.py


oauth_file = Path(config("OAUTH_FILE", default="oauth-authorization.json"))


"""
Asks for the user credentials and creates the oauth json file
"""
def writeAuthFile():

    logging.warn(f"will create new oauth json file at {oauth_file}")

    username = input("Please enter your ring Username: ")
    password = getpass.getpass("Please enter your ring Password: ")
    auth = Auth("Ringface", None, token_updated)
    try:
        auth.fetch_token(username, password)
    except MissingTokenError:
        auth.fetch_token(username, password, otp_callback())

def token_updated(token):
    oauth_file.write_text(json.dumps(token))
    logging.warn(f"new token file created")

def otp_callback():
    auth_code = input("Please enter your 2FA code that was sent to you: ")
    return auth_code

We now wrap the above code in an shell launcher called startCreateAuthFile.py

#!/usr/bin/env python3
from ringConnector.authorization import writeAuthFile
writeAuthFile()

set the chmod +x flag, and run it: ./startCreateAuthFile.py. The script will ask you for your Ring user, password, and 2fa token, respectively, and create a local oauth-authorization.json file, with the OAUTH2 Access Token and Refrest Tokens. Please keep this file private, as it contains access rights to your Ring device.

# oauth-authorization.json
{
  "access_token": "xxx.yyy.zzz",
  "expires_in": 14400,
  "refresh_token": "xxx.yyy.zzz",
  "scope": ["client"],
  "token_type": "Bearer",
  "expires_at": 1697138450.575915
}

With the this authorization token in stash, we can now proceed to invoking the Ring internal API.

Download API

We plan to create two REST Endpoints for our purposes:

  • download videos for today
  • download videos for any day, which have not been downloaded yet

As before, we will base the core logic of this on the excellent python-ring-doorbell lib, and we will put a  Flask server as REST handler. The final code is available on GitHub.

@startuml

participant "API Client" as C
participant "Flask" as F
participant "Core Module" as P
participant "Ring API" as R
database "File System" as FS

C -> F: Download today
F -> P: Trigger download
P -> R: Call API
R --> P: API Response
P -> FS: Save stream as mp4 file
P -> F: Return list of downloaded files
F -> C: Return list of downloaded files as JSON

@enduml

To use the Flask server, we need to declare it in out requirements and reimport the dependencies by pip3 install -r requirements.txt

#requirements.txt
ring_doorbell==0.7.2
python-decouple
Flask

We will create a ringConnector/server.py module, for the REST endpoints, with the following content

@app.route('/connector/download/today')
def downloadForToday():
    logging.debug(f"Downloading todays events")
    eventsList = downloadDaysDingVideos()
    return jsonify(eventsList)

@app.route('/connector/download/<dayString>', methods=["POST"])
def downloadForDay(dayString):
    downloadedEventsRingIds = request.json
    logging.debug(f"Downloading {dayString}. Will not re-download events {downloadedEventsRingIds}")

    # assert dayString == request.view_args['day']
    
    dayToDownload = datetime.datetime.strptime(dayString, '%Y%m%d').date()

    eventsList = downloadDaysDingVideos(dayToDownload = dayToDownload, downloadedEventsRingIds = downloadedEventsRingIds)

    logging.debug(f"downloaded {len(eventsList)} new events for {dayToDownload}")

    return jsonify(eventsList)

Note we are exposing a GET endpoint on line 1, and a POST endpoint on line 7. The reason for the POST endpoint is, that we will be passing it the list of already downloaded videos for a given day. A use case is when you poll your Ring doorbell every hour, or after a restart to get just the difference. This will save significant runtime, as downloading mp4 files may result in long runtimes. In both of the above API, the call is propagated to the downloadDaysDingVideos method in the core module.

The ringConnector/core.py module will actually run the bulk of the work.

def downloadDaysDingVideos(dayToDownload=date.today(), dirStructure=DEFAULT_DIR_STUCTURE, downloadedEventsRingIds = []):

    logging.debug(f"exising events will not be downloaded: {downloadedEventsRingIds}")
    ring = getRing()

    downloadedEvents = []

    logging.info(f"Importing all ding event videos for {dayToDownload}")
    devices = ring.devices()
    for doorbell in devices['doorbots']:
        for event in doorbell.history(limit=300, kind='ding'):
            if (dayToDownload == None or event['created_at'].date() == dayToDownload):
                if str(event["id"]) in downloadedEventsRingIds:
                    logging.debug(f"event {event['id']} already present, will not re-download")
                elif event["recording"]["status"] != "ready":
                    logging.debug(f"event {event['id']} is not yet ready, and can not be downloaded")
                else:
                    logging.debug(f"event {event['id']} will be downloaded")
                    eventJson = downloadAndSaveEvent(event, doorbell, dirStructure, dayToDownload)
                    downloadedEvents.append(eventJson)

    return downloadedEvents

Here we iterate over the ring devices (9) in case more than one doorbell is installed. We will disregard events downloaded previously (13). Events that are not in ready state (15) are still being finalised by the ring servers, and will not provide a valid mp4 file. For all the rest, we run the download, and save the file:

def downloadAndSaveEvent(event, doorbell, dirStructure, dayToDownload):

    id  = event['id'] 
    eventName = event['created_at'].strftime("%Y%m%d-%H%M%S")

    filename = f"{dirStructure.videos}/{eventName}"
    videoFileName = filename+".mp4" 

    eventJson = {
        'ringId':str(id), 
        'date': dayToDownload.strftime("%Y%m%d"),
        'eventName': eventName,
        'createdAt':event['created_at'].strftime('%Y-%m-%dT%H:%M:%S.%fZ'), #json formatted event time
        'answered': event['answered'], 
        'kind': event['kind'], 
        'duration': event['duration'],
        'videoFileName': videoFileName,
        'status': 'UNPROCESSED'
    }
    with open(filename + ".json", 'w') as eventDetails:
        json.dump(eventJson, eventDetails)

    # short after the event, the video is not yet be available for download
    # on unavailablility retry for 100 sec
    i = 1
    while True:
        try:
            doorbell.recording_download(id,filename=videoFileName,override=True)
            break
        except requests.exceptions.HTTPError as err:
            logging.info(f"{videoFileName} is not yet available. will retry in 10 sec. Err: {err.response.status_code}")
            i = i + 1
            if i == 10:
                break
            else:
                time.sleep(10)

    return eventJson

The loop (26) ensures, that when we start the download right after an Ring Event was captured, we will wait until the recording is finalised, and available for download. Capturing this trigger event (someone pressed the doorbell) will be a subject of the post next week.

We now wrap the above code in an shell launcher called startDownload.py

#!/usr/bin/env python3

import logging
import datetime
import sys


from ringConnector import core

logging.getLogger().setLevel(logging.INFO)
devices = core.listAllDevices()
logging.info(f"found devices {devices}")


print ('Argument List:', str(sys.argv))
if len(sys.argv) > 1:
    dayToDownloadParam = sys.argv[1]
    logging.info(f"will download {dayToDownloadParam}")
    dayToDownload = datetime.datetime.strptime(dayToDownloadParam, '%Y-%m-%d').date()
    res = core.downloadDaysDingVideos(dayToDownload = dayToDownload)
else:
    logging.info('will download today')
    res = core.downloadDaysDingVideos()
 

logging.info(res)

set the chmod +x flag, and run it: ./startDownload.py.

(venv) MBP:ringface-connector$ ./startDownload.py 
INFO:root:found devices {'stickup_cams': [<RingStickUpCam: Downstairs>, <RingStickUpCam: XXXX>], 'chimes': [<RingChime: YYY>], 'doorbots': [<RingDoorBell: ZZZ>], 'authorized_doorbots': []}
INFO:root:will download today
INFO:root:Importing all ding event videos for 2022-10-13
INFO:root:[{'ringId': '7289369076883314527', 'date': '20221013', 'eventName': '20221013-091454', 'createdAt': '2022-10-13T09:14:54.000000Z', 'answered': False, 'kind': 'ding', 'duration': 33.0, 'videoFileName': './data/videos/20221013-091454.mp4', 'status': 'UNPROCESSED'}]

At this point, you should have a local file in your ./data/videos directory, which you can skim for content.

In our post next week, we will look at the downloaded file, how to extract the relevant frames, how to find faces in those frame, and how to recognise persons (classify faces).