Page 4 of 5

Re: Ring 2 Doorbell

Posted: Monday 15 November 2021 22:47
by DomoRobotto
Hi all,

I see a lot of people struggling and I am not sure this will help but i spent an hour or so today to get this working for my intended use case and will share some working code in the event it helps any of you. ***Working as of the date of this post***

First some prerequisites:
1) You must make sure you pip3 install the ring_doorbell package (https://github.com/tchellomello/python-ring-doorbell) and make sure you are running this in a python3.6+ runtime not python2.x
2) Keep in mind, my home automation setup is micro service based with each workload running on a microk8s home kubernetes cluster consisting of 4 Rasp Pi's. The code I am sharing runs as a service and is NOT some kind of Domoticz drop-in plugin. It's just raw python code which triggers a Domoticz virtual switch and actions can be linked to state changes on that switch from inside Domoticz. How you implement this code snippet is up to you. The intended audience is not the average user, I guess.
3) The code below is tested and seems to be stable for 2 doorbell devices (a front and back doorbell). Should work for more by adding additional 'if device_name == "3rd device name"' statements.
5) The device this code is developed for and tested on is a Ring Video Doorbell Wired (the cheapest, smallest one with no battery - 2020 version). I dont see why it should not work with the other devices as well.
6) On first run, it will ask you for a 2FA code which for me is sent by text message to my configured tel#
6.5) Pay CLOSE attention to the first lines of output on startup. This will tell you the names of your configured devices and you need to use this to configure the name to IDX mapping in the top config section. They must be named the same or Domoticz will not be notified.
7) If you are getting missing_token errors, you must use your own unique API name and version (see this post: https://github.com/tchellomello/python- ... -874319220 ). Ring seems to be flagging the default value and blocking it.
8) I have no idea if this will work if you do not have at least a basic subscription. Would be nice to know if anyone wants to test and report back?
9) You must read, understand, and edit the top section of this code to match your own environment
10) This is untested under windows and therefore not guaranteed to be working at all. More robust cross platform refactoring would need to be implemented for windows file paths and maybe more. Not my passion to worry about windows OS's :D

Features / Notes:
- will watch for any new alerts triggered from your doorbells and immediately toggle a Domoticx IDX# to On(you need to set the IDX to match your own setup)
- allows some time for the video to finalize and upload, and then will download the video to your chosen location - in my case, my local NAS (look at the top section with the comment '# Edit these' and edit accordingly).
- The IDX's in the code below I set up with an Off Delay set to 60 seconds so that it will "clear the motion" on the alarm after 60 seconds and not always remain in the alarm state. This will need configured manually by you in the Domoticz web UI.
- Each event spawns a new thread to wait for the video to finalize and then will download the video from the event without blocking the main polling thread

Ok here is the code (I am updating this as i work on it):

Code: Select all

import json, os
from pathlib import Path
from ring_doorbell import Ring, Auth
from oauthlib.oauth2 import MissingTokenError
import time
from datetime import datetime, timezone
import urllib.request as urllib
from threading import Thread

######### This Section Needs Edited By You - unique_api_name as well! ###########################
your_username = 'CHANGEME'
your_password = 'CHANGEME'
your_unique_api_name = 'PythonMonitorAPI/0.1.1'
cache_file = Path("/app/Ring/cached_auth_token.json")
videopath = '/app/Ring/Captures/'
poll_interval = 1.0  # interval in seconds that we poll the Ring API for new events
# Domoticz setup
domoticz_api = 'http://192.168.1.240:30000'
domoticz_idx_mapping = {"Front Door": 528, "Back Door": 529}
#################################################################################################

###### DO NOT EDIT THESE IF YOU DO NOT KNOW WHAT THEY ARE FOR ###################################
# .env overrides: 1) create your .env file with the exported VARS below (ie. export RING_USERNAME='MYUSERNAME')
#                 2) source them and start this script like:  $ source .env && python3 <script_name>.py
# Note: decided to handle this natively rather than use the python-dotenv package
username = os.getenv('RING_USERNAME', your_username)
password = os.getenv('RING_PASSWORD', your_password)
unique_api_name = os.getenv('RING_API_NAME', your_unique_api_name)


def authenticate():
    if cache_file.is_file():
        auth = Auth(unique_api_name, json.loads(cache_file.read_text()), token_updated)
    else:
        auth = Auth(unique_api_name, None, token_updated)
        try:
            auth.fetch_token(username, password)
        except MissingTokenError:
            auth.fetch_token(username, password, otp_callback())

    auth = Ring(auth)
    return auth


def authenticate_and_initialize(gadget_type: str = "doorbells"):
    myring = authenticate()
    myring.update_data()
    devices = myring.devices()

    if gadget_type is "doorbells":
        doorbells = devices['doorbots']
        enumerate_doorbells(doorbells)

        return myring, doorbells


def ts(filename_format: bool = False, dirname_format: bool = False):
    if dirname_format:
        return f"{datetime.now().strftime('%Y-%m-%d')}/"
    if filename_format:
        return datetime.now().strftime('%H-%M-%S')
    else:
        return datetime.now().strftime('%Y-%m-%d %H:%M:%S')


def construct_local_filename(triggered_device, device_name: str, last_event_type: str, newest_event_id: int):
    filename_timestamp = triggered_device.history(limit=1, enforce_limit=True)[0]['created_at'].replace(
        tzinfo=timezone.utc).astimezone(tz=None).strftime('%H-%M-%S')

    local_filename = f"{videopath}{daily_directory()}{device_name.replace(' ', '')}_{last_event_type}_" \
                     f"{filename_timestamp}_{str(newest_event_id)[-4:]}.mp4"

    return local_filename


def daily_directory():
    daily_directory_path = videopath + ts(dirname_format=True)

    if os.path.isdir(daily_directory_path):
        return ts(dirname_format=True)
    else:
        os.mkdir(daily_directory_path)
        print(f"{ts()}: Created new daily directory at {daily_directory_path}")
        return ts(dirname_format=True)


def token_updated(token):
    cache_file.write_text(json.dumps(token))


def otp_callback():
    auth_code = input("2FA code: ")
    return auth_code


def enumerate_doorbells(doorbells):
    for doorbell in doorbells:
        print(f"{ts()}: Authentication and discovery successful for {doorbell}.")


def notify_domoticz(device_name: str):
    idx = domoticz_idx_mapping.get(device_name)
    urllib.urlopen(f'{domoticz_api}/json.htm?type=command&param=switchlight&idx={idx}&switchcmd=On')
    print(f"{ts()}: Domoticz notified of event at {device_name}")


def find_newest_recording_id(triggered_device):
    last_recorded_id = triggered_device.last_recording_id or None

    if len(triggered_device.history(limit=1, enforce_limit=True)) > 0:
        newest_history_id = triggered_device.history(limit=1, enforce_limit=True)[0].get('id')

        if last_recorded_id and newest_history_id:
            newest_item = max(last_recorded_id, newest_history_id)
            return newest_item
        else:
            return None


def check_device_has_active_alerts(device_name: str, doorbells):
    try:
        triggered_device = next((doorbell for doorbell in doorbells if doorbell.name == device_name), None)
        last_event_type = triggered_device.history(limit=1)[0]['kind']
        newest_event_id = find_newest_recording_id(triggered_device)

        if "on_demand" in last_event_type:
            print(f"{ts()}: skipping {device_name} '{last_event_type}' event (id: {newest_event_id})")
            return None, None, None
        else:
            print(f"{ts()}: {device_name} '{last_event_type}' event was triggered (id: {newest_event_id})")
            notify_domoticz(device_name)
    except:
        triggered_device, last_event_type, newest_event_id = None, None, None

    return triggered_device, last_event_type, newest_event_id


def download_recording(newest_event_id: int = None, local_filename: str = None, device_name: str = None,
                       doorbells=None):
    triggered_device = next((doorbell for doorbell in doorbells if doorbell.name == device_name), None)
    print(f"{ts()}: Attempting to download recording for {device_name} (id: {newest_event_id})...\n")

    if newest_event_id and local_filename and triggered_device:
        remaining_download_tries = 5

        while remaining_download_tries > 0:
            try:
                if triggered_device.recording_download(newest_event_id, filename=local_filename, override=True,
                                                       timeout=120):
                    print(f"{ts()}: Saved {device_name} video to: {local_filename}")
            except:
                print(f"{ts()}: Error downloading {device_name} video from on try number "
                      f"{str(6 - remaining_download_tries)}.")
                remaining_download_tries = remaining_download_tries - 1
                time.sleep(5.0)
                continue
            else:
                print(f"{ts()}: {device_name} (id: {newest_event_id}) download thread complete.\n")
                break


def queue_download(newest_event_id: int = None, local_filename: str = None, device_name: str = None, doorbells=None):
    if newest_event_id and local_filename and device_name:
        thread = Thread(target=download_recording,
                        args=(newest_event_id, local_filename, device_name, doorbells))
        thread.start()
        print(
            f"{ts()}: Download thread created (id: {newest_event_id}). Recording of this event is queued for download.")
    else:
        print(f"{ts()}: Unexpected error in queue_download()\n")


def queue_download_to_local_file(newest_event_id, triggered_device, last_event_type: str, device_name: str, doorbells):
    local_filename = construct_local_filename(triggered_device, device_name, last_event_type, newest_event_id)

    print(f"{ts()}: Spawning download thread for {device_name} (id: {newest_event_id})...")
    queue_download(newest_event_id, local_filename, device_name, doorbells)


def main():
    myring, doorbells = authenticate_and_initialize(gadget_type="doorbells")

    if myring and doorbells:
        print(f"{ts()}: Entering observation loop - polling interval set to {poll_interval}s\n")

    while True:
        myring.update_dings()
        alerts = myring.active_alerts()

        if alerts:
            for alert in alerts:
                try:
                    device_name = alert['doorbot_description']
                    triggered_device, last_event_type, newest_event_id = check_device_has_active_alerts(device_name,
                                                                                                        doorbells)

                    if triggered_device and last_event_type and newest_event_id:
                        queue_download_to_local_file(newest_event_id, triggered_device, last_event_type, device_name,
                                                     doorbells)
                except:
                    time.sleep(poll_interval)
                    continue

        time.sleep(poll_interval)


if __name__ == "__main__":
    main()




Hope this is useful to someone! Enjoy!

Cheers!
DR

Edit 1: Added some more detail, updated version of code, and an idea of what it looks like when its running.
Edit 2: Version 0.1.1 - fixed .env file parsing if one exists.
Edit 3: Version 0.1.2 - added multithreading, added graceful handling of common errors/timeouts and implemented some basic retry logic on encountering errors, added grouping of videos into daily directories/folders, event type detection and labeling in logging and file names
Edit 4: Version 0.2.0 - refactored main() loop to separate areas of concern, moved all hardcoded configuration into the top configuration section, updated documentation, removed excessive sleep (the API only seems to work in such a way that each new event retrieves only the video from the event before it - so you are always one event behind).
Edit 5: Version 0.2.2 - switched from retrieving videos from a download URL to using the recording_download() method which also puts a timestamp in the video which i find nifty. Still have not cracked the "always behind by one event" issue when downloading but I personally dont find that too annoying. Added a filter to ignore live view sessions (on_demand event types)

Re: Ring 2 Doorbell

Posted: Tuesday 16 November 2021 0:04
by MikeF
I am following the examples in this thread, but I am continually getting this error:

oauthlib.oauth2.rfc6749.errors.MissingTokenError: (missing_token) Missing access token parameter

Any clues?

Re: Ring 2 Doorbell

Posted: Tuesday 16 November 2021 7:44
by DomoRobotto
Can you try to change the two instances of

Code: Select all

MyOwnAPI/0.1.0
in the code example above to something else unique for you?

https://github.com/tchellomello/python- ... -874319220

Re: Ring 2 Doorbell

Posted: Tuesday 16 November 2021 10:57
by MikeF
Many thanks - that worked! Would have helped if I'd read point 7 in @DomoRobotto's post properly! ;)

Re: Ring 2 Doorbell

Posted: Tuesday 16 November 2021 11:36
by DomoRobotto
MikeF wrote: Tuesday 16 November 2021 10:57 Many thanks - that worked! Would have helped if I'd read point 7 in @DomoRobotto's post properly! ;)

Oh... to be fair, i added point 7 after your confused post regarding this matter. No worries! Glad you got it to work!

Re: Ring 2 Doorbell

Posted: Tuesday 16 November 2021 11:49
by MikeF
DomoRobotto wrote: Monday 15 November 2021 22:47 I see a lot of people struggling...
...
Hope it's useful to someone!
Yes, this works well, many thanks! :D

Re: Ring 2 Doorbell

Posted: Thursday 18 November 2021 21:18
by DomoRobotto
Hi All,

I have been playing with this some more and have expanded my original code and made it a little more robust, added multithreading, added multi device support, event type detection and logging, descriptive event type in saved file names, and more...

Original post has been updated with the new code here:
viewtopic.php?p=282381#p282381

Enjoy!
- DR

Re: Ring 2 Doorbell

Posted: Friday 19 November 2021 8:21
by Doudy
Hello,
Excuse my ignorance, but do I have to install the tchellomello script first? :oops:

https://github.com/tchellomello/python-ring-doorbell

Re: Ring 2 Doorbell

Posted: Friday 19 November 2021 9:23
by DomoRobotto
Doudy wrote: Friday 19 November 2021 8:21 Hello,
Excuse my ignorance, but do I have to install the tchellomello script first? :oops:

https://github.com/tchellomello/python-ring-doorbell
yes that's right... see point 1 in my post above. But.... make sure you have pip3 installed first and to be sure you dont install a pip2 version of the package make sure when you install it use

Code: Select all

pip3 install <the package name>

Re: Ring 2 Doorbell

Posted: Friday 19 November 2021 9:45
by Doudy
DomoRobotto wrote: Friday 19 November 2021 9:23 yes that's right... see point 1 in my post above. But.... make sure you have pip3 installed first and to be sure you dont install a pip2 version of the package make sure when you install it use

Code: Select all

pip3 install <the package name>
👍 ;)

Re: Ring 2 Doorbell

Posted: Friday 19 November 2021 11:43
by DomoRobotto
@all,

FYI, another update to the code here: viewtopic.php?p=282381#p282381

I think I will call it quits here. This meets my needs and seems to work nicely (until they change the API or rate limit / block me :D :D)

Re: Ring 2 Doorbell

Posted: Saturday 20 November 2021 23:34
by MikeF
the API only seems to work in such a way that each new event retrieves only the video from the event before it - so you are always one event behind
I've reverted to version 0.1.0 (non-threading). Although by no means exhaustive testing, I seem to be able to save the current event by using the method recording_download instead of recording_url and urlretrieve.

I made the following changes:

Code: Select all

#            dl_url = doorbell.recording_url(doorbell.last_recording_id)
            file_name = f"{videopath}FrontDoor_{ts(filename_friendly=True)}.mp4"
            doorbell.recording_download(doorbell.last_recording_id, file_name)
#            if dl_url:
#                urllib2.urlretrieve(dl_url, file_name)
#                print (f"{ts()}: Saved video location: {file_name}")
#            else:
#                print (f"{ts()}: Error downloading video from {dl_url}")
EDIT: Looking at the API, recording_download and recording_url use different URLs: URL_RECORDING (/clients_api/dings/{0}/recording) and URL_RECORDING_SHARE_PLAY (/clients_api/dings/{0}/share/play) respectively - is this significant?

Re: Ring 2 Doorbell

Posted: Sunday 21 November 2021 10:25
by DomoRobotto
MikeF wrote: Saturday 20 November 2021 23:34
the API only seems to work in such a way that each new event retrieves only the video from the event before it - so you are always one event behind
I've reverted to version 0.1.0 (non-threading). Although by no means exhaustive testing, I seem to be able to save the current event by using the method recording_download instead of recording_url and urlretrieve.

EDIT: Looking at the API, recording_download and recording_url use different URLs: URL_RECORDING (/clients_api/dings/{0}/recording) and URL_RECORDING_SHARE_PLAY (/clients_api/dings/{0}/share/play) respectively - is this significant?
Oh! Nice tip! I didn't even play with the .recording_download method yet but after just having a look it sedems much nicer. It also puts a timestamp in the video whereas the download URL does not. Gonna play with it a bit and might switch to this method as well.

Re: Ring 2 Doorbell

Posted: Sunday 21 November 2021 13:33
by DomoRobotto
Hmm... I updated my code in the original post again... switched from retrieving videos from a download URL to using the recording_download() method which also puts a timestamp in the video which i find nifty (nice tip @mikef !). Still have not cracked the "always behind by one event" - i can can see that there is a newer event id and i can download that event but there is no straightforward way of knowing how long a "freshly triggered" event will record for so the result is always a downloaded video which is shorter than the actual video. I wish there was an API method to check if video is currently being recorded or uploaded so you could just poll that and wait until False before attempting to download.

I also added a filter to not download live viewing sessions ('on_demand' event types). For me it's not useful. See the check_device_has_active_alerts() function if you don't want this behavior.

code in this post: viewtopic.php?p=282381#p282381

Re: Ring 2 Doorbell

Posted: Tuesday 21 December 2021 2:21
by roykaandorp
@DomoRobotto
Works great! Thank you for sharing. I would like to use it to trigger a regular gong but that's not really usefull when it's also triggered by motion. Is it possible to only trigger when the doorbell is pressed?
Edit:
Found something promising, https://github.com/tsightler/ring-mqtt, I’ll try this when I’ve time for it, hopefully it gets recognized by Domoticz mqtt auto discovery
Edit2:
Auto discovery found most switches, but till now it doesn't update the switches

Re: Ring 2 Doorbell

Posted: Tuesday 15 February 2022 11:25
by Martini77
I got the 'motion' and on demand live view filtered by the kind of alert.

# Now loop infinitely
while(1):
myring.update_dings()
if myring.active_alerts() != []:
logger.info('We have an alert!')
active_alert = myring.active_alerts()[0]
alert_json = json.loads(json.dumps(active_alert))
# alert_json = json.loads(json.dumps(ast.literal_eval("%s" % active_alert))) in case the above gives issues; see https://domoticz.com/forum/viewtopic.ph ... 2&start=40
if alert_json['kind'] == 'ding':
logger.info('Ding dong! -> Domoticz')
post_domo = domoticzrequest('http://<domoticz ip>:<port>/json.htm?type=command&param=switchlight&idx=<idx>&switchcmd=On')
elif alert_json['kind'] == 'motion':
logger.info('Motion at the front door, do nothing!')
elif alert_json['kind'] == 'on_demand':
logger.info('On demand live view, do nothing!')
else:
logger.info('OTHER EVENT!!!')
logger.info(alert_json['kind'])
# Loop around
sleep(2)

Re: Ring 2 Doorbell

Posted: Saturday 04 June 2022 18:28
by grecof973
In the end, after much research and few results and given the reliability of the whole, I decided to abandon RING and switched to SIP intercoms instead.
Another planet much more reliable and in addition I integrated home phone, mobile phone and intercom all in one app, and at the base of everything FreePBX

Re: Ring 2 Doorbell

Posted: Thursday 16 June 2022 23:23
by robgeerts
I installed this script today and it almost works, the problem is when i actived motion detection.
"last_event_type" is in my case always 'motion'...
Never 'ding'.

When i disabled motion detection, my 'last_event_type' is 'ding' and the script is working fine.
In the most ideal situation i want one domoticz switch for motion and one for ding.. how to solve if ding is never a 'last_event_type'?

Code: Select all

def check_device_has_active_alerts(device_name: str, doorbells):
    try:
        triggered_device = next((doorbell for doorbell in doorbells if doorbell.name == device_name), None)
        last_event_type = triggered_device.history(limit=1)[0]['kind']
        newest_event_id = find_newest_recording_id(triggered_device)

        if "ding" in last_event_type:
            print(f"{ts()}: {device_name} '{last_event_type}' event was triggered (id: {newest_event_id})")
            notify_domoticz(device_name)
    except:
        triggered_device, last_event_type, newest_event_id = None, None, None

    return triggered_device, last_event_type, newest_event_id

Re: Ring 2 Doorbell

Posted: Saturday 18 June 2022 15:47
by grecof973
robgeerts wrote: Thursday 16 June 2022 23:23 I installed this script today and it almost works, the problem is when i actived motion detection.
"last_event_type" is in my case always 'motion'...
Never 'ding'.
As I have already said, since the installation of the script was unclear, I changed the system by switching to a sip intercom.
But since you say you have installed the script, can you give some info on the procedure ???

Re: Ring 2 Doorbell

Posted: Sunday 19 June 2022 23:01
by robgeerts
grecof973 wrote: Saturday 18 June 2022 15:47
robgeerts wrote: Thursday 16 June 2022 23:23 I installed this script today and it almost works, the problem is when i actived motion detection.
"last_event_type" is in my case always 'motion'...
Never 'ding'.
As I have already said, since the installation of the script was unclear, I changed the system by switching to a sip intercom.
But since you say you have installed the script, can you give some info on the procedure ???
I followed these steps:
viewtopic.php?p=282381&sid=3ca51af3f8c4 ... 74#p282381

Nothing more :)
Im running the py-script in the background and it works flawlessly, what part didnt work for you?