pax_global_header00006660000000000000000000000064147537425170014531gustar00rootroot0000000000000052 comment=d761d752440f31da6d3d545845bcdf70dca2a1d6 pyaarlo-0.8.0.15/000077500000000000000000000000001475374251700134315ustar00rootroot00000000000000pyaarlo-0.8.0.15/.gitignore000066400000000000000000000024141475374251700154220ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # mine *~ .*.sw? aarlo/ *.diff rsa.* diffs video_dir .idea docs/out bugs/ videos/ logs/ pyaarlo-0.8.0.15/LICENSE000066400000000000000000000167441475374251700144520ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. pyaarlo-0.8.0.15/README.md000066400000000000000000000437351475374251700147240ustar00rootroot00000000000000# Pyaarlo ### Breaking Changes #### Trusted Browser Support _Arlo_ recently changed their back end and reduced the lifetime of the authentication token from 2 weeks to 2 hours. This code relied on that 2 weeks to reduce the number of 2fa requests we made. The code now acts more like the official website by using the "trusted browser" setting. With this we can request more authentication tokens for up to 2 weeks without using 2fa again. If this breaks the module for you please drop back to version `0.8.0.14` and create an issue. ## Table of Contents - [Introduction](#introduction) - [Installation](#installation) - [Usage](#usage) - [Pyaarlo Executable](#executable) - [User Agent](#user-agent) - [Saving Media](#saving-media) - [2 Factor Authentication](#2fa) * [Manual](#2fa-manual) * [IMAP](#2fa-imap) - [Error Reporting](#errors) - [Limitations](#limitations) - [Other 2 Factor Authentication](#2fa-other) ## Introduction Pyaarlo is a module for Python that provides asynchronous access to Netgear Arlo cameras. When you start Pyaarlo, it starts a background thread that opens a single, persistent connection, an *event stream*, to the Arlo servers. As things happen to the Arlo devices - motion detected, battery level changes, mode changes, etc... - the Arlo servers post these events onto the event stream. The background thread reads these events from the stream, updates Pyaarlo's internal state and calls any user registered callbacks. #### Differences from Pyarlo The biggest difference is Pyaarlo defaults to asynchronous mode by default. The following code brought from Pyarlo might not work: ```python base.mode = 'armed' if base.mode == 'armed': print('it worked!') ``` This is because between setting `mode` and reading `mode` the code has to: * build and send a mode change packet to Arlo * read the mode change packet back from the Arlo event stream * update its internal state for `base` I say "might" not work because it might work, it all depends on timing, and context switches and network speed... To enable synchronous mode you need to specify it when starting PyArlo. ```python # login, use console for 2FA if needed arlo = pyaarlo.PyArlo( username=USERNAME,password=PASSWORD, tfa_type='SMS',tfa_source='console', synchronous_mode=True) ``` #### Thanks Many thanks to: * [Pyarlo](https://github.com/tchellomello/python-arlo) and [Arlo](https://github.com/jeffreydwalter/arlo) for doing the original heavy lifting and the free Python lesson! * [sseclient](https://github.com/btubbs/sseclient) for reading from the event stream * [JetBrains](https://www.jetbrains.com/?from=hass-aarlo) for the excellent **PyCharm IDE** and providing me with an open source license to speed up the project development. [![JetBrains](/images/jetbrains.svg)](https://www.jetbrains.com/?from=hass-aarlo) ## Installation Proper PIP support is coming but for now, this will install the latest version. ```bash pip install git+https://github.com/twrecked/pyaarlo ``` ## Usage You can read the developer documentation here: [https://pyaarlo.readthedocs.io/](https://pyaarlo.readthedocs.io/) The following example will login to your Arlo system, use 2FA if needed, register callbacks for all events on all base stations and cameras and then wait 10 minutes printing out any events that arrive during that time. ```python # code to trap when attributes change def attribute_changed(device, attr, value): print('attribute_changed', time.strftime("%H:%M:%S"), device.name + ':' + attr + ':' + str(value)[:80]) # login, use console for 2FA if needed arlo = pyaarlo.PyArlo( username=USERNAME,password=PASSWORD, tfa_type='SMS',tfa_source='console') # get base stations, list their statuses, register state change callbacks for base in arlo.base_stations: print("base: name={},device_id={},state={}".format(base.name,base.device_id,base.state)) base.add_attr_callback('*', attribute_changed) # get cameras, list their statuses, register state change callbacks # * is any callback, you can use motionDetected just to get motion events for camera in arlo.cameras: print("camera: name={},device_id={},state={}".format(camera.name,camera.device_id,camera.state)) camera.add_attr_callback('*', attribute_changed) # disarm then arm the first base station base = arlo.base_stations[0] base.mode = 'disarmed' time.sleep(5) base.mode = 'armed' # wait 10 minutes, try moving in front of a camera to see motionDetected events time.sleep(600) ``` As mentioned, it uses the [Pyarlo](https://github.com/tchellomello/python-arlo) API where possible so the following code from the original [Usage](https://github.com/tchellomello/python-arlo#usage) will still work: ```python # login, use console for 2FA if needed, turn on synchronous_mode for maximum compatibility arlo = pyaarlo.PyArlo( username=USERNAME,password=PASSWORD, tfa_type='SMS',tfa_source='console',synchronous_mode=True) # listing devices arlo.devices # listing base stations arlo.base_stations # get base station handle # assuming only 1 base station is available base = arlo.base_stations[0] # get the current base station mode base.mode # 'disarmed' # listing Arlo modes base.available_modes # ['armed', 'disarmed', 'schedule', 'custom'] # Updating the base station mode base.mode = 'custom' # listing all cameras arlo.cameras # showing camera preferences cam = arlo.cameras[0] # check if camera is connected to base station cam.is_camera_connected # True # printing camera attributes cam.serial_number cam.model_id cam.unseen_videos # get brightness value of camera cam.brightness ``` ## User Agent The `user_agent` option will control what kind of stream Arlo sends to you. The options are: - `arlo`; the original user agent, returns an `rtsps` stream. - `ipad`; returns a `HLS` stream - `mac`; returns a `HLS` stream - `linux`; returns a `MPEG-DASH` stream ## Saving Media If you use the `save_media_to` parameter to specify a file naming scheme `praarlo` will use that to save all media - videos and snapshots - locally. You can use the following substitutions: - `SN`; the device serial number - `N`; the device name - `NN`; the device name, lower case with _ replacing spaces - `Y`; the year of the recording, include century - `m`; the month of the year as a number (range 01 to 12) - `d`; the day of the month as a number (range 01 to 31) - `H`; the hour of the day (range 00 to 23) - `M`; the minute of the hour (range 00 to 59) - `S`; the seconds of the minute (range 00 to 59) - `F`; a short cut for `Y-m-d` - `T`; a short cut for `H:M:S` - `t`; a short cut for `H-M-S` - `s`; the number of seconds since the epoch You specify the substitution by prefixing it with a `$` in the format string. You can optionally use curly brackets to remove any ambiguity. For example, the following configuration will save all media under `/config/media` organised by serial number then date. The code will add the correct file extension. ```yaml save_media_to: "/config/media/${SN}/${Y}/${m}/${d}/${T}" ``` The first time you configure `save_media_to` the system can take several minutes to download all the currently available media. The download is throttled to not overload Home Assistant or Arlo. Once the initial download is completed updates should happen a lot faster. The code doesn't provide any management of the downloads, it will keep downloading them until your device is full. It also doesn't provide a NAS interface, you need to mount the NAS device and point `save_media_to` at it. ## 2FA Pyaarlo supports 2 factor authentication. #### Manual Start `PyArlo` specifying `tfa_source` as `console`. Whenever `PyArlo` needs a secondary code it will prompt you for it. ```python ar = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_source='console', tfa_type='SMS') ``` #### IMAP __I recommend using `IMAP`, it's well tested now and it works. The other methods haven't been tested or looked at in a while.__ Automatic is trickier. Support is there but needs testing. For automatic 2FA PyArlo needs to access and your email account form where it reads the token Arlo sent. ```python ar = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_source='imap',tfa_type='email', tfa_host='imap.host.com', tfa_username='your-user-name', tfa_password='your-imap-password' ) ``` If you have multiple e-mail addresses associated with Arlo, you might also try configuring `tfa_nickname` to ensure that the correct factor is triggered: ```python ar = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_source='imap',tfa_type='email', tfa_host='imap.host.com', tfa_username='your-user-name', tfa_password='your-imap-password', tfa_nickname='your-user-name@your-domain.com' ) ``` It's working well with my gmail account, see [here](https://support.google.com/mail/answer/185833?hl=en) for help setting up single app passwords. If needed, you can specify a port by appending it to the host. ```python ar = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_source='imap',tfa_type='email', tfa_host='imap.host.com:1234', tfa_username='your-user-name', tfa_password='your-imap-password' ) ``` ## Pyaarlo Executable The pip installation adds an executable `pyaarlo`. You can use this to list devices, perform certain simple actions and anonymize and encrypt logs for debugging purposes. _Device operations are currently limited..._ The git installation has `bin/pyaarlo` which functions in a similar manner. ```bash # To show the currently available actions: pyaarlo --help # To list all the known devices: pyaarlo -u 'your-user-name' -p 'your-password' list all # this version will anonymize the output pyaarlo -u 'your-user-name' -p 'your-password' --anonymize list all # this version will anonymize and encrypt the output pyaarlo -u 'your-user-name' -p 'your-password' --anonymize --encrypt list all ``` ## Error Reporting When reporting errors please include the version of Pyaarlo you are using and what Arlo devices you have. Please turn on DEBUG level logging, capture the output and include as much information as possible about what you were trying to do. You can use the `pyaarlo` executable to anonymize and encrypt feature on arbitrary data like log files or source code. If you are only encrypting you don't need your username and password. ```bash # encrypt an existing file cat output-file | pyaarlo encrypt # anonymize and then encrypt a file cat output-file | pyaarlo -u 'your-user-name' -p 'your-password' anonymize | pyaarlo encrypt ``` If you installed from git you can use a shell script in `bin/` to encrypt your logs. No anonymizing is possible this way. ```bash # encrypt an existing file cat output-file | ./bin/pyaarlo-encrypt encrypt ``` `pyaarlo-encrypt` is a fancy wrapper around: ```bash curl -s -F 'plain_text_file=@-;filename=clear.txt' https://pyaarlo-tfa.appspot.com/encrypt ``` You can also encrypt your output on this [webpage](https://pyaarlo-tfa.appspot.com/). ## Limitations The component uses the Arlo webapi. * There is no documentation so the API has been reverse engineered using browser debug tools. * There is no support for smart features, you only get motion detection notifications, not what caused the notification. (Although, you can pipe a snapshot into deepstack...) This isn't strictly true, you can get "what caused" the notifications but only after Arlo has analysed the footage. * Streaming times out after 30 minutes. * The webapi doesn't seem like it was really designed for permanent connections so the system will sometimes appear to lock up. Various work arounds are in the code and can be configured at the `arlo` component level. See next paragraph. If you do find the component locks up after a while (I've seen reports of hours, days or weeks), you can add the following to the main configuration. Start from the top and work down: * `refresh_devices_every`, tell Pyaarlo to request the device list every so often. This will sometimes prevent the back end from aging you out. The value is in hours and a good starting point is 3. * `stream_timeout`, tell Pyaarlo to close and reopen the event stream after a certain period of inactivity. Pyaarlo will send keep alive every minute so a good starting point is 180 seconds. * `reconnect_every`, tell Pyaarlo to logout and back in every so often. This establishes a new session at the risk of losing an event notification. The value is minutes and a good starting point is 90. * `request_timeout`, the amount of time to allow for a http request to work. A good starting point is 120 seconds. Alro will allow shared accounts to give cameras their own name. If you find cameras appearing with unexpected names (or not appearing at all), log into the Arlo web interface with your Home Assistant account and make sure the camera names are correct. You can change the brightness on the light but not while it's turned on. You need to turn it off and back on again for the change to take. This is how the web interface does it. ## Other 2 Factor Authentication __I recommend using `IMAP`, it's well tested now and it works. These following methods haven't been tested or looked at in a while.__ #### Rest API This mechanism allows you to an external website. When you start authenticating Pyarlo makes a `clear` request and repeated `look-up` requests to a website to retrieve your TFA code. The format of these requests and their responses are well defined but the host Pyarlo uses is configurable. ```python ar = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_source='rest-api',tfa_type='email', tfa_host='custom-host', tfa_username='test@test.com', tfa_password='1234567890' ) ``` * Pyaarlo will clear the current code with this HTTP GET request: ```http request https://custom-host/clear?email=test@test.com&token=1234567890 ``` * And the server will respond with this on success: ```json { "meta": { "code": 200 }, "data": { "success": True, "email": "test@test.com" } } ``` * Pyaarlo will look up the current code with this HTTP GET request: ```http request https://custom-host/get?email=test@test.com&token=1234567890 ``` * And the server will respond with this on success: ```json { "meta": { "code": 200 }, "data": { "success": True, "email": "test@test.com", "code": "123456", "timestamp": "123445666" } } ``` * Failures always have `code` value of anything other than 200. ```json { "meta": { "code": 400 }, "data": { "success": False, "error": "permission denied" }} ``` Pyaarlo doesn't care how you get the codes into the system only that they are there. Feel free to roll your own server or... ##### Using My Server I have a website running at https://pyaarlo-tfa.appspot.com that can provide this service. It's provided as-is, it's running as a Google app so it should be pretty reliable and the only information I have access to is your email address, access token for my website and whatever your last code was. (_Note:_ if you're not planning on using email forwarding the `email` value isn't strictly enforced, a unique ID is sufficient.) _If you don't trust me and my server - and I won't be offended - you can get the source from [here](https://github.com/twrecked/pyaarlo-tfa-helper) and set up your own._ To use the REST API with my website do the following: * Register with my website. You only need to do this once and I'm sorry for the crappy interface. Go to [registration page](https://pyaarlo-tfa.appspot.com/register) and enter your email address (or unique ID). The website will reply with a json document containing your _token_, keep this _token_ and use it in all REST API interactions. ```json {"email":"testing@testing.com", "fwd-to":"pyaarlo@thewardrobe.ca", "success":true, "token":"4f529ea4dd20ca65e102e743e7f18914bcf8e596b909c02d"} ``` * To add a code send the following HTTP GET request: ```http request https://custom-host/add?email=test@test.com&token=4f529ea4dd20ca65e102e743e7f18914bcf8e596b909c02d&code=123456 ``` You can replace `code` with `msg` and the server will try and parse the code out value of `msg`, use it for picking apart SMS messages. ##### Using IFTTT You have your server set up or are using mine, one way to send codes is to use [IFTTT](https://ifttt.com/) to forward SMS messages to the server. I have an Android phone so use the `New SMS received from phone number` trigger and match to the Arlo number sending me SMS codes. (I couldn't get the match message to work, maybe somebody else will have better luck.) I pair this with `Make a web request` action to forward the SMS code into my server, I use the following recipe. Modify the email and token as necessary. ``` URL: https://pyaarlo-tfa.appspot.com/add?email=test@test.com&token=4f529ea4dd20ca65e102e743e7f18914bcf8e596b909c02d&msg={{Text}} Method: GET Content Type: text/plain ``` Make sure to configure Pyaarlo to request a token over SMS with `tfa_type='SMS`. Now, when you login in, Arlo will send an SMS to your phone, the IFTTT app will forward this to the server and Pyaarlo will read it from the server. ##### Using EMAIL If you run your own `postfix` server you can use [this script](https://github.com/twrecked/pyaarlo-tfa-helper/blob/master/postfix/pyaarlo-fwd.in) to set up an email forwarding alias. Use an alias like this: ```text pyaarlo: "|/home/test/bin/pyaarlo-fwd" ``` Make sure to configure Pyaarlo to request a token over SMS with `tfa_type='EMAIL`. Then set up your email service to forward Arlo code message to your email forwarding alias. pyaarlo-0.8.0.15/bin/000077500000000000000000000000001475374251700142015ustar00rootroot00000000000000pyaarlo-0.8.0.15/bin/pyaarlo000077500000000000000000000003241475374251700155750ustar00rootroot00000000000000#!/usr/bin/env python3 import os import sys # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) from pyaarlo.main import main_func main_func() pyaarlo-0.8.0.15/bin/pyaarlo-encrypt000077500000000000000000000002751475374251700172640ustar00rootroot00000000000000#!/bin/bash # #curl -s -F 'plain_text_file=@-;filename=clear.txt' http://127.0.0.1:8080/encrypt curl -s -F 'plain_text_file=@-;filename=clear.txt' https://pyaarlo-tfa.appspot.com/encrypt pyaarlo-0.8.0.15/changelog000066400000000000000000000170021475374251700153030ustar00rootroot000000000000000.8.0.15 Rewrite the authentication code to support the "trusted browser" mechanism Catch remote closing of sse client better 0.8.0.14 Update user agents 0.8.0.13 Update user agents 0.8.0.12 Squash "browser not trusted" error. It's part of the normal startup process when you need to provide authentication. 0.8.0.11 Fix ping issue for new cameras. The code was creating a basestation and pinging it. 0.8.0.10 Added some missing cameras. Essential Indoor Gen2 HD Essential Outdoor Gen2 HD Essential Outdoor Gen2 2K Essential XL Outdoor Gen2 HD Essential XL Spotlight Renamed some constants. 0.8.0.9 Fix setup.py [thanks @jamesoff] 0.8.0.8 Better login error handling and reporting. Added slugified naming option 0.8.0.7 Add Essential Indoor Gen 2 support. 0.8.0.6 Improved MQTT support. If you set the backend to auto it will read the MQTT server address and port from a key passed in the session packet. The previous version was too simple. 0.8.0.5 Improved cloudfare avoidance. Improved MQTT support. If you set the backend to auto it will read the MQTT server address and port from a key passed in the session packet. 0.8.0.4 Tweaked Gen2 doorbell support. 0.8.0.3 Add Arlo Essential XL support (thank @mzsanford) Add Gen2 doorbell support. Renamed Essential doorbell. 0.8.0.2 Handle 'synced' device state. 0.8.0.1 Better Yahoo IMAP handling. It will now better parse out the emails and better keep track of the used IDs. 0.8.0.0 Try multiple EDHC curves Allow `Source: arloWebCam` header entry to be turned on and off 0.8.0b12: Add Arlo Pro 5 support Allow cloudflare curves to be set Fix scheme in cfg 0.8.0b11: Latest arlo/cloudflare work arounds. Fix scheme in cgf Add host unit tests 0.8.0b10: Add schemaversion back. 0.8.0b9: Fix push headers. Added cache to headers [thank KoolLSL for pointing that out] Force SSE client over requests not cloudscraper. 0.8.0b8: Ping wired doorbell. Try proper device id for cloud flare 0.8.0b7: Allow more MQTT config. Fix headers. Distinguish between user/shared location. Fix missing event issue. Tidy capture code. Specify Python 3 0.8.0b6: Simplify state checking; bring API up to date and remove deprecated calls Add back the packet docs. 0.8.0b5: Add initial 8-in-1 sensor support [thanks xirtamoen for lending the sensors] Support Arlo v4 APIs [thanks JeffSteinbok for the implementation] 0.8.0b4: Allow ping when devices are on chargers. Added event_id and time to URL paths. Added custom cipher list. 0.8.0b3: Better snapshot tidy up. Improve debugging - add component or ID to debug entry. Allow actual user agent to be used by prefixing with a ! Undo broken IMAP Fix doorbell motion Fix doorbell capabilities 0.8.0b2: Better shutdown of threads. Allow ciphers to be set to SSL defaults. Try to parse all email parts looking for the authentication code. Open mailbox read only, stops the wrong emails getting marked as read. Better detection of backend. 0.8.0b1: Fix yahoo imap support. Fix creation of ArloSnapshot objects. 0.8.0a15: Fix media library day count. 0.8.0a14: Fix sse reconnect. Provide access to base station recordings. 0.8.0a13: Better loading of initial settings from new devices. Update devices from device refresh Allow old backend to be used. Quiet down traditional chimes. Fix up missing timezones. 0.8.0a12: Use new MQTT backend 0.8.0a11: Add random user agent support. 0.8.0a10: Don't request resources for Wirefree doorbells. 0.8.0a9: Support IMAP port. Don't request resources for Essential cameras. 0.8.0a8: Don't ping Pro 3 floodlight. 0.8.0a7: Don't ping Essential and Pro 4 cameras. Stop when asked to. 0.8.0a6: Fixed schedule handling. Handle events in the background thread. 0.8.0a5: Update token to map to email address Save last video details Don't signal unless information has really changed 0.8.0a4: Add GO camera support. Fixed header issue. 0.8.0a3: Save authentication token. 0.8.0a2: Support quoted printable in Arlo 2fa emails Fix connected capability for video door bell. 0.8.0a1: New version after split from hass-aarlo. 0.7.1b13: Merge to hass-aarlo b13 0.7.1b11: Merge to hass-aarlo b11 0.7.1b9: Add cloudscraper support. 0.7.1b7: Allow removal of unicode characters from entity ids Siren on wired doorbell. Better doorbell icon. 0.7.1b6: Add new event handling Allow custimisable disarmed mode 0.7.1b5: Don't rely on camera reporting back idle status 0.7.1b4: Smart user agents. 0.7.1b1: Add media download. 0.7.0.18: Update user agent and request headers. 0.7.0.6: Stop battery drain on any battery based base station. 0.7.0.4: Handle broken modes fetch. 0.7.0.2: Make mode refresh optional. 0.7.0: Added PUSH support for authentication. Fixed missing stream state. 0.7.0.beta.7 Fix imap error handling. Allow ids to be used in mode update. 0.7.0.beta.6 Fix mode update. 0.7.0.beta.5 Fix mode update. Fix possible log in issue. 0.7.0.beta.4 Support base reset Better snapshot handling. 0.7.0.beta.2 Remove duplicate connection tracker. Better backtrace in backend processes. Fix crash in mode 1 setting. 0.7.0.beta.1 Add switches to turn off doorbell chimes. Fix missing mode update. Fix camera on/off. Fix arloq audio merge issue. 0.7.0.alpha.5: Added new image update events. Fixed backend not starting promptly. 0.7.0.alpha.4: Further State machine improvements. Speed up startup. 0.7.0.alpha.3: Further State machine improvements. Pyclewn linting. 0.7.0.alpha.2: State machine improvements. 0.7.0: New version 0.6.20: Added REST-API support 0.6.19: Added TFA support Added synchronous mode Fixed(?) docs and README Tidied repository 0.6.17: Add request_snapshot webservice 0.6.16: Fix last_image so lovelace card works Arlo Baby: fix effect switching 0.6.15: Copied services into aarlo domain 0.6.14: Added unique keys 0.6.13: Tidy default alarm modes 0.6.12: Added schedule parsing and monitoring 0.6.11: Added brightness for lamps, fixed alarm issue 0.6.10: Add nightlamp support and cry detection for Arlo Baby. 0.6.9: Add object detected to last_captured sensor 0.6.8: Siren support for VMB4500r2 0.6.7: Arlo Baby media player support. 0.6.6: fixed close, added code for alarms 0.6.5: initial video doorbell support 0.6.4: fixed versioning 0.6.3: added support for HA 103 0.6.2: added aarlo_(start|stop)_recording service 0.6.1: added last thumbnail url 0.6.0: New revision 0.5.11: add save video request 0.5.10: mirror aarlo version 0.5.9: get devices after login 0.5.8: improved exception reporting 0.5.7: mirror aarlo version 0.5.6: initial Ultra support 0.5.5: mirror aarlo version 0.5.4: mirror aarlo version 0.5.3: Tidied up battery and wired status 0.5.2: Fixed wired status 0.0.18: fix thumbnails disappearing overnight 0.0.17: mirror aarlo version 0.0.16: Allow https pool parameters to be customized. 0.0.15: Mode API fixes and optional device list refresh 0.0.14: Added battery and signal strength for doorbells 0.0.13: Fix camera.(enable|disable)_motion service. 0.0.12: mirror aarlo version 0.0.11: mirror aarlo version 0.0.10: Added support for schedules. 0.0.9: Added timestamp to device request. 0.0.8: Restore version 1 of the modes API because it is needed by ArloQ and Arlo Babycams. 0.0.7: Fix race condition causing crash when initial login times out. 0.0.6: Handle general exceptions when using requests. 0.0.5: Add ping to startup sequence. 0.0.4: Fix mode lookup in base code pyaarlo-0.8.0.15/docs/000077500000000000000000000000001475374251700143615ustar00rootroot00000000000000pyaarlo-0.8.0.15/docs/conf.py000066400000000000000000000036501475374251700156640ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath('../')) # -- Project information ----------------------------------------------------- project = 'PyAarlo' copyright = '2020, Steve Herrell' author = 'Steve Herrell' # The full version, including alpha/beta/rc tags release = '0.7.0.beta.7' master_doc = 'index' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinx.ext.autodoc'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] html_static_path = [] pyaarlo-0.8.0.15/docs/index.rst000066400000000000000000000020261475374251700162220ustar00rootroot00000000000000.. PyAarlo documentation master file, created by sphinx-quickstart on Wed Apr 1 23:01:38 2020. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to PyAarlo's documentation! =================================== .. toctree:: :maxdepth: 2 :caption: Contents: PyArlo ------ .. automodule:: pyaarlo :members: ArloBase -------- .. autoclass:: pyaarlo.base.ArloBase :members: :show-inheritance: ArloCamera ---------- .. autoclass:: pyaarlo.camera.ArloCamera :members: :show-inheritance: :inherited-members: ArloDoorBell ------------ .. autoclass:: pyaarlo.doorbell.ArloDoorBell :members: :show-inheritance: :inherited-members: ArloLight --------- .. autoclass:: pyaarlo.light.ArloLight :members: :show-inheritance: :inherited-members: ArloVideo --------- .. autoclass:: pyaarlo.media.ArloVideo :members: :inherited-members: Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` pyaarlo-0.8.0.15/docs/packets.md000066400000000000000000000157071475374251700163470ustar00rootroot00000000000000 # Arlo Packet Types These are the packets we can receive over the _SSE_ or _MQTT_ back ends. ## Packet type #1 This is a subscription reply packet. This will be received about once a minute for devices that need them. ```json { "action": "is", "from": "XXXXXXXXXXXXX", "properties": {"devices": ["XXXXXXXXXXXXX"]}, "resource": "subscriptions/XXXXXXXXXXXXX24993_web", "to": "XXXXXXXXXXXXX24993_web", "transId": "web!33c2027d-9b96-4a9f-9b41-aaf412082e80"} ``` ## Packet type #2 A base has changed its alarm mode, ie, gone from `disarmed` to `armed`. The packet can appear when we change mode or another user changes the mode. ```json { "4R068BXXXXXXX": { "activeModes": ["mode1"], "activeSchedules": [], "timestamp": 1568142116238}, "resource": "activeAutomations"} ``` ## Packet type #3 These packets are updates from individual devices, they normally indicate some sort of activity we are interested in; motion or sound or a temperature change. ```json { "action": "is", "from": "XXXXXXXXXXXXX", "properties": {"motionDetected": "True"}, "resource": "cameras/XXXXXXXXXXXXX", "transId": "XXXXXXXXXXXXX!c87fdfa6!1675735611287"} ``` ## Packet type #4 These packets are returned from base stations to describe themselves and their child devices' states. We will periodically ask for this information to keep our device information up to do. ```json { "action": "is", "devices": { "XXXXXXXXXXXXX": { "properties": { "activityState": "idle", "alsReading": 32, "alsSensitivity": 15, "armed": "True", "batteryLevel": 45, "batteryTech": "Rechargeable", "brightness": 0, "chargeNotificationLedEnable": "False", "chargerTech": "None", "chargingState": "Off", "colorMode": "single", "connectionState": "available", "duration": 300, "flash": "off", "hwVersion": "AL1101r3", "interfaceVersion": 2, "lampState": "off", "modelId": "AL1101", "motionDetected": "False", "motionSetupModeEnabled": "False", "motionSetupModeSensitivity": 80, "multi": { "color1": "0xFF0008", "color2": "0x23FF02", "color3": "0x2100FF", "cycle": 2}, "name": "", "pattern": "flood", "sensitivity": 80, "serialNumber": "XXXXXXXXXXXXX", "signalStrength": 0, "single": "0xFFDEAD", "sleepTime": 0, "sleepTimeRel": 0, "swVersion": "3.2.51", "updateAvailable": "None"}, "states": { "motionStart": { "enabled": "True", "external": {}, "lightOn": { "brightness": 255, "colorMode": "white", "duration": 30, "enabled": "True", "flash": "off", "pattern": "flood"}, "pushNotification": { "enabled": "False"}, "sendEmail": { "enabled": "False", "recipients": [ ]}, "sensitivity": 80}, "schemaVersion": 1}}, "XXXXXXXXXXXXYY": { "properties": { "antiFlicker": { "autoDefault": 1, "mode": 0}, "apiVersion": 1, "autoUpdateEnabled": "True", "capabilities": ["bridge"], "claimed": "True", "connectivity": [ { "connected": "True", "ipAddr": "192.168.1.179", "signalStrength": 4, "ssid": "sprinterland", "type": "wifi"}], "hwVersion": "ABB1000r1.0", "interfaceVersion": 2, "mcsEnabled": "True", "modelId": "ABB1000", "olsonTimeZone": "America/New_York", "state": "idle", "swVersion": "2.0.1.0_278_341", "timeSyncState": "synchronized", "timeZone": "EST5EDT,M3.2.0,M11.1.0", "updateAvailable": "None"}, "states": {}}}, "from": "XXXXXXXXXXXXX", "resource": "devices", "to": "XXXXXXXXXXXXX24993_web", "transId": "web!c989b294-b117-4e5e-8647-bb039d9ff8d6"} ``` pyaarlo-0.8.0.15/docs/pyaarlo.1000066400000000000000000000050561475374251700161200ustar00rootroot00000000000000.TH PYAARLO 1 .SH NAME pyaarlo \- List devices, perform simple actions and anonymize/encrypt logs for debugging .SH SYNOPSIS \fBpyaarlo [OPTIONS] COMMAND [ARGS]...\fR .SH DESCRIPTION pyaarlo is a Python module to manage Aarlo cameras. The \fBpyaarlo\fR executable performs simple actions. This manual page is for the \fBpyaarlo\fR executable. The Python module documentation is available in \fI/usr/share/doc/python3-pyaarlo/README.md.gz\fR. .SS COMMANDS Help specific to each command is available by executing \fBpyaarlo COMMAND --help\fR. .TP \fBanonymize\fR Anonymize and encrypt logs for debugging purposes. For example: .IP cat output-file | pyaarlo -u username -p password anonymize .TP \fBcamera\fR Start or stop the camera, or take thumbnails. Requires an argument from {start-stream|stop-stream|last-thumbnail}. For example: .IP pyaarlo -u username -p password camera last-thumbnail .TP \fBdecrypt\fR Decrypt from stdin. For example: .IP cat encrypted | pyaarlo decrypt .TP \fBdump\fR Print out everything that was returned from Arlo about the devices in the system. For example: .IP pyaarlo dump all .TP \fBencrypt\fR Encrypt from stdin. For example: .IP cat output-file | pyaarlo encrypt .TP \fBlist\fR Requires an argument from {all|cameras|bases|lights|doorbells}. For example: .IP pyaarlo -u username -p password list all .SS OPTIONS .TP \fB-u, --username TEXT\fR Aarlo username. .TP \fB-p, --password TEXT\fR Aarlo password. .TP \fB-a, --anonymize / --no-anonymize\fR Anonymize ids. .TP \fB-c, --compact / --no-compact\fR Minimize lists. .TP \fB-e, --encrypt / --no-encrypt\fR Where possible, encrypt output. .TP \fB-k, --public-key TEXT\fR Public key for encryption. .TP \fB-K, --private-key TEXT\fR Private key for decryption. .TP \fB-P, --pass-phrase TEXT\fR Pass phrase for private key. .TP \fB-s, --storage-dir TEXT\fR. Where to store Aarlo state and packet dump [default: (current dir)] .TP \fB-w, --wait / --no-wait\fR. Wait for all information to arrive on start-up. .TP \fB-v, --verbose\fR. More vebose with multiple -v. .TP \fB--help\fR. Show help message and exit. .SH AUTHOR This manual page was written by Carles Pina i Estany for the \fBDebian\fR system (but may be used by others). Permission is granted to copy, distribute and/or modify this document under the terms of the GNU General Public License, Version 3 any later version published by the Free Software Foundation. .SH SEE ALSO Full Pyaarlo full documentation or available locally in . pyaarlo-0.8.0.15/examples/000077500000000000000000000000001475374251700152475ustar00rootroot00000000000000pyaarlo-0.8.0.15/examples/archive000077500000000000000000000030311475374251700166130ustar00rootroot00000000000000#!/usr/bin/env python3 # import logging import os import sys import time import pprint # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) import pyaarlo # set these from the environment to log in USERNAME = os.environ.get('ARLO_USERNAME', 'test.login@gmail.com') PASSWORD = os.environ.get('ARLO_PASSWORD', 'test-password') # set up logging, change INFO to DEBUG for a *lot* more information logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _LOGGER = logging.getLogger('pyaarlo') # log in # add `verbose_debug=True` to enable even more debugging # add `dump=True` to enable event stream packet dumps arlo = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_type='SMS', tfa_source='console', synchronous_mode=True, save_state=False, dump=False, storage_dir='aarlo', verbose_debug=True) if not arlo.is_connected: print("failed to login({})".format(arlo._last_error)) sys.exit(-1) print('download missing videos') for camera in arlo.cameras: print("camera: name={},device_id={},state={}".format(camera.name, camera.device_id, camera.state)) for video in camera.last_n_videos(1): video_name = "videos/{}-{}.mp4".format(camera.name.lower().replace(' ', '_'), video.created_at_pretty()) if not os.path.exists(video_name): print("downloading {}".format(video_name)) video.download_video(video_name) time.sleep(30) pyaarlo-0.8.0.15/examples/async-snapshot000077500000000000000000000034251475374251700201530ustar00rootroot00000000000000#!/usr/bin/env python3 # import logging import io import os import sys import time from PIL import Image # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) import pyaarlo USERNAME = os.environ.get('ARLO_USERNAME', 'test.login@gmail.com') PASSWORD = os.environ.get('ARLO_PASSWORD', 'test-password') # setup logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _LOGGER = logging.getLogger('pyaarlo') # define a simple snapshot callback def snapshot_callback(camera, attr, value): """ Snapshot callback converts snapshot image bytes to a PIL Image and shows it """ # convert snapshot to PIL Image img = Image.open(io.BytesIO(value)) _LOGGER.info(f'got snapshot from {camera.name} {img.size}') # show the image img.show() # login _LOGGER.info('logging in') arlo = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_type='SMS', tfa_source='console', save_state=False, dump=False, storage_dir='aarlo') # register snapshot callback with each camera for camera in arlo.cameras: camera.add_attr_callback(pyaarlo.constant.LAST_IMAGE_DATA_KEY, snapshot_callback) # set the wait time refresh_interval_s = 60 _LOGGER.info(f'requesting snapshots from cameras with {1. / float(refresh_interval_s):.2f}Hz interval') # request snapshots for 5 minutes end_time = time.time() + 5 * 60 while time.time() < end_time: _LOGGER.info('scheduling snapshots without blocking') for camera in arlo.cameras: camera.request_snapshot() _LOGGER.info(f'waiting for {refresh_interval_s}s ({end_time - time.time():.2f}s remaining in example)') time.sleep(refresh_interval_s) pyaarlo-0.8.0.15/examples/basics000077500000000000000000000036221475374251700164440ustar00rootroot00000000000000#!/usr/bin/env python3 # import logging import os import sys import time import pprint # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) import pyaarlo # set these from the environment to log in USERNAME = os.environ.get('ARLO_USERNAME', 'test.login@gmail.com') PASSWORD = os.environ.get('ARLO_PASSWORD', 'test-password') # set up logging, change INFO to DEBUG for a *lot* more information logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _LOGGER = logging.getLogger('pyaarlo') # log in # add `verbose_debug=True` to enable even more debugging # add `dump=True` to enable event stream packet dumps arlo = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_type='EMAIL', tfa_source='console', synchronous_mode=True, save_session=True, save_state=False, dump=True, storage_dir='aarlo', verbose_debug=True) if not arlo.is_connected: print("failed to login({})".format(arlo._last_error)) sys.exit(-1) print('list bases') for base in arlo.base_stations: print("base: name={},device_id={},state={}".format(base.name, base.device_id, base.state)) pprint.pprint(base.available_modes) pprint.pprint(base.available_modes_with_ids) print('list cameras') for camera in arlo.cameras: print("camera: name={},device_id={},state={}".format(camera.name, camera.device_id, camera.state)) for video in camera.last_n_videos(5): print("url {} ".format(video.video_url)) print('list doorbells') for doorbell in arlo.doorbells: print("doorbell: name={},device_id={},state={}".format(doorbell.name, doorbell.device_id, doorbell.state)) print('list lights') for light in arlo.lights: print("light: name={},device_id={},state={}".format(light.name, light.device_id, light.state)) time.sleep(15) pyaarlo-0.8.0.15/examples/custom-tfa000077500000000000000000000025711475374251700172640ustar00rootroot00000000000000#!/usr/bin/env python3 # import logging import os import sys import time # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) import pyaarlo # set these from the environment to log in USERNAME = os.environ.get('ARLO_USERNAME', 'test.login@gmail.com') PASSWORD = os.environ.get('ARLO_PASSWORD', 'test-password') # set up logging, change INFO to DEBUG for a *lot* more information logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _LOGGER = logging.getLogger('pyaarlo') class Arlo2FATest: """ 2FA authentication via console. Accepts input from console and returns that for 2FA. """ def __init__(self): pass def start(self): _LOGGER.debug('2fa-cconsole: starting') return True def get(self): _LOGGER.debug('2fa-cconsole: checking') return input('Custom Enter Code: ') def stop(self): _LOGGER.debug('2fa-cconsole: stopping') # log in # add `verbose_debug=True` to enable even more debugging # add `dump=True` to enable event stream packet dumps arlo = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_type='SMS', tfa_source=Arlo2FATest(), save_state=False, dump=False, storage_dir='aarlo', verbose_debug=True) time.sleep(60) pyaarlo-0.8.0.15/examples/inject000077500000000000000000000034701475374251700164550ustar00rootroot00000000000000#!/usr/bin/env python3 # import logging import os import sys # for examples, import relative to starting path import time # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) import pyaarlo # set these from the environment to log in USERNAME = os.environ.get('ARLO_USERNAME', 'test.login@gmail.com') PASSWORD = os.environ.get('ARLO_PASSWORD', 'test-password') # set up logging, change INFO to DEBUG for a *lot* more information logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _LOGGER = logging.getLogger('pyaarlo') # log in # add `verbose_debug=True` to enable even more debugging # add `dump=True` to enable event stream packet dumps arlo = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_type='SMS', tfa_source='console', save_state=False, dump=False, storage_dir='aarlo', verbose_debug=True) packet = { 'action': 'is', 'from': 'XXXXXXXXXXXXX', 'properties': [{'blockNotifications': {'block': False, 'duration': 0, 'endTime': 0}, 'callLedEnable': True, 'chimes': {}, 'liveFeed': True, 'pirLedEnable': True, 'silentMode': {}, 'sipCallActive': False, 'states': {}, 'traditionalChime': True, 'traditionalChimeDuration': 10000, 'traditionalChimeType': 'digital', 'voiceMailEnabled': False}], 'resource': 'doorbells', 'to': 'XXXXXXXXXXXXX', 'transId': '12345' } time.sleep(5) arlo.inject_response(packet) time.sleep(5) pyaarlo-0.8.0.15/examples/monitor-all000077500000000000000000000034321475374251700174340ustar00rootroot00000000000000#!/usr/bin/env python3 # import logging import os import sys import time # for examples add pyaarlo install path sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) import pyaarlo USERNAME = os.environ.get('ARLO_USERNAME', 'test.login@gmail.com') PASSWORD = os.environ.get('ARLO_PASSWORD', 'test-password') # set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _LOGGER = logging.getLogger('pyaarlo') # function to catch all callbacks def attribute_changed(device, attr, value): print('attribute_changed', time.strftime("%H:%M:%S"), device.name + ':' + attr + ':' + str(value)[:80]) print('logging in') arlo = pyaarlo.PyArlo(username=USERNAME, password=PASSWORD, tfa_type='SMS', tfa_source='console', save_state=False, dump=False, storage_dir='aarlo') print('monitoring bases') for base in arlo.base_stations: print("base: name={},device_id={},state={}".format(base.name, base.device_id, base.state)) base.add_attr_callback('*', attribute_changed) print('monitoring cameras') for camera in arlo.cameras: print("camera: name={},device_id={},state={}".format(camera.name, camera.device_id, camera.state)) camera.add_attr_callback('*', attribute_changed) print('monitoring doorbells') for doorbell in arlo.doorbells: print("doorbell: name={},device_id={},state={}".format(doorbell.name, doorbell.device_id, doorbell.state)) doorbell.add_attr_callback('*', attribute_changed) print('monitoring lights') for light in arlo.lights: print("light: name={},device_id={},state={}".format(light.name, light.device_id, light.state)) light.add_attr_callback('*', attribute_changed) # hang around for 10 minutes time.sleep(1200) pyaarlo-0.8.0.15/images/000077500000000000000000000000001475374251700146765ustar00rootroot00000000000000pyaarlo-0.8.0.15/images/jetbrains.svg000066400000000000000000000114161475374251700174030ustar00rootroot00000000000000 pyaarlo-0.8.0.15/pyaarlo.py000077500000000000000000000001101475374251700154450ustar00rootroot00000000000000#!/usr/bin/env python3 from pyaarlo.main import main_func main_func() pyaarlo-0.8.0.15/pyaarlo/000077500000000000000000000000001475374251700151005ustar00rootroot00000000000000pyaarlo-0.8.0.15/pyaarlo/__init__.py000066400000000000000000000666661475374251700172350ustar00rootroot00000000000000import base64 import datetime import logging import os import pprint import threading import time from .backend import ArloBackEnd from .background import ArloBackground from .base import ArloBase from .camera import ArloCamera from .cfg import ArloCfg from .constant import ( BLANK_IMAGE, DEVICES_PATH, FAST_REFRESH_INTERVAL, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_GO, MODEL_GO_2, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, PING_CAPABILITY, SLOW_REFRESH_INTERVAL, TOTAL_BELLS_KEY, TOTAL_CAMERAS_KEY, TOTAL_LIGHTS_KEY, LOCATIONS_PATH_FORMAT, LOCATIONS_EMERGENCY_PATH, VALID_DEVICE_STATES, ) from .doorbell import ArloDoorBell from .light import ArloLight from .media import ArloMediaLibrary from .storage import ArloStorage from .location import ArloLocation from .sensor import ArloSensor from .util import time_to_arlotime _LOGGER = logging.getLogger("pyaarlo") __version__ = "0.8.0.15" class PyArlo(object): """Entry point for all Arlo operations. This is used to login to Arlo, open and maintain an evenstream with Arlo, find and store devices and device state, provide keep-alive services and make sure media sources are kept up to date. Every device discovered and created is done in here, every device discovered and created uses this instance to log errors, info and debug, to access the state database and configuration settings. **Required `kwargs` parameters:** * **username** - Your Arlo username. * **password** - Your Arlo password. **Optional `kwargs` parameters:** * **wait_for_initial_setup** - Wait for initial devices states to load before returning from constructor. Default `True`. Setting to `False` and using saved state can increase startup time. * **last_format** - Date string format used when showing video file dates. Default ``%m-%d %H:%M``. * **library_days** - Number of days of recordings to load. Default is `30`. If you have a lot of recordings you can lower this value. * **save_state** - Store device state across restarts. Default `True`. * **state_file** - Where to store state. Default is `${storage_dir}/${name.}pickle` * **refresh_devices_every** - Time, in hours, to refresh the device list from Arlo. This can help keep the login from timing out. * **stream_timeout** - Time, in seconds, for the event stream to close after receiving no packets. 0 means no timeout. Default 0 seconds. Setting this to `120` can be useful for catching dead connections - ie, an ISP forced a new IP on you. * **synchronous_mode** - Wait for operations to complete before returing. If you are coming from Pyarlo this will make Pyaarlo behave more like you expect. * **save_media_to** - Save media to a local directory. **Debug `kwargs` parameters:** * **dump** - Save event stream packets to a file. * **dump_file** - Where to packets. Default is `${storage_dir}/packets.dump` * **name** - Name used for state and dump files. * **verbose_debug** - If `True`, provide extra debug in the logs. This includes packets in and out. **2FA authentication `kwargs` parameters:** These parameters are needed for 2FA. * **tfa_source** - Where to get the token from. Default is `console`. Can be `imap` to use email or `rest-api` to use rest API website. * **tfa_type** - How to get the 2FA token delivered. Default is `email` but can be `sms`. * **tfa_timeout** - When using `imap` or `rest-api`, how long to wait, in seconds, between checks. * **tfa_total_timeout** - When using `imap` or `rest-api`, how long to wait, in seconds, for all checks. * **tfa_host** - When using `imap` or `rest-api`, host name of server. * **tfa_username** - When using `imap` or `rest-api`, user name on server. If `None` will use Arlo username. * **tfa_password** - When using `imap` or `rest-api`, password/token on server. If `None` will use Arlo password. * **cipher_list** - Set if your IMAP server is using less secure ciphers. **Infrequently used `kwargs` parameters:** These parameters are very rarely changed. * **host** - Arlo host to use. Default `https://my.arlo.com`. * **storage_dir** - Where to store saved state. * **db_motion_time** - Time, in seconds, to show active for doorbell motion detected. Default 30 seconds. * **db_ding_time** - Time, in seconds, to show on for doorbell button press. Default 10 seconds. * **request_timeout** - Time, in seconds, for requests sent to Arlo to succeed. Default 60 seconds. * **recent_time** - Time, in seconds, for the camera to indicate it has seen motion. Default 600 seconds. * **no_media_upload** - Force a media upload after camera activity. Normally not needed but some systems fail to push media uploads. Default 'False'. Deprecated, use `media_retry`. * **media_retry** - Force a media upload after camera activity. Normally not needed but some systems fail to push media uploads. An integer array of timeout to use to get the update image. Default '[]'. * **no_media_upload** - Force a media upload after camera activity. Normally not needed but some systems fail to push media uploads. Default 'False'. * **user_agent** - Set what 'user-agent' string is passed in request headers. It affects what video stream type is returned. Default is `arlo`. * **mode_api** - Which api to use to set the base station modes. Default is `auto` which choose an API based on camera model. Can also be `v1`, `v2` or `v3`. Use `v3` for the new location API. * **reconnect_every** - Time, in minutes, to close and relogin to Arlo. * **snapshot_timeout** - Time, in seconds, to stop the snapshot attempt and return the camera to the idle state. * **mqtt_host** - specify the mqtt host to use, default mqtt-cluster.arloxcld.com * **mqtt_hostname_check** - disable MQTT host SSL certificate checking, default True * **mqtt_transport** - specify either `websockets` or `tcp`, default `tcp` * **ecdh_curve** - Sets initial ecdhCurve for Cloudscraper. Available options are `prime256v1` and `secp384r1`. Backend will try all options if login fails. * **send_source** - Add a `Source` item to the authentication header, default is False. **Attributes** Pyaarlo provides an asynchronous interface for receiving events from Arlo devices. To use it you register a callback for an attribute against a device. The following are a list of currently supported attributes: * **motionDetected** - called when motion start and stops * **audioDetected** - called when noise starts and stops * **activeMode** - called when a base changes mode * **more to come...** - I will flesh this out, but look in const.h for a good idea You can use the attribute `*` to register for all events. """ def __init__(self, **kwargs): """Constructor for the PyArlo object.""" # get this out quick self.info(f"pyarlo {__version__} starting...") # core values self._last_error = None # Set up the config first. self._cfg = ArloCfg(self, **kwargs) # Create storage/scratch directory. if self._cfg.save_state or self._cfg.dump or self._cfg.save_session: try: if not os.path.exists(self._cfg.storage_dir): os.mkdir(self._cfg.storage_dir) except Exception: self.warning(f"Problem creating {self._cfg.storage_dir}") # Create remaining components. self._bg = ArloBackground(self) self._st = ArloStorage(self) self._be = ArloBackEnd(self) self._ml = ArloMediaLibrary(self) # Make sure they are empty. self._locations = [] self._bases = [] self._cameras = [] self._lights = [] self._doorbells = [] self._sensors = [] # Failed to login, then stop now! if not self._be.is_connected: return self._lock = threading.Condition() # On day flip we do extra work, record today. self._today = datetime.date.today() # Every few hours we can refresh the device list. self._refresh_devices_at = time.monotonic() + self._cfg.refresh_devices_every # Every few minutes we can refresh the mode list. self._refresh_modes_at = time.monotonic() + self._cfg.refresh_modes_every # default blank image when waiting for camera image to appear self._blank_image = base64.standard_b64decode(BLANK_IMAGE) # Slow piece. # Get locations for multi location sites. # Get devices, fill local db, and create device instance. self.info("pyaarlo starting") self._started = False if self._be.multi_location: self._refresh_locations() self._refresh_devices() for device in self._devices: dname = device.get("deviceName") dtype = device.get("deviceType") device_state = device.get("state", "unknown").lower() if device_state not in VALID_DEVICE_STATES: self.info(f"skipping {dname}: state is {device_state}") continue # This needs it's own code now... Does no parent indicate a base station??? if ( dtype == "basestation" or dtype == "arlobridge" or dtype.lower() == 'hub' or device.get("modelId") == "ABC1000" or device.get("modelId").startswith(MODEL_GO) or dtype == "arloq" or dtype == "arloqs" ): self._bases.append(ArloBase(dname, self, device)) # Newer devices can connect directly to wifi and can be its own base station, # it can also be assigned to a real base station if device.get("modelId").startswith(( MODEL_WIRED_VIDEO_DOORBELL, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_GO_2 )): parent_id = device.get("parentId", None) if parent_id is None or parent_id == device.get("deviceId", None): self._bases.append(ArloBase(dname, self, device)) if ( dtype == "camera" or dtype == "arloq" or dtype == "arloqs" or device.get("modelId").startswith(( MODEL_GO, MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL )) ): self._cameras.append(ArloCamera(dname, self, device)) if dtype == "doorbell": self._doorbells.append(ArloDoorBell(dname, self, device)) if dtype == "lights": self._lights.append(ArloLight(dname, self, device)) if dtype == "sensors": self._sensors.append(ArloSensor(dname, self, device)) # Save out unchanging stats! self._st.set(["ARLO", TOTAL_CAMERAS_KEY], len(self._cameras), prefix="aarlo") self._st.set(["ARLO", TOTAL_BELLS_KEY], len(self._doorbells), prefix="aarlo") self._st.set(["ARLO", TOTAL_LIGHTS_KEY], len(self._lights), prefix="aarlo") # Subscribe to events. self._be.start_monitoring() # Now ping the bases. self._ping_bases() # Initial config and state retrieval. if self._cfg.synchronous_mode: # Synchronous; run them one after the other self.debug("getting initial settings") self._refresh_bases(initial=True) self._refresh_modes() self._refresh_ambient_sensors() self._refresh_doorbells() self._ml.load() self._refresh_camera_thumbnails(True) self._refresh_camera_media(True) self._initial_refresh_done() else: # Asynchronous; queue them to run one after the other self.debug("queueing initial settings") self._bg.run(self._refresh_bases, initial=True) self._bg.run(self._refresh_modes) self._bg.run(self._refresh_ambient_sensors) self._bg.run(self._refresh_doorbells) self._bg.run(self._ml.load) self._bg.run(self._refresh_camera_thumbnails, wait=False) self._bg.run(self._refresh_camera_media, wait=False) self._bg.run(self._initial_refresh_done) # Register house keeping cron jobs. self.debug("registering cron jobs") self._bg.run_every(self._fast_refresh, FAST_REFRESH_INTERVAL) self._bg.run_every(self._slow_refresh, SLOW_REFRESH_INTERVAL) # Wait for initial refresh if self._cfg.wait_for_initial_setup: with self._lock: while not self._started: self.debug("waiting for initial setup...") self._lock.wait(1) self.debug("setup finished...") def __repr__(self): # Representation string of object. return "<{0}: {1}>".format(self.__class__.__name__, self._cfg.name) # Using this to indicate that we're using location-based modes, vs basestation-based modes. # also called Arlo app v4. Open to new ideas for what to call this. @property def _v3_modes(self): return self.cfg.mode_api.lower() == "v3" def _refresh_devices(self): """Read in the devices list. This returns all devices known to the Arlo system. The newer devices include state information - battery levels etc - while the old devices don't. We update what we can. """ url = DEVICES_PATH + "?t={}".format(time_to_arlotime()) self._devices = self._be.get(url) if not self._devices: self.warning("No devices returned from " + url) self._devices = [] self.vdebug(f"devices={pprint.pformat(self._devices)}") # Newer devices include information in this response. Be sure to update it. for device in self._devices: device_id = device.get("deviceId", None) props = device.get("properties", None) self.vdebug(f"device-id={device_id}") if device_id is not None and props is not None: device = self.lookup_device_by_id(device_id) if device is not None: self.vdebug(f"updating {device_id} from device refresh") device.update_resources(props) else: self.vdebug(f"not updating {device_id} from device refresh") def _refresh_locations(self): """Retrieve location list from the backend """ self.debug("_refresh_locations") self._locations = [] elocation_data = self._be.get(LOCATIONS_EMERGENCY_PATH) if elocation_data: self.debug("got something") else: self.debug("got nothing") url = LOCATIONS_PATH_FORMAT.format(self.be.user_id) location_data = self._be.get(url) if not location_data: self.warning("No locations returned from " + url) else: for user_location in location_data.get("userLocations", []): self._locations.append(ArloLocation(self, user_location, True)) for shared_location in location_data.get("sharedLocations", []): self._locations.append(ArloLocation(self, shared_location, False)) self.vdebug("locations={}".format(pprint.pformat(self._locations))) def _refresh_camera_thumbnails(self, wait=False): """Request latest camera thumbnails, called at start up.""" for camera in self._cameras: camera.update_last_image(wait) def _refresh_camera_media(self, wait=False): """Rebuild cameras media library, called at start up or when day changes.""" for camera in self._cameras: camera.update_media(wait) def _refresh_ambient_sensors(self): for camera in self._cameras: camera.update_ambient_sensors() def _refresh_doorbells(self): for doorbell in self._doorbells: doorbell.update_silent_mode() def _ping_bases(self): for base in self._bases: if base.has_capability(PING_CAPABILITY): base.ping() else: self.vdebug(f"NO ping to {base.device_id}") def _refresh_bases(self, initial): for base in self._bases: base.update_modes(initial) base.keep_ratls_open() base.update_states() def _refresh_modes(self): self.vdebug("refresh modes") for base in self._bases: base.update_modes() base.update_mode() for location in self._locations: location.update_modes() location.update_mode() def _fast_refresh(self): self.vdebug("fast refresh") self._bg.run(self._st.save) self._ping_bases() # do we need to reload the modes? if self._cfg.refresh_modes_every != 0: now = time.monotonic() self.vdebug( "mode reload check {} {}".format(str(now), str(self._refresh_modes_at)) ) if now > self._refresh_modes_at: self.debug("mode reload needed") self._refresh_modes_at = now + self._cfg.refresh_modes_every self._bg.run(self._refresh_modes) else: self.vdebug("no mode reload") # do we need to reload the devices? if self._cfg.refresh_devices_every != 0: now = time.monotonic() self.vdebug( "device reload check {} {}".format( str(now), str(self._refresh_devices_at) ) ) if now > self._refresh_devices_at: self.debug("device reload needed") self._refresh_devices_at = now + self._cfg.refresh_devices_every self._bg.run(self._refresh_devices) else: self.vdebug("no device reload") # if day changes then reload recording library and camera counts today = datetime.date.today() self.vdebug("day testing with {}!".format(str(today))) if self._today != today: self.debug("day changed to {}!".format(str(today))) self._today = today self._bg.run(self._ml.load) self._bg.run(self._refresh_camera_media, wait=False) def _slow_refresh(self): self.vdebug("slow refresh") self._bg.run(self._refresh_bases, initial=False) self._bg.run(self._refresh_ambient_sensors) def _initial_refresh(self): self.debug("initial refresh") self._bg.run(self._refresh_bases, initial=True) self._bg.run(self._refresh_ambient_sensors) self._bg.run(self._initial_refresh_done) def _initial_refresh_done(self): self.debug("initial refresh done") with self._lock: self._started = True self._lock.notify_all() def stop(self, logout=False): """Stop connection to Arlo and, optionally, logout.""" self._st.save() self._bg.stop() self._ml.stop() if logout: self._be.logout() @property def entity_id(self): if self.cfg.serial_ids: return self.device_id else: return self.name.lower().replace(" ", "_") @property def name(self): return "ARLO CONTROLLER" @property def devices(self): return self._devices @property def device_id(self): return "ARLO" @property def model_id(self): return self.name @property def cfg(self): return self._cfg @property def bg(self): return self._bg @property def st(self): return self._st @property def be(self): return self._be @property def ml(self): return self._ml @property def is_connected(self): """Returns `True` if the object is connected to the Arlo servers, `False` otherwise.""" return self._be.is_connected @property def cameras(self): """List of registered cameras. :return: a list of cameras. :rtype: list(ArloCamera) """ return self._cameras @property def doorbells(self): """List of registered doorbells. :return: a list of doorbells. :rtype: list(ArloDoorBell) """ return self._doorbells @property def lights(self): """List of registered lights. :return: a list of lights. :rtype: list(ArloLight) """ return self._lights @property def base_stations(self): """List of base stations.. :return: a list of base stations. :rtype: list(ArloBase) """ return self._bases @property def locations(self): """List of locations.. :return: a list of locations. :rtype: list(ArloLocation) """ return self._locations @property def all_devices(self): return self.cameras + self.doorbells + self.lights + self.base_stations + self.locations @property def sensors(self): return self._sensors @property def blank_image(self): """Return a binary representation of a blank image. :return: A bytes representation of a blank image. :rtype: bytearray """ return self._blank_image def lookup_camera_by_id(self, device_id): """Return the camera referenced by `device_id`. :param device_id: The camera device to look for :return: A camera object or 'None' on failure. :rtype: ArloCamera """ camera = list(filter(lambda cam: cam.device_id == device_id, self.cameras)) if camera: return camera[0] return None def lookup_camera_by_name(self, name): """Return the camera called `name`. :param name: The camera name to look for :return: A camera object or 'None' on failure. :rtype: ArloCamera """ camera = list(filter(lambda cam: cam.name == name, self.cameras)) if camera: return camera[0] return None def lookup_doorbell_by_id(self, device_id): """Return the doorbell referenced by `device_id`. :param device_id: The doorbell device to look for :return: A doorbell object or 'None' on failure. :rtype: ArloDoorBell """ doorbell = list(filter(lambda cam: cam.device_id == device_id, self.doorbells)) if doorbell: return doorbell[0] return None def lookup_doorbell_by_name(self, name): """Return the doorbell called `name`. :param name: The doorbell name to look for :return: A doorbell object or 'None' on failure. :rtype: ArloDoorBell """ doorbell = list(filter(lambda cam: cam.name == name, self.doorbells)) if doorbell: return doorbell[0] return None def lookup_light_by_id(self, device_id): """Return the light referenced by `device_id`. :param device_id: The light device to look for :return: A light object or 'None' on failure. :rtype: ArloDoorBell """ light = list(filter(lambda cam: cam.device_id == device_id, self.lights)) if light: return light[0] return None def lookup_light_by_name(self, name): """Return the light called `name`. :param name: The light name to look for :return: A light object or 'None' on failure. :rtype: ArloDoorBell """ light = list(filter(lambda cam: cam.name == name, self.lights)) if light: return light[0] return None def lookup_base_station_by_id(self, device_id): """Return the base_station referenced by `device_id`. :param device_id: The base_station device to look for :return: A base_station object or 'None' on failure. :rtype: ArloDoorBell """ base_station = list(filter(lambda cam: cam.device_id == device_id, self.base_stations)) if base_station: return base_station[0] return None def lookup_base_station_by_name(self, name): """Return the base_station called `name`. :param name: The base_station name to look for :return: A base_station object or 'None' on failure. :rtype: ArloDoorBell """ base_station = list(filter(lambda cam: cam.name == name, self.base_stations)) if base_station: return base_station[0] return None def lookup_device_by_id(self, device_id): device = self.lookup_base_station_by_id(device_id) if device is None: device = self.lookup_camera_by_id(device_id) if device is None: device = self.lookup_doorbell_by_id(device_id) if device is None: device = self.lookup_light_by_id(device_id) return device def inject_response(self, response): """Inject a test packet into the event stream. **Note:** The method makes no effort to check the packet. :param response: packet to inject. :type response: JSON data """ self.debug("injecting\n{}".format(pprint.pformat(response))) self._be.ev_inject(response) def attribute(self, attr): """Return the value of attribute attr. PyArlo stores its state in key/value pairs. This returns the value associated with the key. :param attr: Attribute to look up. :type attr: str :return: The value associated with attribute or `None` if not found. """ return self._st.get(["ARLO", attr], None) def add_attr_callback(self, attr, cb): pass # TODO needs thinking about... track new cameras for example. def update(self, update_cameras=False, update_base_station=False): pass def error(self, msg): self._last_error = msg _LOGGER.error(msg) @property def last_error(self): """Return the last reported error.""" return self._last_error def warning(self, msg): _LOGGER.warning(msg) def info(self, msg): _LOGGER.info(msg) def debug(self, msg): _LOGGER.debug(msg) def vdebug(self, msg): if self._cfg.verbose: _LOGGER.debug(msg) pyaarlo-0.8.0.15/pyaarlo/backend.py000066400000000000000000001436471475374251700170600ustar00rootroot00000000000000import json import pickle import pprint import re import ssl import threading import time import traceback import uuid import random import cloudscraper import paho.mqtt.client as mqtt import requests import requests.adapters from enum import IntEnum from http.cookiejar import LWPCookieJar from .constant import ( AUTH_FINISH_PATH, AUTH_GET_FACTORID, AUTH_GET_FACTORS, AUTH_PATH, AUTH_START_PAIRING, AUTH_START_PATH, AUTH_VALIDATE_PATH, DEFAULT_RESOURCES, DEVICES_PATH, LOGOUT_PATH, MQTT_HOST, MQTT_PATH, MQTT_URL_KEY, NOTIFY_PATH, ORIGIN_HOST, REFERER_HOST, SESSION_PATH, SUBSCRIBE_PATH, TFA_CONSOLE_SOURCE, TFA_IMAP_SOURCE, TFA_PUSH_SOURCE, TFA_REST_API_SOURCE, TRANSID_PREFIX, USER_AGENTS, ) from .sseclient import SSEClient from .tfa import Arlo2FAConsole, Arlo2FAImap, Arlo2FARestAPI from .util import days_until, now_strftime, time_to_arlotime, to_b64 class AuthResult(IntEnum): CAN_RETRY = -1, SUCCESS = 0, FAILED = 1 # include token and session details class ArloBackEnd(object): _session_lock = threading.Lock() _session_info = {} _multi_location = False _user_device_id = None _browser_auth_code = None _user_id: str | None = None _web_id: str | None = None _sub_id: str | None = None _token: str | None = None _expires_in: int | None = None _needs_pairing: bool = False def __init__(self, arlo): self._arlo = arlo self._lock = threading.Condition() self._req_lock = threading.Lock() self._dump_file = self._arlo.cfg.dump_file self._use_mqtt = False self._requests = {} self._callbacks = {} self._resource_types = DEFAULT_RESOURCES self._load_session() if self._user_device_id is None: self._arlo.debug("created new user ID") self._user_device_id = str(uuid.uuid4()) # event thread stuff self._event_thread = None self._event_client = None self._event_connected = False self._stop_thread = False # login self._session = None self._load_cookies() self._logged_in = self._login() if not self._logged_in: self.debug("failed to log in") return def _load_session(self): self._user_id = None self._web_id = None self._sub_id = None self._token = None self._expires_in = 0 self._browser_auth_code = None self._user_device_id = None if not self._arlo.cfg.save_session: return try: with ArloBackEnd._session_lock: with open(self._arlo.cfg.session_file, "rb") as dump: ArloBackEnd._session_info = pickle.load(dump) version = ArloBackEnd._session_info.get("version", 1) if version == "2": session_info = ArloBackEnd._session_info.get(self._arlo.cfg.username, None) else: session_info = ArloBackEnd._session_info ArloBackEnd._session_info = { "version": "2", self._arlo.cfg.username: session_info, } if session_info is not None: self._user_id = session_info["user_id"] self._web_id = session_info["web_id"] self._sub_id = session_info["sub_id"] self._token = session_info["token"] self._expires_in = session_info["expires_in"] if "browser_auth_code" in session_info: self._browser_auth_code = session_info["browser_auth_code"] if "device_id" in session_info: self._user_device_id = session_info["device_id"] self.debug(f"loadv{version}:session_info={ArloBackEnd._session_info}") else: self.debug(f"loadv{version}:failed") except Exception: self.debug("session file not read") ArloBackEnd._session_info = { "version": "2", } def _save_session(self): if not self._arlo.cfg.save_session: return try: with ArloBackEnd._session_lock: with open(self._arlo.cfg.session_file, "wb") as dump: ArloBackEnd._session_info[self._arlo.cfg.username] = { "user_id": self._user_id, "web_id": self._web_id, "sub_id": self._sub_id, "token": self._token, "expires_in": self._expires_in, "browser_auth_code": self._browser_auth_code, "device_id": self._user_device_id, } pickle.dump(ArloBackEnd._session_info, dump) self.debug(f"savev2:session_info={ArloBackEnd._session_info}") except Exception as e: self._arlo.warning("session file not written" + str(e)) def _save_cookies(self, requests_cookiejar): if self._cookies is not None: self.debug(f"saving-cookies={self._cookies}") self._cookies.save(ignore_discard=True) def _load_cookies(self): self._cookies = LWPCookieJar(self._arlo.cfg.cookies_file) try: self._cookies.load() except: pass self.debug(f"loading cookies={self._cookies}") def _transaction_id(self): return 'FE!' + str(uuid.uuid4()) def _build_url(self, url, tid): sep = "&" if "?" in url else "?" now = time_to_arlotime() return f"{url}{sep}eventId={tid}&time={now}" def _request_tuple( self, path, method="GET", params=None, headers=None, stream=False, raw=False, timeout=None, host=None, authpost=False, cookies=None ): if params is None: params = {} if headers is None: headers = {} if timeout is None: timeout = self._arlo.cfg.request_timeout try: with self._req_lock: if host is None: host = self._arlo.cfg.host if authpost: url = host + path else: tid = self._transaction_id() url = self._build_url(host + path, tid) headers['x-transaction-id'] = tid self.vdebug("request-url={}".format(url)) self.vdebug("request-params=\n{}".format(pprint.pformat(params))) self.vdebug("request-headers=\n{}".format(pprint.pformat(headers))) if method == "GET": r = self._session.get( url, params=params, headers=headers, stream=stream, timeout=timeout, cookies=cookies, ) if stream is True: return 200, r elif method == "PUT": r = self._session.put( url, json=params, headers=headers, timeout=timeout, cookies=cookies, ) elif method == "POST": r = self._session.post( url, json=params, headers=headers, timeout=timeout, cookies=cookies, ) elif method == "OPTIONS": self._session.options( url, json=params, headers=headers, timeout=timeout ) return 200, None except Exception as e: self._arlo.warning("request-error={}".format(type(e).__name__)) return 500, None try: if "application/json" in r.headers["Content-Type"]: body = r.json() else: body = r.text self.vdebug("request-body=\n{}".format(pprint.pformat(body))) except Exception as e: self._arlo.warning("body-error={}".format(type(e).__name__)) self._arlo.debug(f"request-text={r.text}") return 500, None self.vdebug("request-end={}".format(r.status_code)) if r.status_code != 200: return r.status_code, None if raw: return 200, body # New auth style and TFA helper if "meta" in body: if body["meta"]["code"] == 200: return 200, body["data"] else: # don't warn on untrusted errors, they just mean we need to log in if body["meta"]["error"] != 9204: self._arlo.warning("error in new response=" + str(body)) return int(body["meta"]["code"]), body["meta"]["message"] # Original response type elif "success" in body: if body["success"]: if "data" in body: return 200, body["data"] # success, but no data so fake empty data return 200, {} else: self._arlo.warning("error in response=" + str(body)) return 500, None def _request( self, path, method="GET", params=None, headers=None, stream=False, raw=False, timeout=None, host=None, authpost=False, cookies=None ): code, body = self._request_tuple(path=path, method=method, params=params, headers=headers, stream=stream, raw=raw, timeout=timeout, host=host, authpost=authpost, cookies=cookies) return body def gen_trans_id(self, trans_type=TRANSID_PREFIX): return trans_type + "!" + str(uuid.uuid4()) def _event_dispatcher(self, response): # get message type(s) and id(s) responses = [] resource = response.get("resource", "") err = response.get("error", None) if err is not None: self._arlo.info( "error: code=" + str(err.get("code", "xxx")) + ",message=" + str(err.get("message", "XXX")) ) # # I'm trying to keep this as generic as possible... but it needs some # smarts to figure out where to send responses - the packets from Arlo # are anything but consistent... # See docs/packets for and idea of what we're parsing. # # Answer for async ping. Note and finish. # Packet type #1 if resource.startswith("subscriptions/"): self.vdebug("packet: async ping response " + resource) return # These is a base station mode response. Find base station ID and # forward response. # Packet type #2 if resource == "activeAutomations": self.debug("packet: base station mode response") for device_id in response: if device_id != "resource": responses.append((device_id, resource, response[device_id])) # Mode update response # XXX these might be deprecated elif "states" in response: self.debug("packet: mode update") device_id = response.get("from", None) if device_id is not None: responses.append((device_id, "states", response["states"])) # These are individual device updates, they are usually used to signal # things like motion detection or temperature changes. # Packet type #3 elif [x for x in self._resource_types if resource.startswith(x + "/")]: self.debug("packet: device update") device_id = resource.split("/")[1] responses.append((device_id, resource, response)) # Base station its child device statuses. We split this apart here # and pass directly to the referenced devices. # Packet type #4 elif resource == 'devices': self.debug("packet: base and child statuses") for device_id in response.get('devices', {}): self._arlo.debug(f"DEVICES={device_id}") props = response['devices'][device_id] responses.append((device_id, resource, props)) # These are base station responses. Which can be about the base station # or devices on it... Check if property is list. # XXX these might be deprecated elif resource in self._resource_types: prop_or_props = response.get("properties", []) if isinstance(prop_or_props, list): for prop in prop_or_props: device_id = prop.get("serialNumber", None) if device_id is None: device_id = response.get("from", None) responses.append((device_id, resource, prop)) else: device_id = response.get("from", None) responses.append((device_id, resource, response)) # ArloBabyCam packets. elif resource.startswith("audioPlayback"): device_id = response.get("from") properties = response.get("properties") if resource == "audioPlayback/status": # Wrap the status event to match the 'audioPlayback' event properties = {"status": response.get("properties")} self._arlo.info( "audio playback response {} - {}".format(resource, response) ) if device_id is not None and properties is not None: responses.append((device_id, resource, properties)) # This a list ditch effort to funnel the answer the correct place... # Check for device_id # Check for unique_id # Check for locationId # If none of those then is unhandled else: device_id = response.get("deviceId", response.get("uniqueId", response.get("locationId", None))) if device_id is not None: responses.append((device_id, resource, response)) else: self.debug(f"unhandled response {resource} - {response}") # Now find something waiting for this/these. for device_id, resource, response in responses: cbs = [] self.debug("sending {} to {}".format(resource, device_id)) with self._lock: if device_id and device_id in self._callbacks: cbs.extend(self._callbacks[device_id]) if "all" in self._callbacks: cbs.extend(self._callbacks["all"]) for cb in cbs: self._arlo.bg.run(cb, resource=resource, event=response) def _event_handle_response(self, response): # Debugging. if self._dump_file is not None: with open(self._dump_file, "a") as dump: time_stamp = now_strftime("%Y-%m-%d %H:%M:%S.%f") dump.write( "{}: {}\n".format( time_stamp, pprint.pformat(response, indent=2) ) ) self.vdebug( "packet-in=\n{}".format(pprint.pformat(response, indent=2)) ) # Run the dispatcher to set internal state and run callbacks. self._event_dispatcher(response) # is there a notify/post waiting for this response? If so, signal to waiting entity. tid = response.get("transId", None) resource = response.get("resource", None) device_id = response.get("from", None) with self._lock: # Transaction ID # Simple. We have a transaction ID, look for that. These are # usually returned by notify requests. if tid and tid in self._requests: self._requests[tid] = response self._lock.notify_all() # Resource # These are usually returned after POST requests. We trap these # to make async calls sync. if resource: # Historical. We are looking for a straight matching resource. if resource in self._requests: self.vdebug("{} found by text!".format(resource)) self._requests[resource] = response self._lock.notify_all() else: # Complex. We are looking for a resource and-or # deviceid matching a regex. if device_id: resource = "{}:{}".format(resource, device_id) self.vdebug("{} bounded device!".format(resource)) for request in self._requests: if re.match(request, resource): self.vdebug( "{} found by regex {}!".format(resource, request) ) self._requests[request] = response self._lock.notify_all() def _event_stop_loop(self): self._stop_thread = True def _event_main(self): self.debug("re-logging in") while not self._stop_thread: # say we're starting if self._dump_file is not None: with open(self._dump_file, "a") as dump: time_stamp = now_strftime("%Y-%m-%d %H:%M:%S.%f") dump.write("{}: {}\n".format(time_stamp, "event_thread start")) # login again if not first iteration, this will also create a new session while not self._logged_in: with self._lock: self._lock.wait(5) self.debug("re-logging in") self._logged_in = self._login() if self._use_mqtt: self._mqtt_main() else: self._sse_main() self.debug("exited the event loop") # clear down and signal out with self._lock: self._client_connected = False self._requests = {} self._lock.notify_all() # restart login... self._event_client = None self._logged_in = False def _mqtt_topics(self): topics = [] for device in self._arlo.devices: for topic in device.get("allowedMqttTopics", []): topics.append((topic, 0)) return topics def _mqtt_subscribe(self): # Make sure we are listening to library events and individual base # station events. This seems sufficient for now. self._event_client.subscribe([ (f"u/{self._user_id}/in/userSession/connect", 0), (f"u/{self._user_id}/in/userSession/disconnect", 0), (f"u/{self._user_id}/in/library/add", 0), (f"u/{self._user_id}/in/library/update", 0), (f"u/{self._user_id}/in/library/remove", 0) ]) topics = self._mqtt_topics() self.debug("topics=\n{}".format(pprint.pformat(topics))) self._event_client.subscribe(topics) def _mqtt_on_connect(self, _client, _userdata, _flags, rc): # Subscribing in on_connect() means that if we lose the connection and # reconnect then subscriptions will be renewed. self.debug(f"mqtt: connected={str(rc)}") self._mqtt_subscribe() with self._lock: self._event_connected = True self._lock.notify_all() def _mqtt_on_log(self, _client, _userdata, _level, msg): self.vdebug(f"mqtt: log={str(msg)}") def _mqtt_on_message(self, _client, _userdata, msg): self.debug(f"mqtt: topic={msg.topic}") try: response = json.loads(msg.payload.decode("utf-8")) # deal with mqtt specific pieces if response.get("action", "") == "logout": # Logged out? MQTT will log back in until stopped. self._arlo.warning("logged out? did you log in from elsewhere?") return # pass on to general handler self._event_handle_response(response) except json.decoder.JSONDecodeError as e: self.debug("reopening: json error " + str(e)) def _mqtt_main(self): try: self.debug("(re)starting mqtt event loop") headers = { "Host": MQTT_HOST, "Origin": ORIGIN_HOST, } # Build a new client_id per login. The last 10 numbers seem to need to be random. self._event_client_id = f"user_{self._user_id}_" + "".join( str(random.randint(0, 9)) for _ in range(10) ) self.debug(f"mqtt: client_id={self._event_client_id}") # Create and set up the MQTT client. self._event_client = mqtt.Client( client_id=self._event_client_id, transport=self._arlo.cfg.mqtt_transport ) self._event_client.on_log = self._mqtt_on_log self._event_client.on_connect = self._mqtt_on_connect self._event_client.on_message = self._mqtt_on_message ssl_context = ssl.create_default_context() ssl_context.check_hostname = self._arlo.cfg.mqtt_hostname_check self._event_client.tls_set_context(ssl_context) self._event_client.username_pw_set(f"{self._user_id}", self._token) self._event_client.ws_set_options(path=MQTT_PATH, headers=headers) self.debug(f"mqtt: host={self._arlo.cfg.mqtt_host}, " f"check={self._arlo.cfg.mqtt_hostname_check}, " f"transport={self._arlo.cfg.mqtt_transport}") # Connect. self._event_client.connect(self._arlo.cfg.mqtt_host, port=self._arlo.cfg.mqtt_port, keepalive=60) self._event_client.loop_forever() except Exception as e: # self._arlo.warning('general exception ' + str(e)) self._arlo.error( "mqtt-error={}\n{}".format( type(e).__name__, traceback.format_exc() ) ) def _sse_reconnected(self): self.debug("fetching device list after ev-reconnect") self.devices() def _sse_reconnect(self): self.debug("trying to reconnect") if self._event_client is not None: self._event_client.stop() def _sse_main(self): # get stream, restart after requested seconds of inactivity or forced close try: if self._arlo.cfg.stream_timeout == 0: self.debug("starting stream with no timeout") self._event_client = SSEClient( self._arlo, self._arlo.cfg.host + SUBSCRIBE_PATH, headers=self._headers(), reconnect_cb=self._sse_reconnected, ) else: self.debug( "starting stream with {} timeout".format( self._arlo.cfg.stream_timeout ) ) self._event_client = SSEClient( self._arlo, self._arlo.cfg.host + SUBSCRIBE_PATH, headers=self._headers(), reconnect_cb=self._sse_reconnected, timeout=self._arlo.cfg.stream_timeout, ) for event in self._event_client: # stopped? if event is None: self.debug("reopening: no event") break # dig out response try: response = json.loads(event.data) except json.decoder.JSONDecodeError as e: self.debug("reopening: json error " + str(e)) break # deal with SSE specific pieces # logged out? signal exited if response.get("action", "") == "logout": self._arlo.warning("logged out? did you log in from elsewhere?") break # connected - yay! if response.get("status", "") == "connected": with self._lock: self._event_connected = True self._lock.notify_all() continue # pass on to general handler self._event_handle_response(response) except requests.exceptions.ConnectionError: self._arlo.warning("event loop timeout") except requests.exceptions.HTTPError: self._arlo.warning("event loop closed by server") except AttributeError as e: self._arlo.warning("forced close " + str(e)) except Exception as e: # self._arlo.warning('general exception ' + str(e)) self._arlo.error( "sse-error={}\n{}".format( type(e).__name__, traceback.format_exc() ) ) def _select_backend(self): # determine backend to use if self._arlo.cfg.event_backend == 'auto': if len(self._mqtt_topics()) == 0: self.debug("auto chose SSE backend") self._use_mqtt = False else: self.debug("auto chose MQTT backend") self._use_mqtt = True elif self._arlo.cfg.event_backend == 'mqtt': self.debug("user chose MQTT backend") self._use_mqtt = True else: self.debug("user chose SSE backend") self._use_mqtt = False def start_monitoring(self): self._select_backend() self._event_client = None self._event_connected = False self._event_thread = threading.Thread( name="ArloEventStream", target=self._event_main, args=() ) self._event_thread.daemon = True with self._lock: self._event_thread.start() count = 0 while not self._event_connected and count < 30: self.debug("waiting for stream up") self._lock.wait(1) count += 1 # start logout daemon for sse clients if not self._use_mqtt: if self._arlo.cfg.reconnect_every != 0: self.debug("automatically reconnecting") self._arlo.bg.run_every(self._sse_reconnect, self._arlo.cfg.reconnect_every) self.debug("stream up") return True def _get_tfa(self): """Return the 2FA type we're using.""" tfa_type = self._arlo.cfg.tfa_source if tfa_type == TFA_CONSOLE_SOURCE: return Arlo2FAConsole(self._arlo) elif tfa_type == TFA_IMAP_SOURCE: return Arlo2FAImap(self._arlo) elif tfa_type == TFA_REST_API_SOURCE: return Arlo2FARestAPI(self._arlo) else: return tfa_type def _update_auth_info(self, body): if "accessToken" in body: body = body["accessToken"] self._token = body["token"] self._token64 = to_b64(self._token) self._user_id = body["userId"] self._web_id = self._user_id + "_web" self._sub_id = "subscriptions/" + self._web_id self._expires_in = body["expiresIn"] if "browserAuthCode" in body: self.debug("browser auth code: {}".format(body["browserAuthCode"])) self._browser_auth_code = body["browserAuthCode"] def _auth_headers(self): headers = { "Accept": "application/json, text/plain, */*", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8", "Cache-Control": "no-cache", "Content-Type": "application/json", # "Dnt": "1", "Origin": ORIGIN_HOST, "Pragma": "no-cache", "Priority": "u=1, i", "Referer": REFERER_HOST, # "Sec-Ch-Ua": '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', # "Sec-Ch-Ua-Mobile": "?0", # "Sec-Ch-Ua-Platform": "Linux", # "Sec-Fetch-Dest": "empty", # "Sec-Fetch-Mode": "cors", # "Sec-Fetch-Site": "same-site", "User-Agent": self._user_agent, "X-Service-Version": "3", "X-User-Device-Automation-Name": "QlJPV1NFUg==", "X-User-Device-Id": self._user_device_id, "X-User-Device-Type": "BROWSER", } # Add Source if asked for. if self._arlo.cfg.send_source: headers.update({ "Source": "arloCamWeb", }) return headers def _headers(self): return { "Accept": "application/json", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8", "Auth-Version": "2", "Authorization": self._token, "Cache-Control": "no-cache", "Content-Type": "application/json; charset=utf-8;", # "Dnt": "1", "Origin": ORIGIN_HOST, "Pragma": "no-cache", "Priority": "u=1, i", "Referer": REFERER_HOST, "SchemaVersion": "1", # "Sec-Ch-Ua": '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', # "Sec-Ch-Ua-Mobile": "?0", # "Sec-Ch-Ua-Platform": "Linux", # "Sec-Fetch-Dest": "empty", # "Sec-Fetch-Mode": "cors", # "Sec-Fetch-Site": "same-site", "User-Agent": self._user_agent, } def _auth(self) -> AuthResult: headers = self._auth_headers() # Handle 1015 error attempt = 0 code = 0 body = None while attempt < 3: attempt += 1 self.debug("login attempt #{}".format(attempt)) self._options = self.auth_options(AUTH_PATH, headers) code, body = self.auth_post( AUTH_PATH, { "email": self._arlo.cfg.username, "password": to_b64(self._arlo.cfg.password), "language": "en", "EnvSource": "prod", }, headers, ) if code == 200 or code == 401: break time.sleep(3) if body is None: self._arlo.error(f"login failed: {code} - possible cloudflare issue") return AuthResult.CAN_RETRY if code != 200: self._arlo.error(f"login failed: {code} - {body}") return AuthResult.FAILED # save new login information self._update_auth_info(body) # Looks like we need 2FA. So, request a code be sent to our email address. if not body["authCompleted"]: self.debug("need 2FA...") # update headers and create 2fa instance headers["Authorization"] = self._token64 tfa = self._get_tfa() # get available 2fa choices, self.debug("getting tfa choices") self._options = self.auth_options(AUTH_GET_FACTORID, headers) # look for code source choice self.debug(f"looking for {self._arlo.cfg.tfa_type}/{self._arlo.cfg.tfa_nickname}") factors_of_type = [] factor_id = None payload = { "factorType": "BROWSER", "factorData": "", "userId": self._user_id } code, body = self.auth_post( AUTH_GET_FACTORID, payload, headers, cookies=self._cookies ) if code == 200: self._needs_pairing = False factor_id = body["factorId"] else: self._needs_pairing = True factors = self.auth_get( AUTH_GET_FACTORS + "?data = {}".format(int(time.time())), {}, headers ) if factors is None: self._arlo.error("login failed: 2fa: no secondary choices available") return AuthResult.FAILED for factor in factors["items"]: if factor["factorType"].lower() == self._arlo.cfg.tfa_type: factors_of_type.append(factor) if len(factors_of_type) > 0: # Try to match the factorNickname with the tfa_nickname for factor in factors_of_type: if self._arlo.cfg.tfa_nickname == factor["factorNickname"]: factor_id = factor["factorId"] break # Otherwise fallback to using the first option else: factor_id = factors_of_type[0]["factorId"] if factor_id is None: self._arlo.error("login failed: 2fa: no secondary choices available") return AuthResult.FAILED if code == 200: payload = { "factorId": factor_id, "factorType": "BROWSER", "userId": self._user_id } self._options = self.auth_options(AUTH_START_PATH, headers) code, body = self.auth_post(AUTH_START_PATH, payload, headers) if code != 200: self._arlo.error(f"login failed: quick start failed: {code} - {body}") return AuthResult.FAILED elif tfa != TFA_PUSH_SOURCE: # snapshot 2fa before sending in request if not tfa.start(): self._arlo.error("login failed: 2fa: startup failed") return AuthResult.FAILED # start authentication with email self.debug( "starting auth with {}".format(self._arlo.cfg.tfa_type) ) payload = { "factorId": factor_id, "factorType": "BROWSER", "userId": self._user_id } self._options = self.auth_options(AUTH_START_PATH, headers) code, body = self.auth_post(AUTH_START_PATH, payload, headers) if code != 200: self._arlo.error(f"login failed: start failed: {code} - {body}") return AuthResult.CAN_RETRY factor_auth_code = body["factorAuthCode"] # get code from TFA source code = tfa.get() if code is None: self._arlo.error(f"login failed: 2fa: code retrieval failed") return AuthResult.CAN_RETRY # tidy 2fa tfa.stop() # finish authentication self.debug("finishing auth") code, body = self.auth_post( AUTH_FINISH_PATH, { "factorAuthCode": factor_auth_code, "otp": code, "isBrowserTrusted": True }, headers, ) if code != 200: self._arlo.error(f"login failed: finish failed: {code} - {body}") return AuthResult.FAILED else: # start authentication self.debug( "starting auth with {}".format(self._arlo.cfg.tfa_type) ) payload = { "factorId": factor_id, "factorType": "", "userId": self._user_id } code, body = self.auth_post(AUTH_START_PATH, payload, headers) if code != 200: self._arlo.error(f"login failed: start failed: {code} - {body}") return AuthResult.FAILED factor_auth_code = body["factorAuthCode"] tries = 1 while True: # finish authentication self.debug("finishing auth") code, body = self.auth_post( AUTH_FINISH_PATH, {"factorAuthCode": factor_auth_code}, headers, ) if code != 200: self._arlo.warning("2fa finishAuth - tries {}".format(tries)) if tries < self._arlo.cfg.tfa_retries: time.sleep(self._arlo.cfg.tfa_delay) tries += 1 else: self._arlo.error(f"login failed: finish failed: {code} - {body}") return AuthResult.FAILED else: break # save new login information self._update_auth_info(body) return AuthResult.SUCCESS def _validate(self): headers = self._auth_headers() headers["Authorization"] = self._token64 # Validate it! validated = self.auth_get( AUTH_VALIDATE_PATH + "?data = {}".format(int(time.time())), {}, headers ) if validated is None: self._arlo.error("token validation failed") return False return True def _pair_auth_code(self): headers = self._auth_headers() headers["Authorization"] = self._token64 if not self._needs_pairing: self._arlo.debug("no pairing required") self._save_cookies(self._cookies) return True if self._browser_auth_code is None: self._arlo.debug("pairing postponed") return True # self._cookies = self._load_cookies() payload = { "factorAuthCode": self._browser_auth_code, "factorData": "", "factorType": "BROWSER" } code, body = self.auth_post(AUTH_START_PAIRING, payload, headers, cookies=self._cookies) self._save_cookies(self._cookies) if code != 200: self._arlo.error(f"pairing: failed: {code} - {body}") return False self._arlo.debug("pairing succeeded") return True def _v2_session(self): v2_session = self.get(SESSION_PATH) if v2_session is None: self._arlo.error("session start failed") return False self._multi_location = v2_session.get('supportsMultiLocation', False) self._arlo.debug(f"multilocation is {self._multi_location}") # If Arlo provides an MQTT URL key use it to set the backend. if MQTT_URL_KEY in v2_session: self._arlo.cfg.update_mqtt_from_url(v2_session[MQTT_URL_KEY]) self._arlo.debug(f"back={self._arlo.cfg.event_backend};url={self._arlo.cfg.mqtt_host}:{self._arlo.cfg.mqtt_port}") return True def _login(self): # pickup user configured user agent self._user_agent = self.user_agent(self._arlo.cfg.user_agent) # we always login but and let the backend determine if we need to # use 2fa success = AuthResult.FAILED for curve in self._arlo.cfg.ecdh_curves: self.debug(f"CloudFlare curve set to: {curve}") self._session = cloudscraper.create_scraper( # browser={ # 'browser': 'chrome', # 'platform': 'darwin', # 'desktop': True, # 'mobile': False, # }, disableCloudflareV1=True, ecdhCurve=curve, debug=False, ) self._session.cookies = self._cookies # Try to authenticate. We retry if it was a cloud flare # error or we failed to get the 2FA code. success = self._auth() if success == AuthResult.FAILED: return False if success == AuthResult.SUCCESS and self._validate() and self._pair_auth_code(): break success = AuthResult.FAILED self.debug("login failed, trying another ecdh_curve") if success != AuthResult.SUCCESS: return False # save session in case we updated it self._save_session() # update sessions headers headers = self._headers() self._session.headers.update(headers) # Grab a session. Needed for new session and used to check existing # session. (May not really be needed for existing but will fail faster.) if not self._v2_session(): return False return True def _notify(self, base, body, trans_id=None): if trans_id is None: trans_id = self.gen_trans_id() body["to"] = base.device_id if "from" not in body: body["from"] = self._web_id body["transId"] = trans_id response = self.post( NOTIFY_PATH + base.device_id, body, headers={"xcloudId": base.xcloud_id} ) if response is None: return None else: return trans_id def _start_transaction(self, tid=None): if tid is None: tid = self.gen_trans_id() self.vdebug("starting transaction-->{}".format(tid)) with self._lock: self._requests[tid] = None return tid def _wait_for_transaction(self, tid, timeout): if timeout is None: timeout = self._arlo.cfg.request_timeout mnow = time.monotonic() mend = mnow + timeout self.vdebug("finishing transaction-->{}".format(tid)) with self._lock: try: while mnow < mend and self._requests[tid] is None: self._lock.wait(mend - mnow) mnow = time.monotonic() response = self._requests.pop(tid) except KeyError as _e: self.debug("got a key error") response = None self.vdebug("finished transaction-->{}".format(tid)) return response @property def is_connected(self): return self._logged_in def logout(self): self.debug("trying to logout") self._event_stop_loop() if self._event_client is not None: if self._use_mqtt: self._event_client.disconnect() else: self._event_client.stop() self.put(LOGOUT_PATH) def notify(self, base, body, timeout=None, wait_for=None): """Send in a notification. Notifications are Arlo's way of getting stuff done - turn on a light, change base station mode, start recording. Pyaarlo will post a notification and Arlo will post a reply on the event stream indicating if it worked or not or of a state change. How Pyaarlo treats notifications depends on the mode it's being run in. For asynchronous mode - the default - it sends the notification and returns immediately. For synchronous mode it sends the notification and waits for the event related to the notification to come back. To use the default settings leave `wait_for` as `None`, to force asynchronous set `wait_for` to `nothing` and to force synchronous set `wait_for` to `event`. There is a third way to send a notification where the code waits for the initial response to come back but that must be specified by setting `wait_for` to `response`. :param base: base station to use :param body: notification message :param timeout: how long to wait for response before failing, only applied if `wait_for` is `event`. :param wait_for: what to wait for, either `None`, `event`, `response` or `nothing`. :return: either a response packet or an event packet """ if wait_for is None: wait_for = "event" if self._arlo.cfg.synchronous_mode else "nothing" if wait_for == "event": self.vdebug("notify+event running") tid = self._start_transaction() self._notify(base, body=body, trans_id=tid) return self._wait_for_transaction(tid, timeout) # return self._notify_and_get_event(base, body, timeout=timeout) elif wait_for == "response": self.vdebug("notify+response running") return self._notify(base, body=body) else: self.vdebug("notify+ sent") self._arlo.bg.run(self._notify, base=base, body=body) def get( self, path, params=None, headers=None, stream=False, raw=False, timeout=None, host=None, wait_for="response", cookies=None, ): if wait_for == "response": self.vdebug("get+response running") return self._request( path, "GET", params, headers, stream, raw, timeout, host, cookies ) else: self.vdebug("get sent") self._arlo.bg.run( self._request, path, "GET", params, headers, stream, raw, timeout, host ) def put( self, path, params=None, headers=None, raw=False, timeout=None, wait_for="response", cookies=None, ): if wait_for == "response": self.vdebug("put+response running") return self._request(path, "PUT", params, headers, False, raw, timeout, cookies) else: self.vdebug("put sent") self._arlo.bg.run( self._request, path, "PUT", params, headers, False, raw, timeout ) def post( self, path, params=None, headers=None, raw=False, timeout=None, tid=None, wait_for="response" ): """Post a request to the Arlo servers. Posts are used to retrieve data from the Arlo servers. Mostly. They are also used to change base station modes. The default mode of operation is to wait for a response from the http request. The `wait_for` variable can change the operation. Setting it to `response` waits for a http response. Setting it to `resource` waits for the resource in the `params` parameter to appear in the event stream. Setting it to `nothing` causing the post to run in the background. Setting it to `None` uses `resource` in synchronous mode and `response` in asynchronous mode. """ if wait_for is None: wait_for = "resource" if self._arlo.cfg.synchronous_mode else "response" if wait_for == "resource": self.vdebug("notify+resource running") if tid is None: tid = list(params.keys())[0] tid = self._start_transaction(tid) self._request(path, "POST", params, headers, False, raw, timeout) return self._wait_for_transaction(tid, timeout) if wait_for == "response": self.vdebug("post+response running") return self._request(path, "POST", params, headers, False, raw, timeout) else: self.vdebug("post sent") self._arlo.bg.run( self._request, path, "POST", params, headers, False, raw, timeout ) def auth_post(self, path, params=None, headers=None, raw=False, timeout=None, cookies=None): return self._request_tuple( path, "POST", params, headers, False, raw, timeout, self._arlo.cfg.auth_host, authpost=True, cookies=cookies ) def auth_get( self, path, params=None, headers=None, stream=False, raw=False, timeout=None, cookies=None ): return self._request( path, "GET", params, headers, stream, raw, timeout, self._arlo.cfg.auth_host, authpost=True, cookies=cookies ) def auth_options( self, path, headers=None, timeout=None ): return self._request( path, "OPTIONS", None, headers, False, False, timeout, self._arlo.cfg.auth_host, authpost=True ) @property def session(self): return self._session @property def sub_id(self): return self._sub_id @property def user_id(self): return self._user_id @property def multi_location(self): return self._multi_location def add_listener(self, device, callback): with self._lock: if device.device_id not in self._callbacks: self._callbacks[device.device_id] = [] self._callbacks[device.device_id].append(callback) if device.unique_id not in self._callbacks: self._callbacks[device.unique_id] = [] self._callbacks[device.unique_id].append(callback) def add_any_listener(self, callback): with self._lock: if "all" not in self._callbacks: self._callbacks["all"] = [] self._callbacks["all"].append(callback) def del_listener(self, device, callback): pass def devices(self): return self.get(DEVICES_PATH + "?t={}".format(time_to_arlotime())) def user_agent(self, agent): """Map `agent` to a real user agent. User provides a default user agent they want for most interactions but it can be overridden for stream operations. `!real-string` will use the provided string as-is, used when passing user agent from a browser. `random` will provide a different user agent for each log in attempt. """ if agent.startswith("!"): self.debug(f"using user supplied user_agent {agent[:70]}") return agent[1:] agent = agent.lower() self.debug(f"looking for user_agent {agent}") if agent == "random": return self.user_agent(random.choice(list(USER_AGENTS.keys()))) return USER_AGENTS.get(agent, USER_AGENTS["linux"]) def ev_inject(self, response): self._event_dispatcher(response) def debug(self, msg): self._arlo.debug(f"backend: {msg}") def vdebug(self, msg): self._arlo.vdebug(f"backend: {msg}") pyaarlo-0.8.0.15/pyaarlo/background.py000066400000000000000000000114751475374251700176010ustar00rootroot00000000000000import threading import time import traceback class ArloBackgroundWorker(threading.Thread): def __init__(self, arlo): super().__init__() self._arlo = arlo self._id = 0 self._lock = threading.Condition() self._queue = {} self._stopThread = False def _next_id(self): self._id += 1 return str(self._id) + ":" + str(time.monotonic()) def _run_next(self): # timeout in the future timeout = int(time.monotonic() + 60) # go by priority... for prio in sorted(self._queue.keys()): # jobs in particular priority for run_at, job_id in sorted(self._queue[prio].keys()): if run_at <= int(time.monotonic()): job = self._queue[prio].pop((run_at, job_id)) self._lock.release() # run it try: job["callback"](**job["args"]) except Exception as e: self._arlo.error( "job-error={}\n{}".format( type(e).__name__, traceback.format_exc() ) ) # reschedule? self._lock.acquire() run_every = job.get("run_every", None) if run_every: run_at += run_every self._queue[prio][(run_at, job_id)] = job # start going through list again return None else: if run_at < timeout: timeout = run_at break return timeout def run(self): with self._lock: while not self._stopThread: # loop till done timeout = None while timeout is None: timeout = self._run_next() # wait or get going? now = time.monotonic() if now < timeout: self._lock.wait(timeout - now) def queue_job(self, run_at, prio, job): run_at = int(run_at) with self._lock: job_id = self._next_id() if prio not in self._queue: self._queue[prio] = {} self._queue[prio][(run_at, job_id)] = job self._lock.notify() return job_id def stop_job(self, to_delete): with self._lock: for prio in self._queue.keys(): for run_at, job_id in self._queue[prio].keys(): if job_id == to_delete: # print( 'cancelling ' + str(job_id) ) del self._queue[prio][(run_at, job_id)] return True return False def stop(self): with self._lock: self._stopThread = True self._lock.notify() self.join(10) class ArloBackground: def __init__(self, arlo): self._worker = ArloBackgroundWorker(arlo) self._worker.name = "ArloBackgroundWorker" self._worker.daemon = True self._worker.start() arlo.debug("background: starting") def _run(self, bg_cb, prio, **kwargs): job = {"callback": bg_cb, "args": kwargs} return self._worker.queue_job(time.monotonic(), prio, job) def run_high(self, bg_cb, **kwargs): return self._run(bg_cb, 10, **kwargs) def run(self, bg_cb, **kwargs): return self._run(bg_cb, 40, **kwargs) def run_low(self, bg_cb, **kwargs): return self._run(bg_cb, 99, **kwargs) def _run_in(self, bg_cb, prio, seconds, **kwargs): job = {"callback": bg_cb, "args": kwargs} return self._worker.queue_job(time.monotonic() + seconds, prio, job) def run_high_in(self, bg_cb, seconds, **kwargs): return self._run_in(bg_cb, 10, seconds, **kwargs) def run_in(self, bg_cb, seconds, **kwargs): return self._run_in(bg_cb, 40, seconds, **kwargs) def run_low_in(self, bg_cb, seconds, **kwargs): return self._run_in(bg_cb, 99, seconds, **kwargs) def _run_every(self, bg_cb, prio, seconds, **kwargs): job = {"run_every": seconds, "callback": bg_cb, "args": kwargs} return self._worker.queue_job(time.monotonic() + seconds, prio, job) def run_high_every(self, bg_cb, seconds, **kwargs): return self._run_every(bg_cb, 10, seconds, **kwargs) def run_every(self, bg_cb, seconds, **kwargs): return self._run_every(bg_cb, 40, seconds, **kwargs) def run_low_every(self, bg_cb, seconds, **kwargs): return self._run_every(bg_cb, 99, seconds, **kwargs) def cancel(self, to_delete): if to_delete is not None: self._worker.stop_job(to_delete) def stop(self): self._worker.stop() pyaarlo-0.8.0.15/pyaarlo/base.py000066400000000000000000000546141475374251700163760ustar00rootroot00000000000000import pprint import time from typing import TYPE_CHECKING if TYPE_CHECKING: from . import PyArlo from .constant import ( AIR_QUALITY_KEY, AUTOMATION_PATH, CONNECTION_KEY, DEFAULT_MODES, DEFINITIONS_PATH, HUMIDITY_KEY, MODE_ID_TO_NAME_KEY, MODE_IS_SCHEDULE_KEY, MODE_KEY, MODE_NAME_TO_ID_KEY, MODE_UPDATE_INTERVAL, MODEL_BABY, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_GO, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, PING_CAPABILITY, RESOURCE_CAPABILITY, RESTART_PATH, SCHEDULE_KEY, SIREN_STATE_KEY, TEMPERATURE_KEY, TIMEZONE_KEY, ) from .device import ArloDevice from .util import time_to_arlotime from .media import ArloBaseStationMediaLibrary from .ratls import ArloRatls day_of_week = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su", "Mo"] class ArloBase(ArloDevice): def __init__(self, name: str, arlo: 'PyArlo', attrs): super().__init__(name, arlo, attrs) self._ml = None self._refresh_rate = 15 self._schedules = None self._last_update = 0 self._ratls = None def _id_to_name(self, mode_id): return self._load([MODE_ID_TO_NAME_KEY, mode_id], None) def _id_is_schedule(self, mode_id): return self._load([MODE_IS_SCHEDULE_KEY, mode_id.lower()], False) def _name_to_id(self, mode_name): return self._load([MODE_NAME_TO_ID_KEY, mode_name.lower()], None) def _parse_modes(self, modes): for mode in modes: mode_id = mode.get("id", None) mode_name = mode.get("name", "") if mode_name == "": mode_name = mode.get("type", "") if mode_name == "": mode_name = mode_id if mode_id and mode_name != "": self.debug(mode_id + "<=M=>" + mode_name) self._save([MODE_ID_TO_NAME_KEY, mode_id], mode_name) self._save([MODE_NAME_TO_ID_KEY, mode_name.lower()], mode_id) self._save([MODE_IS_SCHEDULE_KEY, mode_id.lower()], False) self._save([MODE_IS_SCHEDULE_KEY, mode_name.lower()], False) def schedule_to_modes(self): if self._schedules is None: return [] now = time.localtime() day = day_of_week[now.tm_wday] minute = (now.tm_hour * 60) + now.tm_min for schedule in self._schedules: if not schedule.get("enabled", False): continue for action in schedule.get("schedule", []): if day in action.get("days", []): start = action.get("startTime", 65535) duration = action.get("duration", 65536) if start <= minute < (start + duration): modes = action.get("startActions", {}).get("enableModes", None) if modes: self.debug("schdule={}".format(modes[0])) return modes # If nothing in schedule we are disarmed. return ['mode0'] def _parse_schedules(self, schedules): self._schedules = schedules for schedule in schedules: schedule_id = schedule.get("id", None) schedule_name = schedule.get("name", "") if schedule_name == "": schedule_name = schedule_id if schedule_id and schedule_name != "": self.debug(schedule_id + "<=S=>" + schedule_name) self._save([MODE_ID_TO_NAME_KEY, schedule_id], schedule_name) self._save([MODE_NAME_TO_ID_KEY, schedule_name.lower()], schedule_id) self._save([MODE_IS_SCHEDULE_KEY, schedule_id.lower()], True) self._save([MODE_IS_SCHEDULE_KEY, schedule_name.lower()], True) def _set_mode_or_schedule(self, event): # schedule on or off? schedule_ids = event.get("activeSchedules", []) if schedule_ids: self.debug(self.name + " schedule change " + schedule_ids[0]) schedule_name = self._id_to_name(schedule_ids[0]) self._save_and_do_callbacks(SCHEDULE_KEY, schedule_name) else: self.debug(self.name + " schedule cleared ") self._save_and_do_callbacks(SCHEDULE_KEY, None) # mode present? we just set to that one... If no mode but schedule then # try to parse that out mode_ids = event.get("activeModes", []) if not mode_ids and schedule_ids: self.debug(self.name + " mode change (via schedule) ") self.vdebug(self.name + " schedules: " + pprint.pformat(self._schedules)) mode_ids = self.schedule_to_modes() if mode_ids: self.debug(self.name + " mode change " + mode_ids[0]) mode_name = self._id_to_name(mode_ids[0]) self._save_and_do_callbacks(MODE_KEY, mode_name) def _event_handler(self, resource, event): self.debug(self.name + " BASE got " + resource) # modes on base station if resource == "modes": props = event.get("properties", {}) # list of modes - recheck? self._parse_modes(props.get("modes", [])) # mode change? if "activeMode" in props: self._save_and_do_callbacks( MODE_KEY, self._id_to_name(props["activeMode"]) ) elif "active" in props: self._save_and_do_callbacks(MODE_KEY, self._id_to_name(props["active"])) # Base station mode change. # These come in per device and can arrive multiple times per state # change. We limit the updates to once per MODE_UPDATE_INTERVAL # seconds. Arlo doesn't send a "schedule changed" notification so we # re-fetch that information before testing the mode. elif resource == "states": now = time.monotonic() with self._lock: if now < self._last_update + MODE_UPDATE_INTERVAL: return self._last_update = now self.debug("state change") self.update_modes() self.update_mode() # mode change? elif resource == "activeAutomations": self._set_mode_or_schedule(event) # schedule has changed, so reload elif resource == "automationRevisionUpdate": self.update_modes() # pass on to lower layer else: super()._event_handler(resource, event) @property def _modes_version(self): if self._arlo.cfg.mode_api.lower() == "v1": self.vdebug("forced v1 api") return 1 if self._arlo.cfg.mode_api.lower() == "v2": self.vdebug("forced v2 api") return 2 if self._arlo.cfg.mode_api.lower() == "v3": self._arlo.vdebug("forced v3 api") return 3 if self._arlo.be.multi_location: self._arlo.vdebug("multilocation, deduced v3 api") return 3 if ( self.model_id == MODEL_BABY or self.model_id == MODEL_GO or self.device_type == "arloq" or self.device_type == "arloqs" ): self.vdebug("deduced v1 api") return True self._arlo.vdebug("deduced v2 api") return 2 @property def _v1_modes(self): return self._modes_version == 1 @property def _v2_modes(self): return self._modes_version == 2 @property def _v3_modes(self): return self._modes_version == 3 @property def available_modes(self): """Returns string list of available modes. For example:: ``['disarmed', 'armed', 'home']`` """ return list(self.available_modes_with_ids.keys()) @property def available_modes_with_ids(self): """Returns dictionary of available modes mapped to Arlo ids. For example:: ``{'armed': 'mode1','disarmed': 'mode0','home': 'mode2'}`` """ modes = {} for key, mode_id in self._load_matching([MODE_NAME_TO_ID_KEY, "*"]): modes[key.split("/")[-1]] = mode_id if not modes: modes = DEFAULT_MODES return modes @property def mode(self): """Returns the current mode.""" return self._load(MODE_KEY, "unknown") @mode.setter def mode(self, mode_name): """Set the base station mode. **Note:** Setting mode has been known to hang, method includes code to keep retrying. :param mode_name: mode to use, as returned by available_modes: """ if self._v3_modes: self._arlo.debug(f"BaseStations don't have modes in v3") return # Actually passed a mode? mode_id = None real_mode_name = self._id_to_name(mode_name) if real_mode_name: self.debug(f"passed an ID({mode_name}), converting it") mode_id = mode_name mode_name = real_mode_name # Need to change? if self.mode == mode_name: self.debug("no mode change needed") return if mode_id is None: mode_id = self._name_to_id(mode_name) if mode_id: # Need to change? if self.mode == mode_id: self.debug("no mode change needed (id)") return if not self._v3_modes: # Schedule or mode? Manually set schedule key. if self._id_is_schedule(mode_id): active = "activeSchedules" inactive = "activeModes" self._save_and_do_callbacks(SCHEDULE_KEY, mode_name) else: active = "activeModes" inactive = "activeSchedules" self._save_and_do_callbacks(SCHEDULE_KEY, None) # Post change. self.debug(self.name + ":new-mode=" + mode_name + ",id=" + mode_id) if self._v1_modes: self._arlo.be.notify( base=self, body={ "action": "set", "resource": "modes", "publishResponse": True, "properties": {"active": mode_id}, }, ) elif self._v2_modes: # This is complicated... Setting a mode can fail and setting a mode can be sync or async. # This code tried 3 times to set the mode with attempts to reload the devices between # attempts to try and kick Arlo. In async mode the first set works in the current thread, # subsequent ones run in the background. In sync mode it the same. Sorry. def _set_mode_v2_cb(attempt): self.debug("v2 arming") params = { "activeAutomations": [ { "deviceId": self.device_id, "timestamp": time_to_arlotime(), active: [mode_id], inactive: [], } ] } if attempt < 4: tid = "(modes:{}|activeAutomations)".format(self.device_id) body = self._arlo.be.post( AUTOMATION_PATH, params=params, raw=True, tid=tid, wait_for=None, ) if body is not None: if ( body.get("success", False) is True or body.get("resource", "") == "modes" or body.get("resource", "") == "activeAutomations" ): return self._arlo.warning( "attempt {0}: error in response when setting mode=\n{1}".format( attempt, pprint.pformat(body) ) ) self.debug( "Fetching device list (hoping this will fix arming/disarming)" ) self._arlo.be.devices() if self._arlo.cfg.synchronous_mode: self.debug("trying again, but synchronous") _set_mode_v2_cb(attempt=attempt + 1) else: self._arlo.bg.run(_set_mode_v2_cb, attempt=attempt + 1) return self._arlo.error("Failed to set mode.") self.debug( "Giving up on setting mode! Session headers=\n{}".format( pprint.pformat(self._arlo.be.session.headers) ) ) self.debug( "Giving up on setting mode! Session cookies=\n{}".format( pprint.pprint(self._arlo.be.session.cookies) ) ) _set_mode_v2_cb(1) else: self._arlo.be.put( base=self, body={ "action": "set", "resource": "modes", "publishResponse": True, "properties": {"active": mode_id}, }) else: self._arlo.warning( "{0}: mode {1} is unrecognised".format(self.name, mode_name) ) def update_mode(self): """Check and update the base's current mode.""" now = time.monotonic() with self._lock: # if now < self._last_update + MODE_UPDATE_INTERVAL: # self.debug('skipping an update') # return self._last_update = now if not self._v3_modes: data = self._arlo.be.get(AUTOMATION_PATH) for mode in data: if mode.get("uniqueId", "") == self.unique_id: self._set_mode_or_schedule(mode) def update_modes(self, initial=False): """Get and update the available modes for the base.""" if self._v1_modes: # Work around slow arlo connections. if initial and self._arlo.cfg.synchronous_mode: time.sleep(5) resp = self._arlo.be.notify( base=self, body={"action": "get", "resource": "modes", "publishResponse": False}, wait_for="event", ) if resp is not None: props = resp.get("properties", {}) self._parse_modes(props.get("modes", [])) else: self._arlo.error("unable to read mode, try forcing v2") elif self._v2_modes: modes = self._arlo.be.get( DEFINITIONS_PATH + "?uniqueIds={}".format(self.unique_id) ) if modes is not None: modes = modes.get(self.unique_id, {}) self._parse_modes(modes.get("modes", [])) self._parse_schedules(modes.get("schedules", [])) self._save(TIMEZONE_KEY, modes.get("olsonTimeZone", None)) else: self._arlo.error("failed to read modes (v2)") else: self._arlo.debug("V3Modes - None on BaseStation") curr_location = None for location in self._arlo.locations: for device_id in location.device_ids: if device_id == self.unique_id: curr_location = location break if curr_location is not None: break if curr_location: curr_location.update_mode() def update_states(self): """Get device state from 'old' style base stations. Most new devices return their state from the the devices URL but we need to query the original base stations for their child states. """ # Only do work on 'old' style base stations if self.device_type == 'basestation' or self.device_type == 'arlobridge': self.debug("updating state") self._arlo.be.notify( base=self, body={ "action": "get", "resource": "devices", "publishResponse": False, }, wait_for="response", ) @property def schedule(self): """Returns current schedule name or `None` if no schedule active.""" return self._load(SCHEDULE_KEY, None) @property def on_schedule(self): """Returns `True` is base station is running a schedule.""" return self.schedule is not None @property def refresh_rate(self): return self._refresh_rate @refresh_rate.setter def refresh_rate(self, value): if isinstance(value, (int, float)): self._refresh_rate = value @property def siren_state(self): """Returns the current siren state (`on` or `off`).""" return self._load(SIREN_STATE_KEY, "off") def siren_on(self, duration=300, volume=8): """Turn base siren on. Does nothing if base doesn't support sirens. :param duration: how long, in seconds, to sound for :param volume: how long, from 1 to 8, to sound """ body = { "action": "set", "resource": "siren", "publishResponse": True, "properties": { "sirenState": "on", "duration": int(duration), "volume": int(volume), "pattern": "alarm", }, } self.debug(str(body)) self._arlo.be.notify(base=self, body=body) def siren_off(self): """Turn base siren off. Does nothing if base doesn't support sirens. """ body = { "action": "set", "resource": "siren", "publishResponse": True, "properties": {"sirenState": "off"}, } self.debug(str(body)) self._arlo.be.notify(base=self, body=body) def restart(self): params = {"deviceId": self.device_id} tid = "diagnostics:{}".format(self.device_id) if ( self._arlo.be.post(RESTART_PATH, params=params, tid=tid, wait_for=None) is None ): self.debug("RESTART didnt send") def _ping_and_check_reply(self): body = { "action": "set", "resource": self._arlo.be.sub_id, "publishResponse": False, "properties": {"devices": [self.device_id]}, } self.debug("pinging {}".format(self.name)) if self._arlo.be.notify(base=self, body=body, wait_for="response") is None: self._save_and_do_callbacks(CONNECTION_KEY, "unavailable") else: self._save_and_do_callbacks(CONNECTION_KEY, "available") def ping(self): self._arlo.bg.run(self._ping_and_check_reply) @property def state(self): if self.is_unavailable: return "unavailable" return "available" def has_capability(self, cap): if cap in (TEMPERATURE_KEY, HUMIDITY_KEY, AIR_QUALITY_KEY): if self.model_id.startswith(MODEL_BABY): return True if cap in (SIREN_STATE_KEY,): if ( self.model_id.startswith(("VMB400", "VMB450")) or self.model_id == MODEL_GO ): return True if cap in (PING_CAPABILITY,): # Always true for these devices. if self.model_id.startswith(MODEL_BABY): return True if self.model_id.startswith(MODEL_WIRED_VIDEO_DOORBELL): return True # Don't ping these devices ever. if self.model_id.startswith(( MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, )): return False # We have to be careful pinging some base stations because it can rapidly # drain the battery power. Don't ping if: # - it is a device that acts as its own base station # - it does not have a power supply or charger connected # - it is using WiFi directly rather than an Arlo base station if self.is_own_parent: if not self.is_corded and not self.has_charger: if self.using_wifi: return False # All others, then ping. return True if cap in (RESOURCE_CAPABILITY,): # Not all devices need (or want) to get their resources queried. if self.model_id.startswith(( MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, )): return False return True return super().has_capability(cap) def build_ratls(self, public=False): self._ratls = ArloRatls(self._arlo, self, public=public) def keep_ratls_open(self): if self._ratls: self.debug("refreshing ratls for {}".format(self.name)) self._ratls.open_port() def build_media_library(self): self._ml = ArloBaseStationMediaLibrary(self._arlo, self) self._ml.load() @property def ml(self): return self._ml @property def ratls(self): return self._ratls pyaarlo-0.8.0.15/pyaarlo/camera.py000066400000000000000000001513241475374251700167100ustar00rootroot00000000000000import base64 import pprint import threading import time import zlib from .constant import ( ACTIVITY_STATE_KEY, AIR_QUALITY_KEY, AUDIO_ANALYTICS_KEY, AUDIO_DETECTED_KEY, AUDIO_POSITION_KEY, AUDIO_TRACK_KEY, BATTERY_KEY, BRIGHTNESS_KEY, CAPTURED_TODAY_KEY, CONNECTION_KEY, CRY_DETECTION_KEY, FLIP_KEY, FLOODLIGHT_BRIGHTNESS1_KEY, FLOODLIGHT_BRIGHTNESS2_KEY, FLOODLIGHT_KEY, HUMIDITY_KEY, IDLE_SNAPSHOT_PATH, LAMP_STATE_KEY, LAST_CAPTURE_KEY, LAST_IMAGE_DATA_KEY, LAST_IMAGE_KEY, LAST_IMAGE_SRC_KEY, LAST_VIDEO_CREATED_KEY, LAST_VIDEO_URL_KEY, LAST_VIDEO_THUMBNAIL_URL_KEY, LAST_VIDEO_OBJECT_TYPE, LAST_VIDEO_OBJECT_REGION, LIGHT_BRIGHTNESS_KEY, LIGHT_MODE_KEY, MEDIA_COUNT_KEY, MEDIA_PLAYER_KEY, MEDIA_PLAYER_RESOURCE_ID, MEDIA_UPLOAD_KEY, MEDIA_UPLOAD_KEYS, MIRROR_KEY, MODEL_BABY, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_PRO_2, MODEL_PRO_3, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_ULTRA, MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_GO, MOTION_DETECTED_KEY, MOTION_SENS_KEY, NIGHTLIGHT_KEY, POWER_SAVE_KEY, PRIVACY_KEY, RECENT_ACTIVITY_KEY, RECENT_ACTIVITY_KEYS, RECORD_START_PATH, RECORD_STOP_PATH, RECORDING_STOPPED_KEY, SIGNAL_STR_KEY, SIREN_STATE_KEY, SNAPSHOT_KEY, SPOTLIGHT_BRIGHTNESS_KEY, SPOTLIGHT_KEY, STREAM_SNAPSHOT_KEY, STREAM_SNAPSHOT_PATH, STREAM_START_PATH, TEMPERATURE_KEY, ) from .device import ArloChildDevice from .util import http_get, http_get_img, the_epoch class ArloCamera(ArloChildDevice): def __init__(self, name, arlo, attrs): super().__init__(name, arlo, attrs) self._recent = False self._recent_job = None self._cache_count = None self._cached_videos = None self._min_days_vdo_cache = self._arlo.cfg.library_days self._lock = threading.Condition() self._event = threading.Event() self._snapshot_time = the_epoch() self._stream_url = None # what user has requested locally self._user_requests = set() # what is keeping the stream open for us self._local_users = set() # what is triggered from elsewhere self._remote_users = set() def _parse_statistic(self, data, scale): """Parse binary statistics returned from the history API""" i = 0 for byte in bytearray(data): i = (i << 8) + byte if i == 32768: return None if scale == 0: return i return float(i) / (scale * 10) def _decode_sensor_data(self, properties): """Decode, decompress, and parse the data from the history API""" b64_input = "" for s in properties.get("payload", []): # pylint: disable=consider-using-join b64_input += s if b64_input == "": return None decoded = base64.b64decode(b64_input) data = zlib.decompress(decoded) points = [] i = 0 while i < len(data): points.append( { "timestamp": int(1e3 * self._parse_statistic(data[i: (i + 4)], 0)), "temperature": self._parse_statistic(data[(i + 8): (i + 10)], 1), "humidity": self._parse_statistic(data[(i + 14): (i + 16)], 1), "airQuality": self._parse_statistic(data[(i + 20): (i + 22)], 1), } ) i += 22 return points[-1] def _dump_activities(self, msg): self.debug( "{}::reqs='{}',local='{}',remote='{}'".format( msg, pprint.pformat(self._user_requests), pprint.pformat(self._local_users), pprint.pformat(self._remote_users), ) ) # Media library has updated, reload today's events. def _update_from_media_library(self): self.debug("reloading cache for " + self._name) count, videos = self._arlo.ml.videos_for(self) if videos: captured_today = len([video for video in videos if video.created_today]) last_captured = videos[0].created_at_pretty(self._arlo.cfg.last_format) last_image = videos[0].thumbnail_url else: captured_today = 0 last_captured = None last_image = None # update local copies with self._lock: self._cache_count = count self._cached_videos = videos # Update latest video details. if videos: if videos[0].created_at != self._load(LAST_VIDEO_CREATED_KEY): self._save_and_do_callbacks(LAST_VIDEO_CREATED_KEY, videos[0].created_at) self._save_and_do_callbacks(LAST_VIDEO_URL_KEY, videos[0].video_url) self._save_and_do_callbacks(LAST_VIDEO_THUMBNAIL_URL_KEY, videos[0].thumbnail_url) self._save_and_do_callbacks(LAST_VIDEO_OBJECT_TYPE, videos[0].object_type) self._save_and_do_callbacks(LAST_VIDEO_OBJECT_REGION, videos[0].object_region) # Tell anyone listening about the new capture and how it affects things. self._save_and_do_callbacks(CAPTURED_TODAY_KEY, captured_today) if last_captured is not None: self._save_and_do_callbacks(LAST_CAPTURE_KEY, last_captured) self._do_callbacks(MEDIA_UPLOAD_KEY, True) # new snapshot? snapshot = self._arlo.ml.snapshot_for(self) if snapshot is not None: if self._load(SNAPSHOT_KEY, None) != snapshot.image_url: self.debug("snapshot updated for media " + self.name) self._save(SNAPSHOT_KEY, snapshot.image_url) self._arlo.bg.run_low(self._update_image_from_snapshot) else: self.debug("snapshot already done for " + self.name) # New image? Then fetch it an update image details. if last_image is not None: if self._load(LAST_IMAGE_KEY, None) != last_image: self.debug("image updated for media " + self.name) self._save(LAST_IMAGE_KEY, last_image) self._arlo.bg.run_low(self._update_image_from_capture) else: self.debug("image already done for " + self.name) # Update last captured image. def _update_image_from_capture(self): # Get image and date, if fails ignore img, date = http_get_img(self._load(LAST_IMAGE_KEY, None)) if img is None: self.debug("failed to load image for " + self.name) return # Always make this the latest thumbnail image. if self._snapshot_time < date: self._snapshot_time = date date = date.strftime(self._arlo.cfg.last_format) self.debug(f"updating image for {self.name} ({date})") self._save_and_do_callbacks(LAST_IMAGE_SRC_KEY, "capture/" + date) self._save_and_do_callbacks(LAST_IMAGE_DATA_KEY, img) else: date = date.strftime(self._arlo.cfg.last_format) self.vdebug(f"ignoring image for {self.name} ({date})") # Update the last snapshot def _update_image_from_snapshot(self, ignore_date=False): # Get image and date, if fails ignore. img, date = http_get_img(self._load(SNAPSHOT_KEY, None), ignore_date) if img is None: self.debug("failed to load snapshot for " + self.name) return # Always make this the latest snapshot image. if self._snapshot_time < date: self._snapshot_time = date date = date.strftime(self._arlo.cfg.last_format) self.debug(f"updating snapshot for {self.name} ({date})") self._save_and_do_callbacks(LAST_IMAGE_SRC_KEY, "snapshot/" + date) self._save_and_do_callbacks(LAST_IMAGE_DATA_KEY, img) self._stop_snapshot() else: date = date.strftime(self._arlo.cfg.last_format) self.vdebug(f"ignoring snapshot for {self.name} ({date})") def _set_recent(self, timeo): with self._lock: self._recent = True self._arlo.bg.cancel(self._recent_job) self._recent_job = self._arlo.bg.run_in(self._clear_recent, timeo) self.debug("turning recent ON for " + self._name) self._do_callbacks(RECENT_ACTIVITY_KEY, True) def _clear_recent(self): with self._lock: self._recent = False self._recent_job = None self.debug("turning recent OFF for " + self._name) self._do_callbacks(RECENT_ACTIVITY_KEY, False) def _stop_snapshot(self): # Signal to anybody waiting. with self._lock: self._remote_users.discard("snapshot") if not self.has_user_request("snapshot"): return self._user_requests.discard("snapshot") self._dump_activities("_stop_snapshot") self._lock.notify_all() # Stop based on how we were started. if not self.is_taking_idle_snapshot: self._stop_stream(stopping_for="snapshot") # Signal stop. self.debug("snapshot finished, re-signal real state") self._save_and_do_callbacks( ACTIVITY_STATE_KEY, self._load(ACTIVITY_STATE_KEY, "unknown") ) def _queue_media_updates(self): for retry in self._arlo.cfg.media_retry: self.debug("queueing update in {}".format(retry)) self._arlo.bg.run_in( self._arlo.ml.queue_update, retry, cb=self._update_from_media_library ) def _mark_as_idle(self): """Camera has moved to idle. Either we did this or backend did this. """ if self.has_any_local_users: self.debug("got a stream/recording stop") self._queue_media_updates() self._set_recent(self._arlo.cfg.recent_time) # Remove streaming from state self.debug("removing streaming activity state") with self._lock: self._local_users = set() self._remote_users = set() self._dump_activities("_event::idle") self._lock.notify_all() def _stop_activity(self): """Request the camera stop whatever it is doing and return to the idle state.""" response = self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": {"activityState": "idle"}, "publishResponse": True, "resource": self.resource_id, }, wait_for="response", ) if response is not None: self._mark_as_idle() def _get_stream_url(self, starting_for, user_agent=None): """Getting the stream URL without starting local streaming.""" body = { "action": "get", "from": self.web_id, "properties": { "cameraId": self.device_id, }, "publishResponse": True, "responseUrl": "", "resource": self.resource_id, "to": self.parent_id, "transId": self._arlo.be.gen_trans_id(), } headers = {"xcloudId": self.xcloud_id} if user_agent is not None: headers["User-Agent"] = self._arlo.be.user_agent(user_agent) self._stream_url = self._arlo.be.post(STREAM_START_PATH, body, headers=headers) if self._stream_url is not None: if not self.has_any_local_users: with self._lock: self._local_users.add(starting_for) self._dump_activities("_get_stream_url") self._stream_url = self._stream_url["url"].replace("rtsp://", "rtsps://") self.debug("url={}".format(self._stream_url)) else: self.debug(f"No stream url for {self.name}") return self._stream_url def _start_stream(self, starting_for, user_agent=None): with self._lock: # Already streaming. Update sub-activity as needed. if self.has_any_local_users: self._local_users.add(starting_for) self._dump_activities("_start_stream") return self._stream_url # We can't start a stream if we are doing a straight snapshot. if self.is_taking_idle_snapshot: return None self._local_users.add(starting_for) self._dump_activities("_start_stream2") body = { "action": "set", "from": self.web_id, "properties": { "activityState": "startUserStream", "cameraId": self.device_id, }, "publishResponse": True, "responseUrl": "", "resource": self.resource_id, "to": self.parent_id, "transId": self._arlo.be.gen_trans_id(), } headers = {"xcloudId": self.xcloud_id} if user_agent is not None: headers["User-Agent"] = self._arlo.be.user_agent(user_agent) self._stream_url = self._arlo.be.post(STREAM_START_PATH, body, headers=headers) if self._stream_url is not None: self._stream_url = self._stream_url["url"].replace("rtsp://", "rtsps://") self.debug("url={}".format(self._stream_url)) else: with self._lock: self._local_users = set() return self._stream_url def _stop_stream(self, stopping_for="streaming"): with self._lock: self._local_users.discard(stopping_for) self._dump_activities("_stop_stream") if self.has_any_local_users: return self._stop_activity() def _event_handler(self, resource, event): self.debug(self.name + " CAMERA got one " + resource) # Stream has stopped or recording has stopped so new media is available. if resource == MEDIA_UPLOAD_KEY: # Look for easy keys. for key in MEDIA_UPLOAD_KEYS: value = event.get(key, None) if value is not None: self._save_and_do_callbacks(key, value) # The last image thumbnail has changed. Queue an image or snapshot # update to download the image and process it. if LAST_IMAGE_KEY in event: if not self.is_taking_snapshot: self.debug("{} -> thumbnail changed".format(self.name)) self._arlo.bg.run_low(self._update_image_from_capture) else: self.debug( "{} -> snapshot(thumbnail) ready".format(self.name) ) self._save(SNAPSHOT_KEY, event.get(LAST_IMAGE_KEY, "")) self._arlo.bg.run_low(self._update_image_from_snapshot, ignore_date=True) # Recording has stopped so a new video is available. Queue an # media update, this could later trigger a snapshot or image # update. if event.get(RECORDING_STOPPED_KEY, False): self.debug("{} -> recording stopped".format(self.name)) self._arlo.ml.queue_update(self._update_from_media_library) # Examine the URL passed; snapshots contain `/snapshots/` and # recordings contain `recordings`. For snapshot, save URL and queue # up an event to download and process it. We do nothing with the # recording for now, it will come in via a media update. value = event.get(STREAM_SNAPSHOT_KEY, "") if "/snapshots/" in value: self.debug("{} -> snapshot1 ready".format(self.name)) self._save(SNAPSHOT_KEY, value) self._arlo.bg.run_low(self._update_image_from_snapshot) if "/recordings/" in value: self.debug("{} -> new recording ready".format(self.name)) # Something just happened. self._set_recent(self._arlo.cfg.recent_time) return # Camera Activity State activity = event.get("properties", {}).get("activityState", "unknown") # Camera has gone idle. if activity == "idle": self._mark_as_idle() # Camera is active. If we don't know about it then update our status. if activity == "fullFrameSnapshot": with self._lock: if not self.has_user_request("snapshot"): self._remote_users.add("snapshot") self.vdebug("handle dodgy remote cameras") self._arlo.bg.run_in(self._stop_snapshot, self._arlo.cfg.snapshot_timeout) self._dump_activities("_event::snap") if activity == "alertStreamActive": with self._lock: if not self.has_user_request("recording"): self._remote_users.add("recording") if not self.has_any_local_users: self._local_users.add("remote") self._lock.notify_all() self._dump_activities("_event::record") if activity == "userStreamActive": with self._lock: if not self.has_user_request("streaming"): self._remote_users.add("streaming") if not self.has_any_local_users: self._local_users.add("remote") self._lock.notify_all() self._dump_activities("_event::stream") # Snapshot is updated. Queue retrieval. if event.get("action", "") == "fullFrameSnapshotAvailable": value = event.get("properties", {}).get( "presignedFullFrameSnapshotUrl", None ) if value is not None: self.debug("{} -> snapshot2 ready".format(self.name)) self._save(SNAPSHOT_KEY, value) self._arlo.bg.run_low(self._update_image_from_snapshot) # Non subscription... if event.get("action", "") == "lastImageSnapshotAvailable": value = event.get("properties", {}).get("presignedLastImageUrl", None) if value is not None: self.debug("{} -> snapshot3 ready".format(self.name)) self._save(SNAPSHOT_KEY, value) self._arlo.bg.run_low(self._update_image_from_snapshot) # Ambient sensors update, decode and push changes. if resource.endswith("/ambientSensors/history"): data = self._decode_sensor_data(event.get("properties", {})) if data is not None: self._save_and_do_callbacks("temperature", data.get("temperature")) self._save_and_do_callbacks("humidity", data.get("humidity")) self._save_and_do_callbacks("airQuality", data.get("airQuality")) # Properties settings. properties = event.get("properties", {}) # Anything to trip recent activity? for key in properties: if key in RECENT_ACTIVITY_KEYS: self.debug("recent activity key") self._set_recent(self._arlo.cfg.recent_time) # Local record stopped, try and trip and update. if properties.get("localRecordingActive", True) is False: self.debug("local recording stopped, updating media") self._queue_media_updates() # Night light status. nightlight = properties.get(NIGHTLIGHT_KEY, None) if nightlight is not None: self.debug( "got a night light {}".format(nightlight.get("enabled", False)) ) if nightlight.get("enabled", False) is True: self._save_and_do_callbacks(LAMP_STATE_KEY, "on") else: self._save_and_do_callbacks(LAMP_STATE_KEY, "off") brightness = nightlight.get("brightness") if brightness is not None: self._save_and_do_callbacks(LIGHT_BRIGHTNESS_KEY, brightness) mode = nightlight.get("mode") if mode is not None: rgb = nightlight.get("rgb") temperature = nightlight.get("temperature") light_mode = {"mode": mode} if rgb is not None: light_mode["rgb"] = rgb if temperature is not None: light_mode["temperature"] = temperature self._save_and_do_callbacks(LIGHT_MODE_KEY, light_mode) # Spotlight status. spotlight = properties.get(SPOTLIGHT_KEY, None) if spotlight is not None: self.debug( "got a spotlight {}".format(spotlight.get("enabled", False)) ) if spotlight.get("enabled", False) is True: self._save_and_do_callbacks(SPOTLIGHT_KEY, "on") else: self._save_and_do_callbacks(SPOTLIGHT_KEY, "off") brightness = spotlight.get("intensity") if brightness is not None: self._save_and_do_callbacks(SPOTLIGHT_BRIGHTNESS_KEY, brightness) # Floodlight status. floodlight = properties.get(FLOODLIGHT_KEY, None) if floodlight is not None: self.debug("got a flood light {}".format(floodlight.get("on", False))) self._save_and_do_callbacks(FLOODLIGHT_KEY, floodlight) # Audio analytics. audioanalytics = properties.get(AUDIO_ANALYTICS_KEY, None) if audioanalytics is not None: triggered = audioanalytics.get(CRY_DETECTION_KEY, {}).get( "triggered", False ) self._save_and_do_callbacks(CRY_DETECTION_KEY, triggered) # Pass event to lower level. super()._event_handler(resource, event) @property def resource_type(self): return "cameras" @property def last_thumbnail(self): """Returns the URL of the last image as reported by Arlo.""" return self._load(LAST_IMAGE_KEY, None) @property def last_snapshot(self): """Returns the URL of the last snapshot as reported by Arlo.""" return self._load(SNAPSHOT_KEY, None) @property def last_image(self): """Returns the URL of the last snapshot or image taken. Will pick snapshot or image based on most recently updated. """ image = None if self.last_image_source.startswith("snapshot/"): image = self.last_snapshot if image is None: image = self.last_thumbnail return image @property def last_image_from_cache(self): """Returns the last image or snapshot in binary format. :return: Binary reprsensation of the last image. :rtype: bytearray """ return self._load(LAST_IMAGE_DATA_KEY, self._arlo.blank_image) @property def last_image_source(self): """Returns a string describing what triggered the last image capture. Currently either `capture/${date}` or `snapshot/${date}`. """ return self._load(LAST_IMAGE_SRC_KEY, "") @property def last_video(self): """Returns a video object describing the last captured video. :return: Video object or `None` if no videos present. :rtype: ArloVideo """ with self._lock: if self._cached_videos: return self._cached_videos[0] return None @property def last_video_url(self): return self._load(LAST_VIDEO_URL_KEY, None) @property def last_video_thumbnail_url(self): return self._load(LAST_VIDEO_THUMBNAIL_URL_KEY, None) @property def last_video_object_type(self): return self._load(LAST_VIDEO_OBJECT_TYPE, None) @property def last_video_object_region(self): return self._load(LAST_VIDEO_OBJECT_REGION, None) def last_n_videos(self, count): """Returns the last count video objects describing the last captured videos. :return: `count` video objects or `None` if no videos present. :rtype: list(ArloVideo) """ with self._lock: if self._cached_videos: return self._cached_videos[:count] return [] @property def last_capture(self): """Returns a date string showing when the last video was captured. It uses the format returned by `last_capture_date_format`. """ return self._load(LAST_CAPTURE_KEY, None) @property def last_capture_date_format(self): """Returns a date format string used by the last_capture function. You can set this value in the parameters passed to PyArlo. """ return self._arlo.cfg.last_format @property def brightness(self): """Returns the camera brightness setting.""" return self._load(BRIGHTNESS_KEY, None) @brightness.setter def brightness(self, brightness): """ NOTE: Brightness is between -2 and 2 in increments of 1 (-2, -1, 0, 1, 2). Setting it to an invalid value has no effect. """ body = { "action": "set", "resource": self.resource_id, "publishResponse": True, "properties": {"brightness": brightness}, } self._arlo.be.notify(base=self, body=body) @property def flip_state(self): """Returns `True` if the camera is flipped, `False` otherwise.""" return self._load(FLIP_KEY, None) @property def mirror_state(self): """Returns `True` if the camera is mirrored, `False` otherwise.""" return self._load(MIRROR_KEY, None) @property def motion_detection_sensitivity(self): """Returns the camera motion sensitivity setting.""" return self._load(MOTION_SENS_KEY, None) @property def powersave_mode(self): """Returns `True` if the camera is on power save mode, `False` otherwise.""" return self._load(POWER_SAVE_KEY, None) @property def unseen_videos(self): """Returns the camera unseen video count.""" return self._load(MEDIA_COUNT_KEY, 0) @property def captured_today(self): """Returns the number of videos captured today.""" return self._load(CAPTURED_TODAY_KEY, 0) @property def min_days_vdo_cache(self): return self._min_days_vdo_cache @min_days_vdo_cache.setter def min_days_vdo_cache(self, value): self._min_days_vdo_cache = value def update_media(self, wait=None): """Requests latest list of recordings from the backend server. :param wait if True then wait for completion, if False then don't wait, if None then use synchronous_mode setting. Reloads the videos library from Arlo. """ if wait is None: wait = self._arlo.cfg.synchronous_mode if wait: self.debug("doing media update") self._update_from_media_library() else: self.debug("queueing media update") self._arlo.bg.run_low(self._update_from_media_library) def update_last_image(self, wait=None): """Requests last thumbnail from the backend server. :param wait if True then wait for completion, if False then don't wait, if None then use synchronous_mode setting. Updates the last image. """ if wait is None: wait = self._arlo.cfg.synchronous_mode if wait: self.debug("doing image update") self._update_image_from_capture() else: self.debug("queueing image update") self._arlo.bg.run_low(self._update_image_from_capture) def update_ambient_sensors(self): """Requests the latest temperature, humidity and air quality settings. Queues a job that requests the info from Arlo. """ if self.model_id == MODEL_BABY: self._arlo.be.notify( base=self.base_station, body={ "action": "get", "resource": "cameras/{}/ambientSensors/history".format( self.device_id ), "publishResponse": False, }, ) def _take_streaming_snapshot(self): body = { "xcloudId": self.xcloud_id, "parentId": self.parent_id, "deviceId": self.device_id, "olsonTimeZone": self.timezone, } self._arlo.bg.run( self._arlo.be.post, path=STREAM_SNAPSHOT_PATH, params=body, headers={"xcloudId": self.xcloud_id}, ) def _take_idle_snapshot(self): body = { "action": "set", "from": self.web_id, "properties": {"activityState": "fullFrameSnapshot"}, "publishResponse": True, "resource": self.resource_id, "to": self.parent_id, "transId": self._arlo.be.gen_trans_id(), } self._arlo.bg.run( self._arlo.be.post, path=IDLE_SNAPSHOT_PATH, params=body, headers={"xcloudId": self.xcloud_id}, ) def request_snapshot(self): """Requests a snapshot from the camera without blocking. The snapshot can be handled with callbacks registered to LAST_IMAGE_SRC_KEY - lastImageSource starting with snapshot/, or capture/ LAST_IMAGE_DATA_KEY - presignedLastImageData containing the image data. """ with self._lock: if self.has_user_request("snapshot"): return stream_snapshot = self.has_any_local_users self._user_requests.add("snapshot") self._dump_activities("request_snapshot") snapshot_running = self.has_remote_user("snapshot") self._save_and_do_callbacks(ACTIVITY_STATE_KEY, "fullFrameSnapshot") if not snapshot_running: if stream_snapshot: self.debug("streaming/recording snapshot") self._take_streaming_snapshot() if self._arlo.cfg.stream_snapshot_stop > 0: self.debug( "queing stream stop in {}".format( self._arlo.cfg.stream_snapshot_stop ) ) self._arlo.bg.run_in( self._stop_stream, self._arlo.cfg.stream_snapshot_stop, stopping_for="snapshot", ) else: self.debug("idle snapshot") self._take_idle_snapshot() for check in self._arlo.cfg.snapshot_checks: self.debug("queueing snapshot check in {}".format(check)) self._arlo.bg.run_in( self._arlo.ml.queue_update, check, cb=self._update_from_media_library ) self.vdebug("handle dodgy cameras") self._arlo.bg.run_in(self._stop_snapshot, self._arlo.cfg.snapshot_timeout) def get_snapshot(self, timeout=60): """Gets a snapshot from the camera and returns it. :param timeout: how long to wait, in seconds, before stopping the snapshot attempt :return: a binary represention of the image, or the last image if snapshot timed out :rtype: bytearray """ self.request_snapshot() mnow = time.monotonic() mend = mnow + timeout with self._lock: while mnow < mend and self.has_user_request("snapshot"): self._lock.wait(mend - mnow) mnow = time.monotonic() self.debug("finished snapshot") return self.last_image_from_cache @property def is_taking_snapshot(self): """Returns `True` if camera is taking a snapshot, `False` otherwise. Snapshot can be started from anywhere. """ return self.has_user_request("snapshot") or self.has_remote_user("snapshot") @property def is_taking_idle_snapshot(self): """Returns `True` if camera is taking a non-streaming snapshot, `False` otherwise. """ return self.is_taking_snapshot and not self.has_any_local_users @property def is_recording(self): """Returns `True` if camera is recording a video, `False` otherwise. Recording can be started from anywhere. """ return self.has_user_request("recording") or self.has_remote_user("recording") @property def is_streaming(self): """Returns `True` if camera is streaming a video, `False` otherwise. Stream has to be started locally. """ return self.has_user_request("streaming") or self.has_remote_user("streaming") def has_user_request(self, activity): return activity in self._user_requests @property def has_any_user_requests(self): return len(self._user_requests) != 0 def has_local_user(self, activity): return activity in self._local_users @property def has_any_local_users(self): return len(self._local_users) != 0 def has_remote_user(self, activity): return activity in self._remote_users @property def has_any_remote_users(self): return len(self._remote_users) != 0 def has_activity(self, activity): """Returns `True` is camera is performing a particular activity, `False` otherwise. """ return ( self.has_user_request(activity) or self.has_local_user(activity) or self.has_remote_user(activity) ) @property def was_recently_active(self): """Returns `True` if camera was recently active, `False` otherwise.""" return self._recent @property def state(self): """Returns the camera's current state.""" if not self.is_on: return "off" if self.has_local_user("snapshot"): if self.has_local_user("recording"): return "recording + snapshot" if self.has_local_user("streaming"): return "streaming + snapshot" return "taking snapshot" if self.has_activity("recording"): return "recording" if self.has_activity("streaming"): return "streaming" if self.was_recently_active: return "recently active" return super().state def get_stream_url(self, user_agent=None): """Getting the stream URL without starting local streaming.""" return self._get_stream_url("remote", user_agent) def get_stream(self, user_agent=None): """Start a stream and return the URL for it. Code does nothing with the url, it's up to you to pass the url to something. The stream will stop if nothing connects to it within 30 seconds. """ return self._start_stream("streaming", user_agent) def start_stream(self, user_agent=None): """Start a stream and return the URL for it. Code does nothing with the url, it's up to you to pass the url to something. The stream will stop if nothing connects to it within 30 seconds. """ return self._start_stream("streaming", user_agent) def start_snapshot_stream(self, user_agent=None): return self._start_stream("snapshot", user_agent) def start_recording_stream(self, user_agent=None): return self._start_stream("recording", user_agent) def stop_stream(self): self._stop_stream("streaming") def stop_snapshot_stream(self): self._stop_stream("snapshot") def stop_recording_stream(self): self._stop_stream("recording") def wait_for_user_stream(self, timeout=15): self.debug("waiting for stream") mnow = time.monotonic() mend = mnow + timeout with self._lock: while mnow < mend and not self.has_remote_user("streaming"): self._lock.wait(mend - mnow) mnow = time.monotonic() active = self.has_remote_user("streaming") # Is active, give a small delay to get going. if active: self.debug("delaying stream start") self._event.wait(self._arlo.cfg.user_stream_delay) return active def get_video(self): """Download and return the last recorded video. **Note:** Prefer getting the url and downloading it yourself. """ video = self.last_video if video is not None: return http_get(video.video_url) return None def stop_activity(self): """Request the camera stop whatever it is doing and return to the idle state.""" # has_any_activity self._stop_activity() return True def start_recording(self, duration=None): """Request the camera start recording. :param duration: seconds for recording to run, `None` means no stopping. **Note:** Arlo will stop the recording after 30 seconds if nothing connects to the stream. **Note:** Arlo will stop the recording after 30 minutes anyway. """ with self._lock: if not self.has_any_local_users: return None if self.has_user_request("recording"): return self._stream_url self._user_requests.add("recording") self._dump_activities("start_recording") body = { "parentId": self.parent_id, "deviceId": self.device_id, "olsonTimeZone": self.timezone, } self.debug("starting recording") self._save_and_do_callbacks(ACTIVITY_STATE_KEY, "alertStreamActive") self._arlo.bg.run( self._arlo.be.post, path=RECORD_START_PATH, params=body, headers={"xcloudId": self.xcloud_id}, ) # Queue up stop. if duration is not None: self.debug("queueing stop") self._arlo.bg.run_in(self.stop_recording, duration) return self._stream_url def stop_recording(self): """Request the camera stop recording.""" with self._lock: if not self.has_user_request("recording") and not self.has_remote_user( "recording" ): return self._user_requests.discard("recording") self._dump_activities("stop_recording") body = { "parentId": self.parent_id, "deviceId": self.device_id, } self.debug("stopping recording") self._arlo.bg.run( self._arlo.be.post, path=RECORD_STOP_PATH, params=body, headers={"xcloudId": self.xcloud_id}, ) # stop stream self._arlo.bg.run_in(self.stop_recording_stream, 1) @property def _siren_resource_id(self): return "siren/{}".format(self.device_id) @property def siren_state(self): return self._load(SIREN_STATE_KEY, "off") def siren_on(self, duration=300, volume=8): """Turn camera siren on. Does nothing if camera doesn't support sirens. :param duration: how long, in seconds, to sound for :param volume: how long, from 1 to 8, to sound """ body = { "action": "set", "resource": self._siren_resource_id, "publishResponse": True, "properties": { "sirenState": "on", "duration": int(duration), "volume": int(volume), "pattern": "alarm", }, } self._arlo.be.notify(base=self, body=body) def siren_off(self): """Turn camera siren off. Does nothing if camera doesn't support sirens. """ body = { "action": "set", "resource": self._siren_resource_id, "publishResponse": True, "properties": {"sirenState": "off"}, } self._arlo.be.notify(base=self, body=body) @property def is_on(self): """Returns `True` if the camera turned on.""" return not self._load(PRIVACY_KEY, False) def turn_on(self): """Turn the camera on.""" body = { "action": "set", "resource": self.resource_id, "publishResponse": True, "properties": {"privacyActive": False}, } self._arlo.be.notify(base=self.base_station, body=body) def turn_off(self): """Turn the camera off.""" body = { "action": "set", "resource": self.resource_id, "publishResponse": True, "properties": {"privacyActive": True}, } self._arlo.be.notify(base=self.base_station, body=body) def get_audio_playback_status(self): """Gets the current playback status and available track list""" body = {"action": "get", "publishResponse": True, "resource": "audioPlayback"} self._arlo.be.notify(base=self, body=body) def play_track(self, track_id=None, position=0): """Play the track. A track ID of None will resume playing the current track. :param track_id: track id :param position: position in the track """ body = { "publishResponse": True, "resource": MEDIA_PLAYER_RESOURCE_ID, } if track_id is not None: body.update( { "action": "playTrack", "properties": { AUDIO_TRACK_KEY: track_id, AUDIO_POSITION_KEY: position, }, } ) else: body.update( { "action": "play", } ) self._arlo.be.notify(base=self, body=body) def pause_track(self): """Pause the playing track.""" body = { "action": "pause", "publishResponse": True, "resource": MEDIA_PLAYER_RESOURCE_ID, } self._arlo.be.notify(base=self, body=body) def previous_track(self): """Skips to the previous track in the playlist.""" body = { "action": "prevTrack", "publishResponse": True, "resource": MEDIA_PLAYER_RESOURCE_ID, } self._arlo.be.notify(base=self, body=body) def next_track(self): """Skips to the next track in the playlist.""" body = { "action": "nextTrack", "publishResponse": True, "resource": MEDIA_PLAYER_RESOURCE_ID, } self._arlo.be.notify(base=self, body=body) def set_music_loop_mode_continuous(self): """Sets the music loop mode to repeat the entire playlist.""" body = { "action": "set", "publishResponse": True, "resource": "audioPlayback/config", "properties": {"config": {"loopbackMode": "continuous"}}, } self._arlo.be.notify(base=self, body=body) def set_music_loop_mode_single(self): """Sets the music loop mode to repeat the current track.""" body = { "action": "set", "publishResponse": True, "resource": "audioPlayback/config", "properties": {"config": {"loopbackMode": "singleTrack"}}, } self._arlo.be.notify(base=self, body=body) def set_shuffle(self, shuffle=True): """Sets playback to shuffle. :param shuffle: `True` to turn on shuffle. """ body = { "action": "set", "publishResponse": True, "resource": "audioPlayback/config", "properties": {"config": {"shuffleActive": shuffle}}, } self._arlo.be.notify(base=self, body=body) def set_volume(self, mute=False, volume=50): """Sets the music volume. :param mute: `True` to mute the volume. :param volume: set volume (0-100) """ body = { "action": "set", "publishResponse": True, "resource": self.resource_id, "properties": {"speaker": {"mute": mute, "volume": volume}}, } self._arlo.be.notify(base=self, body=body) def _set_nightlight_properties(self, properties): self.debug( "{}: setting nightlight properties: {}".format(self._name, properties) ) self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": {"nightLight": properties}, "publishResponse": True, "resource": self.resource_id, }, ) return True def nightlight_on(self): """Turns the nightlight on.""" return self._set_nightlight_properties({"enabled": True}) def nightlight_off(self): """Turns the nightlight off.""" return self._set_nightlight_properties({"enabled": False}) def set_nightlight_brightness(self, brightness): """Sets the nightlight brightness. :param brightness: brightness (0-255) """ return self._set_nightlight_properties({"brightness": brightness}) def set_nightlight_rgb(self, red=255, green=255, blue=255): """Turns the nightlight color to the specified RGB value. :param red: red value :param green: green value :param blue: blue value """ return self._set_nightlight_properties( {"mode": "rgb", "rgb": {"red": red, "green": green, "blue": blue}} ) def set_nightlight_color_temperature(self, temperature): """Turns the nightlight to the specified Kelvin color temperature. :param temperature: temperature, in Kelvin """ return self._set_nightlight_properties( {"mode": "temperature", "temperature": str(temperature)} ) def set_nightlight_mode(self, mode): """Turns the nightlight to a particular mode. :param mode: either `rgb`, `temperature` or `rainbow` :return: """ return self._set_nightlight_properties({"mode": mode}) def _set_spotlight_properties(self, properties): self.debug( "{}: setting spotlight properties: {}".format(self._name, properties) ) self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": {"spotlight": properties}, "publishResponse": True, "resource": self.resource_id, }, ) return True def set_spotlight_on(self): """Turns the spotlight on""" return self._set_spotlight_properties({"enabled": True}) def set_spotlight_off(self): """Turns the spotlight off""" return self._set_spotlight_properties({"enabled": False}) def set_spotlight_brightness(self, brightness): """Sets the nightlight brightness. :param brightness: brightness (0-255) """ # Note: Intensity is 0-100 scale, which we map from 0-255 to # provide an API consistent with nightlight brightness return self._set_spotlight_properties({"intensity": (brightness / 255 * 100)}) def _set_floodlight_properties(self, properties): self.debug( "{}: setting floodlight properties: {}".format(self._name, properties) ) self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": {"floodlight": properties}, "publishResponse": True, "resource": self.resource_id, }, ) return True def floodlight_on(self): """Turns the floodlight on.""" return self._set_floodlight_properties({"on": True}) def floodlight_off(self): """Turns the floodlight off.""" return self._set_floodlight_properties({"on": False}) def set_floodlight_brightness(self, brightness): """Turns the floodlight brightness value (0-255).""" percentage = int(brightness / 255 * 100) return self._set_floodlight_properties( { FLOODLIGHT_BRIGHTNESS1_KEY: percentage, FLOODLIGHT_BRIGHTNESS2_KEY: percentage, } ) def has_capability(self, cap): if cap in (BATTERY_KEY,): if self.model_id.startswith(( MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, )): return False else: return True if cap in (MOTION_DETECTED_KEY, SIGNAL_STR_KEY): return True if cap in (LAST_CAPTURE_KEY, CAPTURED_TODAY_KEY, RECENT_ACTIVITY_KEY): return True if cap in (AUDIO_DETECTED_KEY,): if self.model_id.startswith(( MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_PRO_2, MODEL_PRO_3, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_ULTRA, MODEL_GO, MODEL_BABY, )): return True if self.device_type.startswith("arloq"): return True if cap in (SIREN_STATE_KEY,): if self.model_id.startswith(( MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_PRO_3, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_ULTRA, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL, )): return True if cap in (SPOTLIGHT_KEY,): if self.model_id.startswith(( MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_PRO_3, MODEL_PRO_4, MODEL_PRO_5, MODEL_ULTRA )): return True if cap in (TEMPERATURE_KEY, HUMIDITY_KEY, AIR_QUALITY_KEY): if self.model_id.startswith(MODEL_BABY): return True if cap in (MEDIA_PLAYER_KEY, NIGHTLIGHT_KEY, CRY_DETECTION_KEY): if self.model_id.startswith(MODEL_BABY): return True if cap in (FLOODLIGHT_KEY,): if self.model_id.startswith(MODEL_PRO_3_FLOODLIGHT): return True if cap in (CONNECTION_KEY,): # These devices are their own base stations so don't re-add connection key. if self.parent_id == self.device_id and self.model_id.startswith(( MODEL_BABY, MODEL_PRO_3_FLOODLIGHT, MODEL_PRO_4, MODEL_PRO_5, MODEL_ESSENTIAL_SPOTLIGHT, MODEL_ESSENTIAL_XL_SPOTLIGHT, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD, MODEL_ESSENTIAL_OUTDOOR_GEN2_2K, MODEL_ESSENTIAL_OUTDOOR_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL, MODEL_ESSENTIAL_INDOOR, MODEL_ESSENTIAL_INDOOR_GEN2_2K, MODEL_ESSENTIAL_INDOOR_GEN2_HD, MODEL_GO, )): return False if self.device_type in ("arloq", "arloqs"): return False return super().has_capability(cap) pyaarlo-0.8.0.15/pyaarlo/cfg.py000066400000000000000000000206061475374251700162150ustar00rootroot00000000000000import platform import tempfile import os from urllib.parse import urlparse from .constant import ( DEFAULT_AUTH_HOST, DEFAULT_HOST, DEFAULT_MQTT_PORT, MQTT_HOST, PRELOAD_DAYS, TFA_CONSOLE_SOURCE, TFA_DEFAULT_HOST, TFA_DELAY, TFA_EMAIL_TYPE, TFA_RETRIES, ECDH_CURVES ) class ArloCfg(object): """Helper class to get at Arlo configuration options. I got sick of adding in variables each time the config changed so I moved it all here. Config is passed in a kwarg and parsed out by the property methods. """ def __init__(self, arlo, **kwargs): """The constructor. Args: kwargs (kwargs): Configuration options. """ self._arlo = arlo self._kw = kwargs self._arlo.debug("config: loaded") self._update_backend = False strplatform = platform.system() termux_dir = "/data/data/com.termux/files/home" if strplatform == "Windows": self._storage_dir = os.path.join(tempfile.gettempdir(), ".aarlo") elif os.path.exists(termux_dir): self._storage_dir = self._kw.get("storage_dir", os.path.join(termux_dir, ".aarlo")) else: self._storage_dir = self._kw.get("storage_dir", "/tmp/.aarlo") def _remove_scheme(self, host): bits = host.split("://") if len(bits) > 1: return bits[1] return host def _add_scheme(self, host, scheme='https'): if "://" in host: return host return f"{scheme}://{host}" @property def storage_dir(self): return self._storage_dir @property def name(self): return self._kw.get("name", "aarlo") @property def username(self): return self._kw.get("username", "unknown") @property def password(self): return self._kw.get("password", "unknown") @property def host(self): return self._add_scheme(self._kw.get("host", DEFAULT_HOST), "https") @property def auth_host(self): return self._add_scheme(self._kw.get("auth_host", DEFAULT_AUTH_HOST), "https") @property def mqtt_host(self): return self._remove_scheme(self._kw.get("mqtt_host", MQTT_HOST)) @property def mqtt_port(self): return self._kw.get("mqtt_port", DEFAULT_MQTT_PORT) def update_mqtt_from_url(self, url): if self._update_backend or self.event_backend == "auto": self._update_backend = True url = urlparse(url) if url.scheme == "wss": self._kw["backend"] = 'sse' else: self._kw["backend"] = 'mqtt' self._kw["mqtt_host"] = url.hostname self._kw["mqtt_port"] = url.port @property def mqtt_hostname_check(self): return self._kw.get("mqtt_hostname_check", True) @property def mqtt_transport(self): return self._kw.get("mqtt_transport", "tcp") @property def dump(self): return self._kw.get("dump", False) @property def max_days(self): return self._kw.get("max_days", 365) @property def db_motion_time(self): return self._kw.get("db_motion_time", 30) @property def db_ding_time(self): return self._kw.get("db_ding_time", 10) @property def request_timeout(self): return self._kw.get("request_timeout", 60) @property def stream_timeout(self): return self._kw.get("stream_timeout", 0) @property def recent_time(self): return self._kw.get("recent_time", 600) @property def last_format(self): return self._kw.get("last_format", "%m-%d %H:%M") @property def no_media_upload(self): return self._kw.get("no_media_upload", False) @property def media_retry(self): retries = self._kw.get("media_retry", []) if not retries and self.no_media_upload: retries = [0, 5, 10] return retries @property def snapshot_checks(self): return self._kw.get("snapshot_checks", []) @property def user_agent(self): return self._kw.get("user_agent", "arlo") @property def mode_api(self): return self._kw.get("mode_api", "auto") @property def refresh_devices_every(self): return self._kw.get("refresh_devices_every", 0) * 60 * 60 @property def refresh_modes_every(self): return self._kw.get("refresh_modes_every", 0) * 60 @property def reconnect_every(self): return self._kw.get("reconnect_every", 0) * 60 @property def snapshot_timeout(self): return self._kw.get("snapshot_timeout", 60) @property def verbose(self): return self._kw.get("verbose_debug", False) @property def tfa_source(self): return self._kw.get("tfa_source", TFA_CONSOLE_SOURCE) @property def tfa_type(self): return self._kw.get("tfa_type", TFA_EMAIL_TYPE).lower() @property def tfa_delay(self): return self._kw.get("tfa_delay", TFA_DELAY) @property def tfa_retries(self): return self._kw.get("tfa_retries", TFA_RETRIES) @property def tfa_timeout(self): return self._kw.get("tfa_timeout", 3) @property def tfa_total_timeout(self): return self._kw.get("tfa_total_timeout", 60) @property def tfa_host(self): host = self._remove_scheme(self._kw.get("tfa_host", TFA_DEFAULT_HOST)) return host.split(":")[0] def tfa_host_with_scheme(self, scheme="https"): host = self._add_scheme(self._kw.get("tfa_host", TFA_DEFAULT_HOST), scheme) return ":".join(host.split(":")[:2]) @property def tfa_port(self): host = self._remove_scheme(self._kw.get("tfa_host", TFA_DEFAULT_HOST)) bits = host.split(":") if len(bits) == 1: return 993 return int(bits[1]) @property def tfa_username(self): u = self._kw.get("tfa_username", None) if u is None: u = self.username return u @property def tfa_password(self): p = self._kw.get("tfa_password", None) if p is None: p = self.password return p @property def tfa_nickname(self): return self._kw.get("tfa_nickname", self.tfa_username) @property def wait_for_initial_setup(self): return self._kw.get("wait_for_initial_setup", True) @property def save_state(self): return self._kw.get("save_state", True) @property def state_file(self): if self.save_state: return self.storage_dir + "/" + self.name + ".pickle" return None @property def session_file(self): return self.storage_dir + "/session.pickle" @property def save_session(self): return self._kw.get("save_session", True) @property def cookies_file(self): return self.storage_dir + "/cookies.txt" @property def dump_file(self): if self.dump: return self.storage_dir + "/" + "packets.dump" return None @property def library_days(self): return self._kw.get("library_days", PRELOAD_DAYS) @property def synchronous_mode(self): return self._kw.get("synchronous_mode", False) @property def user_stream_delay(self): return self._kw.get("user_stream_delay", 1) @property def serial_ids(self): return self._kw.get("serial_ids", False) @property def stream_snapshot(self): return self._kw.get("stream_snapshot", False) @property def stream_snapshot_stop(self): return self._kw.get("stream_snapshot_stop", 10) @property def save_media_to(self): return self._kw.get("save_media_to", "") @property def no_unicode_squash(self): return self._kw.get("no_unicode_squash", True) @property def event_backend(self): return self._kw.get("backend", "auto") @property def cipher_list(self): if self._kw.get("default_ciphers", False): return 'DEFAULT' return self._kw.get("cipher_list", "") @property def ecdh_curves(self): curve = self._kw.get("ecdh_curve", None) if curve in ECDH_CURVES: # Moves user-selected curve to front of list ECDH_CURVES.insert(0, ECDH_CURVES.pop(ECDH_CURVES.index(curve))) return ECDH_CURVES @property def send_source(self): return self._kw.get("send_source", False) pyaarlo-0.8.0.15/pyaarlo/constant.py000066400000000000000000000244511475374251700173110ustar00rootroot00000000000000DEFAULT_HOST = "https://myapi.arlo.com" ORIGIN_HOST = "https://my.arlo.com" REFERER_HOST = "https://my.arlo.com/" MQTT_HOST = "mqtt-cluster.arloxcld.com" DEFAULT_MQTT_PORT = 443 DEVICES_PATH = "/hmsweb/v2/users/devices" DEFINITIONS_PATH = "/hmsweb/users/automation/definitions" AUTOMATION_PATH = "/hmsweb/users/devices/automation/active" LIBRARY_PATH = "/hmsweb/users/library" LOGIN_PATH = "/hmsweb/login/v2" SESSION_PATH = "/hmsweb/users/session/v3" LOGOUT_PATH = "/hmsweb/logout" NOTIFY_PATH = "/hmsweb/users/devices/notify/" SUBSCRIBE_PATH = "/hmsweb/client/subscribe" UNSUBSCRIBE_PATH = "/hmsweb/client/unsubscribe" MODES_PATH = "/hmsweb/users/devices/automation/active" RECORD_START_PATH = "/hmsweb/users/devices/startRecord" RECORD_STOP_PATH = "/hmsweb/users/devices/stopRecord" RESTART_PATH = "/hmsweb/users/devices/restart" STREAM_SNAPSHOT_PATH = "/hmsweb/users/devices/takeSnapshot" STREAM_START_PATH = "/hmsweb/users/devices/startStream" IDLE_SNAPSHOT_PATH = "/hmsweb/users/devices/fullFrameSnapshot" CREATE_DEVICE_CERTS_PATH = "/hmsweb/users/devices/v2/security/cert/create" RATLS_TOKEN_GENERATE_PATH = "/hmsweb/users/device/ratls/token" RATLS_CONNECTIVITY_PATH = '/hmsls/connectivity' RATLS_DOWNLOAD_PATH = "/hmsls/download" RATLS_LIBRARY_PATH = "/hmsls/list" # Supports list/{YYYYMMDD}/{YYYYMMMDD} or list/{YYYYMMDD}/{YYYYMMMDD}/{device_id} LOCATIONS_PATH_FORMAT = "/hmsdevicemanagement/users/{0}/locations" # {0} is _user_id LOCATION_MODES_PATH_FORMAT = "/hmsweb/automation/v3/modes?locationId={0}" # {0} is _location_id LOCATION_ACTIVEMODE_PATH_FORMAT = "/hmsweb/automation/v3/activeMode?locationId={0}" # {0} is _location_id LOCATIONS_EMERGENCY_PATH = "/hmsweb/users/emergency/locations" MQTT_PATH = "/mqtt" TRANSID_PREFIX = "web" DEFAULT_AUTH_HOST = "https://ocapi-app.arlo.com" AUTH_PATH = "/api/auth" AUTH_START_PATH = "/api/startAuth" AUTH_FINISH_PATH = "/api/finishAuth" AUTH_GET_FACTORS = "/api/getFactors" AUTH_GET_FACTORID = "/api/getFactorId" AUTH_VALIDATE_PATH = "/api/validateAccessToken" AUTH_START_PAIRING = "/api/startPairingFactor" TFA_CONSOLE_SOURCE = "console" TFA_IMAP_SOURCE = "imap" TFA_REST_API_SOURCE = "rest-api" TFA_PUSH_SOURCE = "push" TFA_EMAIL_TYPE = "EMAIL" TFA_SMS_TYPE = "SMS" TFA_PUSH_TYPE = "PUSH" TFA_DELAY = 5 TFA_RETRIES = 5 TFA_DEFAULT_HOST = "https://pyaarlo-tfa.appspot.com" PRELOAD_DAYS = 30 # Start up delays. REFRESH_CAMERA_DELAY = 5 INITIAL_REFRESH_DELAY = REFRESH_CAMERA_DELAY + 3 MEDIA_LIBRARY_DELAY = 15 CAMERA_MEDIA_DELAY = MEDIA_LIBRARY_DELAY + 10 # Update intervals. FAST_REFRESH_INTERVAL = 60 SLOW_REFRESH_INTERVAL = 10 * 60 EVENT_STREAM_TIMEOUT = (FAST_REFRESH_INTERVAL * 2) + 5 MODE_UPDATE_INTERVAL = 2 # Device capabilities PING_CAPABILITY = "pingCapability" RESOURCE_CAPABILITY = "resourceCapability" # update keys ACTIVITY_STATE_KEY = "activityState" AIR_QUALITY_KEY = "airQuality" ALS_STATE_KEY = "alsState" AUDIO_DETECTED_KEY = "audioDetected" AUDIO_ANALYTICS_KEY = "audioAnalytics" BATTERY_KEY = "batteryLevel" BATTERY_TECH_KEY = "batteryTech" BRIGHTNESS_KEY = "brightness" BUTTON_PRESSED_KEY = "buttonPressed" CHARGER_KEY = "chargerTech" CHARGING_KEY = "chargingState" CHIMES_KEY = "chimes" CONNECTION_KEY = "connectionState" CONTACT_STATE_KEY = "contactState" CRY_DETECTION_KEY = "babyCryDetection" FLIP_KEY = "flip" HUMIDITY_KEY = "humidity" LAMP_STATE_KEY = "lampState" MIRROR_KEY = "mirror" MOTION_DETECTED_KEY = "motionDetected" MOTION_STATE_KEY = "motionState" MOTION_ENABLED_KEY = "motionSetupModeEnabled" MOTION_SENS_KEY = "motionSetupModeSensitivity" MQTT_URL_KEY = "mqttUrl" POWER_SAVE_KEY = "powerSaveMode" PRIVACY_KEY = "privacyActive" LIGHT_BRIGHTNESS_KEY = "lightBrightness" LIGHT_MODE_KEY = "lightMode" RECORDING_STOPPED_KEY = "recordingStopped" SILENT_MODE_KEY = "silentMode" SPOTLIGHT_KEY = "spotlight" SPOTLIGHT_BRIGHTNESS_KEY = "spotlightBrightness" SIGNAL_STR_KEY = "signalStrength" SIREN_STATE_KEY = "sirenState" TEMPERATURE_KEY = "temperature" TIMEZONE_KEY = "olsonTimeZone" TRADITIONAL_CHIME_KEY = "traditionalChime" NIGHTLIGHT_KEY = "nightLight" MEDIA_PLAYER_KEY = "mediaPlayer" FLOODLIGHT_KEY = "floodlight" FLOODLIGHT_BRIGHTNESS1_KEY = "brightness1" FLOODLIGHT_BRIGHTNESS2_KEY = "brightness2" TAMPER_STATE_KEY = 'tamperState' WATER_STATE_KEY = "waterState" AUDIO_CONFIG_KEY = "config" AUDIO_PLAYLIST_KEY = "playlist" AUDIO_POSITION_KEY = "position" AUDIO_SPEAKER_KEY = "speaker" AUDIO_STATUS_KEY = "status" AUDIO_TRACK_KEY = "trackId" # we can get these from the resource; doorbell is subset RESOURCE_KEYS = [ ACTIVITY_STATE_KEY, AIR_QUALITY_KEY, AUDIO_DETECTED_KEY, BATTERY_KEY, BATTERY_TECH_KEY, BRIGHTNESS_KEY, CONNECTION_KEY, CHARGER_KEY, CHARGING_KEY, FLIP_KEY, HUMIDITY_KEY, LAMP_STATE_KEY, LIGHT_BRIGHTNESS_KEY, LIGHT_MODE_KEY, MIRROR_KEY, MOTION_DETECTED_KEY, MOTION_ENABLED_KEY, MOTION_SENS_KEY, POWER_SAVE_KEY, PRIVACY_KEY, SIGNAL_STR_KEY, SIREN_STATE_KEY, TEMPERATURE_KEY, AUDIO_CONFIG_KEY, AUDIO_PLAYLIST_KEY, AUDIO_STATUS_KEY, AUDIO_SPEAKER_KEY, AUDIO_TRACK_KEY, AUDIO_POSITION_KEY, ] RESOURCE_UPDATE_KEYS = [ ACTIVITY_STATE_KEY, AIR_QUALITY_KEY, ALS_STATE_KEY, AUDIO_CONFIG_KEY, AUDIO_DETECTED_KEY, AUDIO_PLAYLIST_KEY, AUDIO_POSITION_KEY, AUDIO_SPEAKER_KEY, AUDIO_STATUS_KEY, AUDIO_TRACK_KEY, BATTERY_KEY, BATTERY_TECH_KEY, CHARGER_KEY, CHARGING_KEY, CONNECTION_KEY, CONTACT_STATE_KEY, FLOODLIGHT_KEY, HUMIDITY_KEY, LAMP_STATE_KEY, MOTION_DETECTED_KEY, MOTION_STATE_KEY, PRIVACY_KEY, SIGNAL_STR_KEY, SILENT_MODE_KEY, SIREN_STATE_KEY, TAMPER_STATE_KEY, TEMPERATURE_KEY, ] RECENT_ACTIVITY_KEYS = [AUDIO_DETECTED_KEY, MOTION_DETECTED_KEY] # device keys CONNECTIVITY_KEY = "connectivity" DEVICE_ID_KEY = "deviceId" DEVICE_NAME_KEY = "deviceName" DEVICE_TYPE_KEY = "deviceType" MEDIA_COUNT_KEY = "mediaObjectCount" PARENT_ID_KEY = "parentId" UNIQUE_ID_KEY = "uniqueId" USER_ID_KEY = "userId" LAST_IMAGE_KEY = "presignedLastImageUrl" LAST_RECORDING_KEY = "presignedLastRecordingUrl" SNAPSHOT_KEY = "presignedFullFrameSnapshotUrl" STREAM_SNAPSHOT_KEY = "presignedContentUrl" XCLOUD_ID_KEY = "xCloudId" LAST_VIDEO_CREATED_KEY = "lastCaptureVideoCreated" LAST_VIDEO_URL_KEY = "lastCaptureVideoUrl" LAST_VIDEO_THUMBNAIL_URL_KEY = "lastCaptureThumbnailUrl" LAST_VIDEO_OBJECT_TYPE = "lastCaptureObjectType" LAST_VIDEO_OBJECT_REGION = "lastCaptureObjectRegion" DEVICE_KEYS = [ ACTIVITY_STATE_KEY, DEVICE_ID_KEY, DEVICE_NAME_KEY, DEVICE_TYPE_KEY, LAST_IMAGE_KEY, MEDIA_COUNT_KEY, PARENT_ID_KEY, SNAPSHOT_KEY, UNIQUE_ID_KEY, USER_ID_KEY, XCLOUD_ID_KEY, ] MEDIA_UPLOAD_KEYS = [MEDIA_COUNT_KEY, LAST_IMAGE_KEY] # custom keys CAPTURED_TODAY_KEY = "capturedToday" LAST_CAPTURE_KEY = "lastCapture" MODE_KEY = "activeMode" MODE_REVISION_KEY = "activeModeRevision" MODES_KEY = "configuredMode" LAST_IMAGE_DATA_KEY = "presignedLastImageData" LAST_IMAGE_SRC_KEY = "lastImageSource" MEDIA_UPLOAD_KEY = "mediaUploadNotification" MODE_NAME_TO_ID_KEY = "modeNameToId" MODE_ID_TO_NAME_KEY = "modeIdToName" MODE_IS_SCHEDULE_KEY = "modeIsSchedule" RECENT_ACTIVITY_KEY = "recentActivity" SCHEDULE_KEY = "activeSchedule" TOTAL_BELLS_KEY = "totalDoorBells" TOTAL_CAMERAS_KEY = "totalCameras" TOTAL_LIGHTS_KEY = "totalLights" SILENT_MODE_CALL_KEY = "call" SILENT_MODE_ACTIVE_KEY = "active" # Media player MEDIA_PLAYER_RESOURCE_ID = "audioPlayback/player" DEFAULT_TRACK_ID = "229dca67-7e3c-4a5f-8f43-90e1a9bffc38" BLANK_IMAGE = ( "iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAQAAACVz5XZAAAAh0lEQVR42u3QMQ0AAAgDMOZf9BDB" "RdJKaNrhIAIFChQoEIECBQpEoECBAhEoUKBABAoUKBCBAgUKRKBAgQIRKFCgQAQKFCgQgQIFCkSg" "QIECBSJQoECBCBQoUCACBQoUiECBAgUiUKBAgQgUKFAgAgUKFIhAgQIFIlCgQIEIFChQoECBAgV+" "tivOs6f/QsrFAAAAAElFTkSuQmCC" ) VIDEO_CONTENT_TYPES = ['2k', '4k', 'hd'] # DEFAULT_MODES = [ { u'id':u'mode0',u'type':u'disarmed' }, { u'id':u'mode1',u'type':u'armed' } ] DEFAULT_MODES = {"disarmed": "mode0", "armed": "mode1"} DEFAULT_RESOURCES = {"modes", "siren", "doorbells", "lights", "cameras", "devices", "sensors"} # MODEL PREFIXES MODEL_HUB = "SH1001" MODEL_HD = "VMC3030" MODEL_PRO_2 = "VMC4030" MODEL_PRO_3 = "VMC4040" MODEL_PRO_4 = "VMC4041" MODEL_PRO_5 = "VMC4060" MODEL_PRO_3_FLOODLIGHT = "FB1001" MODEL_ULTRA = "VMC5040" MODEL_BABY = "ABC1000" MODEL_ESSENTIAL_SPOTLIGHT = "VMC2030" MODEL_ESSENTIAL_XL_SPOTLIGHT = "VMC2032" MODEL_ESSENTIAL_INDOOR = "VMC2040" MODEL_ESSENTIAL_INDOOR_GEN2_2K = "VMC3060" MODEL_ESSENTIAL_INDOOR_GEN2_HD = "VMC2060" MODEL_ESSENTIAL_OUTDOOR_GEN2_2K = "VMC3050" MODEL_ESSENTIAL_OUTDOOR_GEN2_HD = "VMC2050" MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_2K = "VMC3052" MODEL_ESSENTIAL_XL_OUTDOOR_GEN2_HD = "VMC2052" MODEL_ESSENTIAL_VIDEO_DOORBELL = "AVD2001" MODEL_WIRED_VIDEO_DOORBELL = "AVD1001" MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K = "AVD4001" MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD = "AVD3001" MODEL_GO = "VML4030" MODEL_GO_2 = "VML2030" MODEL_ALL_IN_1_SENSOR = "MS1001" # The arlo agents are up the air. "arlo001" was recently deprecated so we're # trying a new one. USER_AGENTS = { "arlo": "(iPhone15,2 18_1_1) iOS Arlo 5.4.3", "arlo001": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_2 like Mac OS X) " "AppleWebKit/604.3.5 (KHTML, like Gecko) Mobile/15B202 NETGEAR/v1 " "(iOS Vuezone)", "iphone": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1", "ipad": "Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1", "mac": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_3) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15", "firefox": "Mozilla/5.0 (X11; Linux i686; rv:135.0) " "Gecko/20100101 Firefox/135.0", "linux": "Mozilla/5.0 (X11; Linux x86_64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", "android": "Mozilla/5.0 (Linux; U; Android 8.1.0; zh-cn; PACM00 Build/O11019) " "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.132 MQQBrowser/8.8 Mobile Safari/537.36" } CERT_BEGIN = '-----BEGIN CERTIFICATE-----\n' CERT_END = '-----END CERTIFICATE-----\n' ECDH_CURVES = ['secp384r1', 'prime256v1'] VALID_DEVICE_STATES = ["provisioned", "synced"] pyaarlo-0.8.0.15/pyaarlo/device.py000066400000000000000000000265761475374251700167310ustar00rootroot00000000000000from typing import TYPE_CHECKING from unidecode import unidecode if TYPE_CHECKING: from . import PyArlo from .constant import ( BATTERY_KEY, BATTERY_TECH_KEY, CHARGER_KEY, CHARGING_KEY, CONNECTION_KEY, CONNECTIVITY_KEY, DEVICE_KEYS, RESOURCE_KEYS, RESOURCE_UPDATE_KEYS, SIGNAL_STR_KEY, TIMEZONE_KEY, XCLOUD_ID_KEY, ) from .super import ArloSuper class ArloDevice(ArloSuper): """Base class for all Arlo devices. Has code to handle providing common attributes and comment event handling. """ def __init__(self, name, arlo: 'PyArlo', attrs): super().__init__(name, arlo, attrs, id=attrs.get("deviceId", "unknown"), type=attrs.get("deviceType", "unknown"), uid=attrs.get("uniqueId", None)) # We save this here but only expose it directly in the ArloChild class. # Some devices are their own parents and we need to know that at the ArloDevice # or ArloChild class so we leave this here as a short cut. self._parent_id = attrs.get("parentId", None) # Activities. Used by camera for now but made available to all. self._activities = {} # Build initial values. These can be at the top level or in the # properties dictionary. for key in DEVICE_KEYS: value = attrs.get(key, None) if value is not None: self._save(key, value) props = attrs.get("properties", {}) for key in RESOURCE_KEYS + RESOURCE_UPDATE_KEYS: value = props.get(key, None) if value is not None: self._save(key, value) @property def entity_id(self): if self._arlo.cfg.serial_ids: return self.device_id elif self._arlo.cfg.no_unicode_squash: return self.name.lower().replace(" ", "_") else: return unidecode(self.name.lower().replace(" ", "_")) @property def resource_id(self): """Returns the resource id, used for making requests and checking responses. For base stations has the format [DEVICE-ID] and for other devices has the format [RESOURCE-TYPE]/[DEVICE-ID] """ return self.device_id @property def resource_type(self): """Returns the type of resource this is. For now it's, `cameras`, `doorbells`, `lights` or `basestations`. """ return None @property def serial_number(self): """Returns the device serial number.""" return self.device_id @property def model_id(self): """Returns the model id.""" return self._attrs.get("modelId", None) @property def hw_version(self): """Returns the hardware version.""" return self._attrs.get("properties", {}).get("hwVersion", None) @property def timezone(self): """Returns the timezone.""" time_zone = self._load(TIMEZONE_KEY, None) if time_zone is None: return self._attrs.get("properties", {}).get("olsonTimeZone", None) return time_zone @property def user_id(self): """Returns the user id.""" return self._attrs.get("userId", None) @property def user_role(self): """Returns the user role.""" return self._attrs.get("userRole", None) @property def xcloud_id(self): """Returns the device's xcloud id.""" return self._load(XCLOUD_ID_KEY, "UNKNOWN") @property def web_id(self): """Return the device's web id.""" return self.user_id + "_web" @property def is_own_parent(self): """Returns True if device is its own parent. Can work from child or parent class. """ return self._parent_id == self.device_id def attribute(self, attr, default=None): """Return the value of attribute attr. PyArlo stores its state in key/value pairs. This returns the value associated with the key. See PyArlo for a non-exhaustive list of attributes. :param attr: Attribute to look up. :type attr: str :param default: value to return if not found. :return: The value associated with attribute or `default` if not found. """ value = self._load(attr, None) if value is None: value = self._attrs.get(attr, None) if value is None: value = self._attrs.get("properties", {}).get(attr, None) if value is None: value = default return value def add_attr_callback(self, attr, cb): """Add an callback to be triggered when an attribute changes. Used to register callbacks to track device activity. For example, get a notification whenever motion stop and starts. See PyArlo for a non-exhaustive list of attributes. :param attr: Attribute - eg `motionStarted` - to monitor. :type attr: str :param cb: Callback to run. """ with self._lock: self._attr_cbs_.append((attr, cb)) def has_capability(self, cap): """Is the device capable of performing activity cap:. Used to determine if devices can perform certain actions, like motion or audio detection. See attribute list against PyArlo. :param cap: Attribute - eg `motionStarted` - to check. :return: `True` it is, `False` it isn't. """ if cap in (CONNECTION_KEY,): return True return False @property def state(self): """Returns a string describing the device's current state.""" return "idle" @property def is_on(self): """Returns `True` if the device is on, `False` otherwise.""" return True def turn_on(self): """Turn the device on.""" pass def turn_off(self): """Turn the device off.""" pass @property def is_unavailable(self): """Returns `True` if the device is unavailable, `False` otherwise. **Note:** Sorry about the double negative. """ return self._load(CONNECTION_KEY, "unknown") == "unavailable" @property def battery_level(self): """Returns the current battery level.""" return self._load(BATTERY_KEY, 100) @property def battery_tech(self): """Returns the current battery technology. Is it rechargable, wired... """ return self._load(BATTERY_TECH_KEY, "None") @property def has_batteries(self): """Returns `True` if device has batteries installed, `False` otherwise.""" return self.battery_tech != "None" @property def charger_type(self): """Returns how the device is recharging.""" return self._load(CHARGER_KEY, "None") @property def has_charger(self): """Returns `True` if the charger is plugged in, `False` otherwise.""" return self.charger_type != "None" @property def is_charging(self): """Returns `True` if the device is charging, `False` otherwise.""" return self._load(CHARGING_KEY, "off").lower() == "on" @property def is_charger_only(self): """Returns `True` if the cahrger is plugged in with no batteries, `False` otherwise.""" return self.battery_tech == "None" and self.has_charger @property def is_corded(self): """Returns `True` if the device is connected directly to a power outlet, `False` otherwise. The device can't have any battery option, it can't be using a charger, it has to run directly from a plug. ie, an original base station. """ return not self.has_batteries and not self.has_charger @property def using_wifi(self): """Returns `True` if the device is connected to the wifi, `False` otherwise. This means connecting directly to your home wifi, not connecting to and Arlo basestation. """ return self._attrs.get(CONNECTIVITY_KEY, {}).get("type", "").lower() == "wifi" @property def signal_strength(self): """Returns the WiFi signal strength (0-5).""" return self._load(SIGNAL_STR_KEY, 3) def debug(self, msg): self._arlo.debug(f"{self.device_id}: {msg}") def vdebug(self, msg): self._arlo.vdebug(f"{self.device_id}: {msg}") class ArloChildDevice(ArloDevice): """Base class for all Arlo devices that attach to a base station.""" def __init__(self, name, arlo, attrs): super().__init__(name, arlo, attrs) self.debug("parent is {}".format(self._parent_id)) self.vdebug("resource is {}".format(self.resource_id)) def _event_handler(self, resource, event): self.vdebug("{}: child got {} event **".format(self.name, resource)) if resource.endswith("/states"): self._arlo.bg.run(self.base_station.update_mode) return # Pass event to lower level. super()._event_handler(resource, event) @property def resource_type(self): """Return the resource type this child device describes. Currently limited to `camera`, `doorbell` and `light`. """ return "child" @property def resource_id(self): """Returns the child device resource id. Some devices - certain cameras - can provide other types. """ return self.resource_type + "/" + self.device_id @property def parent_id(self): """Returns the parent device id. **Note:** Some devices - ArloBaby for example - are their own parents. """ if self._parent_id is not None: return self._parent_id return self.device_id @property def timezone(self): """Returns the timezone. Tries to be clever. If it doesn't have a timezone it will try its basestation. """ time_zone = super().timezone if time_zone is None: return self.base_station.timezone return time_zone @property def base_station(self): """Returns the base station controlling this device. Some devices - ArloBaby for example - are their own parents. If we can't find a basestation, this returns the first one (if any exist). """ # look for real parents for base in self._arlo.base_stations: if base.device_id == self.parent_id: return base # some cameras don't have base stations... it's its own base station... for base in self._arlo.base_stations: if base.device_id == self.device_id: return base # no idea! if len(self._arlo.base_stations) > 0: return self._arlo.base_stations[0] self._arlo.error("Could not find any base stations for device " + self._name) return None @property def is_unavailable(self): if not self.base_station: return True return ( self.base_station.is_unavailable or self._load(CONNECTION_KEY, "unknown") == "unavailable" ) @property def too_cold(self): """Returns `True` if the device too cold to operate, `False` otherwise.""" return self._load(CONNECTION_KEY, "unknown") == "thermalShutdownCold" @property def state(self): if self.is_unavailable: return "unavailable" if not self.is_on: return "off" if self.too_cold: return "offline, too cold" return "idle" pyaarlo-0.8.0.15/pyaarlo/doorbell.py000066400000000000000000000172401475374251700172600ustar00rootroot00000000000000from .constant import ( BATTERY_KEY, BUTTON_PRESSED_KEY, CHIMES_KEY, CONNECTION_KEY, MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL, MOTION_DETECTED_KEY, SIGNAL_STR_KEY, SILENT_MODE_ACTIVE_KEY, SILENT_MODE_CALL_KEY, SILENT_MODE_KEY, SIREN_STATE_KEY, ) from .device import ArloChildDevice class ArloDoorBell(ArloChildDevice): def __init__(self, name, arlo, attrs): super().__init__(name, arlo, attrs) self._motion_time_job = None self._ding_time_job = None self._has_motion_detect = False self._chimes = {} def _motion_stopped(self): self._save_and_do_callbacks(MOTION_DETECTED_KEY, False) with self._lock: self._motion_time_job = None def _button_unpressed(self): self._save_and_do_callbacks(BUTTON_PRESSED_KEY, False) with self._lock: self._ding_time_job = None def _event_handler(self, resource, event): self.debug(self.name + " DOORBELL got one " + resource) # create fake motion/button press event... if resource == self.resource_id: props = event.get("properties", {}) # Newer doorbells send a motionDetected True followed by False. If we # see this then turn off connectionState checking. if props.get(MOTION_DETECTED_KEY, False): self.debug(self.name + " has motion detection support") self._has_motion_detect = True # Older doorbells signal a connectionState as available when motion # is detected. We check the properties length to not confuse it # with a device update. There is no motion stopped event so set a # timer to turn off the motion detect. if len(props) == 1 and not self._has_motion_detect: if props.get(CONNECTION_KEY, "") == "available": self._save_and_do_callbacks(MOTION_DETECTED_KEY, True) with self._lock: self._arlo.bg.cancel(self._motion_time_job) self._motion_time_job = self._arlo.bg.run_in( self._motion_stopped, self._arlo.cfg.db_motion_time ) # For button presses we only get a buttonPressed notification, not # a "no longer pressed" notification - set a timer to turn off the # press. if BUTTON_PRESSED_KEY in props: self._save_and_do_callbacks(BUTTON_PRESSED_KEY, True) with self._lock: self._arlo.bg.cancel(self._ding_time_job) self._ding_time_job = self._arlo.bg.run_in( self._button_unpressed, self._arlo.cfg.db_ding_time ) # Save out chimes if CHIMES_KEY in props: self._chimes = props[CHIMES_KEY] # Pass silent mode notifications so we can track them in the "ding" # entity. silent_mode = props.get(SILENT_MODE_KEY, {}) if silent_mode: self._save_and_do_callbacks(SILENT_MODE_KEY, silent_mode) # pass on to lower layer super()._event_handler(resource, event) @property def resource_type(self): return "doorbells" @property def is_video_doorbell(self): return self.model_id.startswith(( MODEL_WIRED_VIDEO_DOORBELL, MODEL_WIRED_VIDEO_DOORBELL_GEN2_HD, MODEL_WIRED_VIDEO_DOORBELL_GEN2_2K, MODEL_ESSENTIAL_VIDEO_DOORBELL )) def has_capability(self, cap): # Video Doorbells appear as both ArloCameras and ArloDoorBells, where # capabilities double up - eg, motion detection - we provide the # capability at the camera level. if cap in (MOTION_DETECTED_KEY, BATTERY_KEY, SIGNAL_STR_KEY, CONNECTION_KEY): return not self.is_video_doorbell if cap in (BUTTON_PRESSED_KEY, SILENT_MODE_KEY): return True return super().has_capability(cap) def update_silent_mode(self): """Requests the latest silent mode settings. Queues a job that requests the info from Arlo. """ self._arlo.be.notify( base=self.base_station, body={ "action": "get", "resource": self.resource_id, "publishResponse": False, }, ) def _build_chimes(self, on_or_off): chimes = {"traditionalChime": on_or_off} for chime in self._chimes: chimes[chime] = on_or_off return chimes def _silence(self, active, calls, chimes): # Build settings silence_settings = { SILENT_MODE_ACTIVE_KEY: active, SILENT_MODE_CALL_KEY: calls, } if chimes: silence_settings[CHIMES_KEY] = chimes # Build request properties = {SILENT_MODE_KEY: silence_settings} self.debug(self.name + " silence is " + str(properties)) # Send out request. response = self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": properties, "publishResponse": True, "resource": self.resource_id, }, wait_for="response", ) # Not none means a 200 so we assume it works until told otherwise. if response is not None: self._arlo.bg.run( self._save_and_do_callbacks, attr=SILENT_MODE_KEY, value=silence_settings, ) def silence_off(self): self._silence(False, False, {}) def silence_on(self): self._silence(True, True, self._build_chimes(True)) def silence_chimes(self): self._silence(True, False, self._build_chimes(True)) def silence_calls(self): self._silence(True, True, self._build_chimes(False)) @property def is_silenced(self): return self._load(SILENT_MODE_KEY, {}).get(SILENT_MODE_ACTIVE_KEY, False) @property def calls_are_silenced(self): return self._load(SILENT_MODE_KEY, {}).get(SILENT_MODE_CALL_KEY, False) @property def chimes_are_silenced(self): for on_or_off in self._load(SILENT_MODE_KEY, {}).get(CHIMES_KEY, {}).values(): if on_or_off is True: return True return False @property def _siren_resource_id(self): return "siren/{}".format(self.device_id) @property def siren_state(self): return self._load(SIREN_STATE_KEY, "off") def siren_on(self, duration=300, volume=8): """Turn camera siren on. Does nothing if camera doesn't support sirens. :param duration: how long, in seconds, to sound for :param volume: how long, from 1 to 8, to sound """ body = { "action": "set", "resource": self._siren_resource_id, "publishResponse": True, "properties": { "sirenState": "on", "duration": int(duration), "volume": int(volume), "pattern": "alarm", }, } self._arlo.be.notify(base=self, body=body) def siren_off(self): """Turn camera siren off. Does nothing if camera doesn't support sirens. """ body = { "action": "set", "resource": self._siren_resource_id, "publishResponse": True, "properties": {"sirenState": "off"}, } self._arlo.be.notify(base=self, body=body) pyaarlo-0.8.0.15/pyaarlo/light.py000066400000000000000000000050111475374251700165560ustar00rootroot00000000000000import pprint from .constant import BATTERY_KEY, BRIGHTNESS_KEY, LAMP_STATE_KEY, MOTION_DETECTED_KEY from .device import ArloChildDevice class ArloLight(ArloChildDevice): def __init__(self, name, arlo, attrs): """An Arlo Light. :param name: name of light :param arlo: controlling arlo instance :param attrs: initial attributes give by Arlo """ super().__init__(name, arlo, attrs) @property def resource_type(self): return "lights" def _event_handler(self, resource, event): self.debug(self.name + " LIGHT got one " + resource) # pass on to lower layer super()._event_handler(resource, event) @property def is_on(self): return self._load(LAMP_STATE_KEY, "off") == "on" def turn_on(self, brightness=None, rgb=None): """Turn the light on. :param brightness: how bright to make the light :param rgb: what color to make the light """ properties = {LAMP_STATE_KEY: "on"} if brightness is not None: properties[BRIGHTNESS_KEY] = brightness if rgb is not None: # properties["single"] = rgb_to_hex(rgb) pass self.debug("{} sending {}".format(self._name, pprint.pformat(properties))) self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": properties, "publishResponse": True, "resource": self.resource_id, }, ) return True def turn_off(self): """Turn the light off.""" self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": {LAMP_STATE_KEY: "off"}, "publishResponse": True, "resource": self.resource_id, }, ) return True def set_brightness(self, brightness): """Set the light brightness. :param brightness: brightness to use (0-255) """ self._arlo.be.notify( base=self.base_station, body={ "action": "set", "properties": {BRIGHTNESS_KEY: brightness}, "publishResponse": True, "resource": self.resource_id, }, ) return True def has_capability(self, cap): if cap in (MOTION_DETECTED_KEY, BATTERY_KEY): return True return super().has_capability(cap) pyaarlo-0.8.0.15/pyaarlo/location.py000066400000000000000000000140561475374251700172700ustar00rootroot00000000000000from .constant import ( MODE_ID_TO_NAME_KEY, MODE_KEY, MODE_NAME_TO_ID_KEY, LOCATION_MODES_PATH_FORMAT, LOCATION_ACTIVEMODE_PATH_FORMAT, MODE_REVISION_KEY ) from .super import ArloSuper AUTOMATION_ACTIVE_MODE = "automation/activeMode" AUTOMATION_MODES = "automation/modes" DEFAULT_MODES = { "standby": "Stand By", "armAway": "Armed Away", "armHome": "Armed Home" } def location_name(name, user): if user: return f"user_location_{name}" return f"location_{name}" class ArloLocation(ArloSuper): """ Represents a Location object. Each Arlo account can have multiple owned locations and multiple shared locations. """ def __init__(self, arlo, attrs, user=False): super().__init__(location_name(attrs.get("locationName", "unknown"), user), arlo, attrs, id=attrs.get("locationId", "unknown"), type="location") self._device_ids = attrs.get("gatewayDeviceIds", []) def _id_to_name(self, mode_id): return self._load([MODE_ID_TO_NAME_KEY, mode_id], None) def _name_to_id(self, mode_name): return self._load([MODE_NAME_TO_ID_KEY, mode_name], None) def _extra_headers(self): return { "x-forwarded-user": self._arlo.be.user_id, "x-user-device-id": self._arlo.be.user_id, } def _parse_modes(self, modes): for mode in modes.items(): mode_id = mode[0] mode_name = mode[1].get("name", "") if mode_id and mode_name != "": self.debug(mode_id + "<=M=>" + mode_name) self._save([MODE_ID_TO_NAME_KEY, mode_id], mode_name) self._save([MODE_NAME_TO_ID_KEY, mode_name], mode_id) def _event_handler(self, resource, event): self.debug(self.name + " LOCATION got " + resource) # A (user requested?) mode change. if resource == AUTOMATION_ACTIVE_MODE: props = event.get("properties", {}) mode = props.get("properties", {}).get("mode", None) if mode is not None: self._save_and_do_callbacks(MODE_KEY, mode) mode_revision = props.get("revision", None) if mode_revision is not None: self._save(MODE_REVISION_KEY, mode_revision) # A mode list update if resource == AUTOMATION_MODES: self._parse_modes(event.get("properties", {}).get("properties", {})) # A (user requested?) mode change. if resource == "states": mode = event.get("states", {}).get("activeMode", None) if mode is not None: self._save_and_do_callbacks(MODE_KEY, mode) @property def available_modes(self): """Returns string list of available modes. For example:: ``['disarmed', 'armed', 'home']`` """ return list(self.available_modes_with_ids.keys()) @property def available_modes_with_ids(self): """Returns dictionary of available modes mapped to Arlo ids. For example:: ``{'armed': 'mode1','disarmed': 'mode0','home': 'mode2'}`` """ modes = {} for key, mode_id in self._load_matching([MODE_NAME_TO_ID_KEY, "*"]): modes[key.split("/")[-1]] = mode_id if not modes: modes = DEFAULT_MODES return modes @property def device_ids(self): return self._device_ids @property def mode(self): """Returns the current mode.""" return self._load(MODE_KEY, "unknown") @mode.setter def mode(self, id_or_name): """Set the location mode. :param id_or_name: mode to use, as returned by available_modes: """ # Convert to an ID. mode_id = self._name_to_id(id_or_name) if mode_id is None: mode_id = id_or_name if mode_id is None: self._arlo.error("passed invalid id or name {id_or_name}") return # Need to change? if self.mode == mode_id: self.debug("no mode change needed") return # Post change. self.debug(f"new-mode={mode_id}({id_or_name})") mode_revision = self._load(MODE_REVISION_KEY, 1) self.vdebug(f"old-revision={mode_revision}") data = self._arlo.be.put( LOCATION_ACTIVEMODE_PATH_FORMAT.format(self._id) + f"&revision={mode_revision}", params={"mode": mode_id}, headers=self._extra_headers()) mode_revision = data.get("revision") self.vdebug(f"new-revision={mode_revision}") self._save_and_do_callbacks(MODE_KEY, mode_id) self._save(MODE_REVISION_KEY, mode_revision) @property def mode_name(self): """Returns the current mode using the Arlo friendly name.""" return self._id_to_name(self._load(MODE_KEY, "standby")) def update_mode(self): """Check and update the base's current mode.""" data = self._arlo.be.get(LOCATION_ACTIVEMODE_PATH_FORMAT.format(self._id), headers=self._extra_headers()) mode_id = data.get("properties", {}).get('mode') mode_revision = data.get("revision") self._save_and_do_callbacks(MODE_KEY, mode_id) self._save(MODE_REVISION_KEY, mode_revision) def update_modes(self, _initial=False): """Get and update the available modes for the base.""" modes = self._arlo.be.get(LOCATION_MODES_PATH_FORMAT.format(self._id), headers=self._extra_headers()) if modes is not None: self._parse_modes(modes.get("properties", {})) else: self._arlo.error("failed to read modes.") def stand_by(self): self.mode = "standby" @property def is_stand_by(self): return self.mode == "standby" def arm_home(self): self.mode = "armHome" @property def is_armed_home(self): return self.mode == "armHome" def arm_away(self): self.mode = "armAway" @property def is_armed_away(self): return self.mode == "armAway" pyaarlo-0.8.0.15/pyaarlo/main.py000066400000000000000000000302421475374251700163770ustar00rootroot00000000000000#!/usr/bin/env python3 # import base64 import io import logging import os import pickle import pprint import sys import click from . import PyArlo from .util import to_b64 logging.basicConfig(level=logging.ERROR, format='%(asctime)s:%(name)s:%(levelname)s: %(message)s') _LOGGER = logging.getLogger('pyaarlo') BEGIN_PYAARLO_DUMP = "-----BEGIN PYAARLO DUMP-----" END_PYAARLO_DUMP = "-----END PYAARLO DUMP-----" PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1oYXnbQPxREiVPUIRkgk h+ehjxHnwz34NsjhjgN1oSKmHpf4cL4L/V4tMnj5NELEmLyTrzAZbeewUMwyiwXO 3l+cSjjoDKcPBSj4uxjWsq74Q5TLHGjOtkFwaqqxtvsVn3fGFWBO405xpvp7jPUc BOvBQaUBUaR9Tbw5anMOzeavUwUTRp2rjtbWyj2P7PEp49Ixzw0w+RjIVrzzevAo AD7SVb6U8P77fht4k9krbIFckC/ByY48HhmF+edh1GZAgLCHuf43tGg2upuH5wf+ AGv/Xlc+9ScTjEp37uPiCpHcB1ur83AFTjcceDIm+VDKF4zQrj88zmL7JqZy+Upx UQIDAQAB -----END PUBLIC KEY-----""" opts = { "username": None, "password": None, "storage-dir": "./", "save-state": False, "dump-packets": False, "wait-for-initial-setup": True, "anonymize": False, "compact": False, "encrypt": False, "public-key": None, "private-key": "./rsa.private", "pass-phrase": None, "verbose": 0, } # where we store before encrypting or anonymizing _out = None # Arlo instance... _arlo = None def _debug(args): _LOGGER.debug("{}".format(args)) def _vdebug(args): if opts["verbose"] > 2: _debug(args) def _info(args): _LOGGER.info("{}".format(args)) def _fatal(args): sys.exit("FATAL-ERROR:{}".format(args)) def _print_start(): if opts['anonymize'] or opts['encrypt']: global _out _out = io.StringIO() def _print(msg): if _out is None: print("{}".format(msg)) else: _out.write(msg) _out.write("\n") def _pprint(msg, obj): _print("{}\n{}".format(msg, pprint.pformat(obj, indent=1))) def _print_end(): if _out is not None: _out.seek(0, 0) out_text = _out.read() if opts['anonymize']: out_text = anonymize_from_string(out_text) if opts['encrypt']: print(BEGIN_PYAARLO_DUMP) out_text = encrypt_to_string(out_text) print(out_text) if opts['encrypt']: print(END_PYAARLO_DUMP) def _casecmp(s1, s2): if s1 is None or s2 is None: return False return str(s1).lower() == str(s2).lower() def encrypt_to_string(obj): from Crypto.Cipher import AES from Crypto.Random import get_random_bytes from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP try: # pickle and resize object obj = pickle.dumps(obj) obj += b' ' * (16 - len(obj) % 16) # create key and encrypt pickled object with it key = get_random_bytes(16) aes_cipher = AES.new(key, AES.MODE_EAX) obj, tag = aes_cipher.encrypt_and_digest(obj) nonce = aes_cipher.nonce # encrypt key with public key if opts["public-key"] is None: rsa_cipher = RSA.importKey(PUBLIC_KEY) else: rsa_cipher = open(opts["public-key"], 'r').read() rsa_cipher = RSA.importKey(rsa_cipher) rsa_cipher = PKCS1_OAEP.new(rsa_cipher) key = rsa_cipher.encrypt(key) # create key/object dictionary, pickle and base64 encode key_obj = pickle.dumps({'k': key, 'n': nonce, 'o': obj, 't': tag}) return base64.encodebytes(key_obj).decode().rstrip() except ValueError as err: _fatal("encrypt error: {}".format(err)) except Exception: _fatal("unexpected encrypt error: {}".format(sys.exc_info()[0])) def decrypt_from_string(key_obj): from Crypto.Cipher import AES from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP try: # decode key/object dictionary then unpickle it key_obj = base64.b64decode(key_obj) key_obj = pickle.loads(key_obj) # import private key and decrypt nonce key = open(opts["private-key"], "r").read() rsa_cipher = RSA.importKey(key, passphrase=opts["pass-phrase"]) rsa_cipher = PKCS1_OAEP.new(rsa_cipher) key = rsa_cipher.decrypt(key_obj["k"]) # decrypt object and unpickle aes_cipher = AES.new(key, AES.MODE_EAX, nonce=key_obj['n']) obj = aes_cipher.decrypt_and_verify(key_obj['o'], key_obj['t']) obj = pickle.loads(obj) return obj except ValueError as err: _fatal("decrypt error {}".format(err)) except Exception: _fatal("unexpected decrypt error: {}".format(sys.exc_info()[0])) def anonymize_from_string(obj): # get device list keys = ['deviceId', 'uniqueId', 'userId', 'xCloudId'] valuables = {} for device in _arlo._devices: for key in keys: value = device.get(key, None) if value and value not in valuables: valuables[value] = "X" * len(value) owner_id = device.get('owner', {}).get('ownerId', None) if owner_id: valuables[owner_id] = "X" * len(owner_id) if opts["username"] is not None: valuables[opts["username"]] = "USERNAME" if opts["password"] is not None: valuables[to_b64(opts["password"])] = "PASSWORD" anon = obj for valuable in valuables: anon = anon.replace(valuable, valuables[valuable]) return anon def login(): _info("logging in") if opts["username"] is None or opts["password"] is None: _fatal("please supply a username and password") global _arlo _arlo = PyArlo(username=opts["username"], password=opts["password"], storage_dir=opts["storage-dir"], save_state=opts['save-state'], wait_for_initial_setup=opts['wait-for-initial-setup'], dump=opts['dump-packets'] ) if _arlo is None: _fatal("unable to login to Arlo") return _arlo def print_item(_name, item): if opts["compact"]: _print(" {};did={};mid={}/{};sno={}".format(item.name, item.device_id, item.model_id, item.hw_version, item.serial_number)) else: _print(" {}".format(item.name)) _print(" device-id:{}".format(item.device_id)) _print(" model-id:{}/{}".format(item.model_id, item.hw_version)) _print(" serial-number:{}".format(item.serial_number)) def list_items(name, items): _print("{}:".format(name)) if items is not None: for item in items: print_item(name, item) # list [all|cameras|bases] # describe device # capture [encrpyted|to-file|wait] # all devices logs events # encrypt [from-file|to-file] # encrypt # decrypt [from-file|to-file] # decrypt @click.group() @click.option('-u', '--username', required=False, help="Arlo username") @click.option('-p', '--password', required=False, help="Arlo password") @click.option('-a', '--anonymize/--no-anonymize', default=False, help="Anonimize ids") @click.option('-c', '--compact/--no-compact', default=False, help="Minimize lists") @click.option('-e', '--encrypt/--no-encrypt', default=False, help="Where possible, encrypt output") @click.option('-k', '--public-key', required=False, help="Public key for encryption") @click.option('-K', '--private-key', required=False, help="Private key for decryption") @click.option('-P', '--pass-phrase', required=False, help="Pass phrase for private key") @click.option('-s', '--storage-dir', default="./", show_default='current dir', help="Where to store Arlo state and packet dump") @click.option('-w', '--wait/--no-wait', default=True, help="Wait for all information to arrive starting up") @click.option("-v", "--verbose", count=True, help="Be chatty. More is more chatty!") def cli(username, password, anonymize, compact, encrypt, public_key, private_key, pass_phrase, storage_dir, wait, verbose): if username is not None: opts['username'] = username if password is not None: opts['password'] = password if anonymize is not None: opts['anonymize'] = anonymize if compact is not None: opts['compact'] = compact if encrypt is not None: opts['encrypt'] = encrypt if public_key is not None: opts['public-key'] = public_key if private_key is not None: opts['private-key'] = private_key if pass_phrase is not None: opts['pass-phrase'] = pass_phrase if storage_dir is not None: opts['storage-dir'] = storage_dir if wait is not None: opts['wait-for-initial-setup'] = wait if verbose is not None: opts['verbose'] = verbose if verbose == 0: _LOGGER.setLevel(logging.ERROR) if verbose == 1: _LOGGER.setLevel(logging.INFO) if verbose > 1: _LOGGER.setLevel(logging.DEBUG) @cli.command() @click.argument('item', default='all', type=click.Choice(['all', 'cameras', 'bases', 'lights', 'doorbells'], case_sensitive=False)) def dump(item): out = {} ar = login() if item == 'all': out = ar._devices _print_start() _pprint(item, out) _print_end() @cli.command() @click.argument('item', type=click.Choice(['all', 'cameras', 'bases', 'lights', 'doorbells'], case_sensitive=False)) def list(item): ar = login() _print_start() if item == "all" or item == "bases": list_items("bases", ar.base_stations) if item == "all" or item == "cameras": list_items("cameras", ar.cameras) if item == "all" or item == "lights": list_items("lights", ar.lights) if item == "all" or item == "doorbells": list_items("doorbells", ar.doorbells) _print_end() @cli.command() def encrypt(): in_text = sys.stdin.read() enc_text = encrypt_to_string(in_text).rstrip() print("{}\n{}\n{}".format(BEGIN_PYAARLO_DUMP, enc_text, END_PYAARLO_DUMP)) @cli.command() def decrypt(): lines = "" save_lines = False for line in sys.stdin.readlines(): if line.startswith(BEGIN_PYAARLO_DUMP): save_lines = True elif line.startswith(END_PYAARLO_DUMP): save_lines = False elif save_lines: lines += line if lines == "": _fatal('no encrypted input found') else: dec_text = decrypt_from_string(lines) print("{}".format(dec_text), end='') @cli.command() def anonymize(): login() in_text = sys.stdin.read() print(anonymize_from_string(in_text)) @cli.command() @click.option('-n', '--name', required=False, help='camera name') @click.option('-d', '--device-id', required=False, help='camera device id') @click.option('-f', '--start-ffmpeg/--no-start-ffmpeg', required=False, default=False, help='start ffmpeg for stream') @click.argument('action', type=click.Choice(['start-stream', 'stop-stream', 'last-thumbnail'], case_sensitive=False)) def camera(name, device_id, start_ffmpeg, action): camera = None ar = login() for c in ar.cameras: if _casecmp(c.name, name) or _casecmp(c.device_id, device_id): camera = c break if camera is None: print('cannot find camera') return 0 if action == 'start-stream': print('starting a stream') stream_url = camera.get_stream() if stream_url is None: print(' failed to start stream') return 0 print("stream-url={}".format(stream_url)) if start_ffmpeg: print('starting ffmpeg') os.system("mkdir video_dir") os.system("ffmpeg -i '{}' ".format(stream_url) + "-fflags flush_packets -max_delay 2 -flags -global_header " + "-hls_time 2 -hls_list_size 3 -vcodec copy -y video_dir/video.m3u8") elif action == 'stop-stream': pass elif action == 'last-thumbnail': last_thumbnail = camera.last_thumbnail if last_thumbnail: print("last-thumbnail={}".format(last_thumbnail)) else: print(' error getting thumbnail') def main_func(): cli() if __name__ == '__main__': cli() pyaarlo-0.8.0.15/pyaarlo/media.py000066400000000000000000000407131475374251700165360ustar00rootroot00000000000000import os import threading from datetime import datetime, timedelta from string import Template from slugify import slugify from .constant import ( LIBRARY_PATH, RATLS_LIBRARY_PATH, RATLS_DOWNLOAD_PATH, VIDEO_CONTENT_TYPES ) from .util import arlotime_strftime, arlotime_to_datetime, http_get, http_stream class ArloMediaDownloader(threading.Thread): def __init__(self, arlo, save_format): super().__init__() self._arlo = arlo self._save_format = save_format self._lock = threading.Condition() self._queue = [] self._stopThread = False self._downloading = False # noinspection PyPep8Naming def _output_name(self, media): """Calculate file name from media object. Uses `self._save_format` to work out the substitions. :param media: ArloMediaObject to download :return: The file name. """ when = arlotime_to_datetime(media.created_at) Y = str(when.year).zfill(4) m = str(when.month).zfill(2) d = str(when.day).zfill(2) H = str(when.hour).zfill(2) M = str(when.minute).zfill(2) S = str(when.second).zfill(2) F = f"{Y}-{m}-{d}" T = f"{H}:{M}:{S}" t = f"{H}-{M}-{S}" s = str(int(when.timestamp())).zfill(10) try: return ( Template(self._save_format).substitute( SN=media.camera.device_id, N=media.camera.name, NN=slugify(media.camera.name, separator='_'), Y=Y, m=m, d=d, H=H, M=M, S=S, F=F, T=T, t=t, s=s, ) + f".{media.extension}" ) except KeyError as _e: self._arlo.error(f"format error: {self._save_format}") return None def _download(self, media): """Download a single piece of media. :param media: ArloMediaObject to download :return: 1 if a file was downloaded, 0 if the file present and skipped or -1 if an error occured """ # Calculate name. save_file = self._output_name(media) if save_file is None: return -1 try: # See if it exists. os.makedirs(os.path.dirname(save_file), exist_ok=True) if not os.path.exists(save_file): # Download to temporary file before renaming it. self.debug(f"dowloading for {media.camera.name} --> {save_file}") save_file_tmp = f"{save_file}.tmp" media.download_video(save_file_tmp) os.rename(save_file_tmp, save_file) return 1 else: self.vdebug( f"skipping dowload for {media.camera.name} --> {save_file}" ) return 0 except OSError as _e: self._arlo.error(f"failed to download: {save_file}") return -1 def run(self): if self._save_format == "": self.debug("not starting downloader") return with self._lock: while not self._stopThread: media = None result = 0 if len(self._queue) > 0: media = self._queue.pop(0) self._downloading = True self._lock.release() if media is not None: result = self._download(media) self._lock.acquire() self._downloading = False # Nothing else to do then just wait. if len(self._queue) == 0: self.vdebug(f"waiting for media") self._lock.wait(60.0) # We downloaded a file so inject a small delay. elif result == 1: self._lock.wait(0.5) def queue_download(self, media): if self._save_format == "": return with self._lock: self._queue.append(media) if len(self._queue) == 1: self._lock.notify() def stop(self): if self._save_format == "": return with self._lock: self._stopThread = True self._lock.notify() self.join(10) @property def processing(self): with self._lock: return len(self._queue) > 0 or self._downloading def debug(self, msg): self._arlo.debug(f"media-downloader: {msg}") def vdebug(self, msg): self._arlo.vdebug(f"media-downloader: {msg}") class ArloMediaLibrary(object): """Arlo Library Media module implementation.""" def __init__(self, arlo): self._arlo = arlo self._lock = threading.Lock() self._load_cbs_ = [] self._count = 0 self._videos = [] self._video_keys = [] self._snapshots = {} self._base = None self._downloader = ArloMediaDownloader(arlo, self._arlo.cfg.save_media_to) self._downloader.name = "ArloMediaDownloader" self._downloader.daemon = True self._downloader.start() def __repr__(self): return "<{0}:{1}>".format(self.__class__.__name__, self._arlo.cfg.name) def _fetch_library(self, date_from, date_to): return self._arlo.be.post( LIBRARY_PATH, {"dateFrom": date_from, "dateTo": date_to} ) # grab recordings from last day, add to existing library if not there def update(self): self.debug("updating image library") # grab today's images date_to = datetime.today().strftime("%Y%m%d") data = self._fetch_library(date_to, date_to) # get current videos with self._lock: keys = self._video_keys # add in new images videos = [] snapshots = {} for video in data: # camera, skip if not found camera = self._arlo.lookup_camera_by_id(video.get("deviceId")) if not camera: continue # snapshots, use first found if video.get("reason", "") == "snapshot": if camera.device_id not in snapshots: self.debug(f"adding snapshot for {camera.name}") snapshots[camera.device_id] = ArloSnapshot( video, camera, self._arlo, camera.base_station ) continue content_type = video.get("contentType", "") # videos, add missing if content_type.startswith("video/") or content_type in VIDEO_CONTENT_TYPES: key = "{0}:{1}".format( camera.device_id, arlotime_strftime(video.get("utcCreatedDate")) ) if key in keys: self.vdebug(f"skipping {key} for {camera.name}") continue self.debug(f"adding {key} for {camera.name}") video = ArloVideo(video, camera, self._arlo, self._base) videos.append(video) self._downloader.queue_download(video) keys.append(key) # note changes and run callbacks with self._lock: self._count += 1 self._videos = videos + self._videos self._video_keys = keys self._snapshots = snapshots self.debug("update-count=" + str(self._count)) cbs = self._load_cbs_ self._load_cbs_ = [] # run callbacks with no locks held for cb in cbs: cb() def load(self): # set beginning and end days = self._arlo.cfg.library_days now = datetime.today() date_from = (now - timedelta(days=days)).strftime("%Y%m%d") date_to = now.strftime("%Y%m%d") self.debug("loading image library ({} days)".format(days)) # save videos for cameras we know about data = self._fetch_library(date_from, date_to) if data is None: self._arlo.warning("error loading the image library") return videos = [] keys = [] snapshots = {} for video in data: # Look for camera, skip if not found. camera = self._arlo.lookup_camera_by_id(video.get("deviceId")) if camera is None: key = "{0}:{1}".format( video.get("deviceId"), arlotime_strftime(video.get("utcCreatedDate")), ) self.vdebug("skipping {0}".format(key)) continue # snapshots, use first found if video.get("reason", "") == "snapshot": if camera.device_id not in snapshots: self.debug(f"adding snapshot for {camera.name}") snapshots[camera.device_id] = ArloSnapshot( video, camera, self._arlo, camera.base_station ) continue # videos, add all content_type = video.get("contentType", "") if content_type.startswith("video/") or content_type in VIDEO_CONTENT_TYPES: key = "{0}:{1}".format( video.get("deviceId"), arlotime_strftime(video.get("utcCreatedDate")), ) self.vdebug(f"adding {key} for {camera.name}") video = ArloVideo(video, camera, self._arlo, self._base) videos.append(video) self._downloader.queue_download(video) keys.append(key) continue # set update count, load() never runs callbacks with self._lock: self._count += 1 self._videos = videos self._video_keys = keys self._snapshots = snapshots self.debug("load-count=" + str(self._count)) def snapshot_for(self, camera): with self._lock: return self._snapshots.get(camera.device_id, None) @property def videos(self): with self._lock: return self._count, self._videos @property def count(self): with self._lock: return self._count def videos_for(self, camera): camera_videos = [] with self._lock: for video in self._videos: if camera.device_id == video.camera.device_id: camera_videos.append(video) return self._count, camera_videos def queue_update(self, cb): with self._lock: if not self._load_cbs_: self.debug("queueing image library update") self._arlo.bg.run_low_in(self.update, 2) self._load_cbs_.append(cb) def stop(self): self._downloader.stop() def debug(self, msg): self._arlo.debug(f"media-library: {msg}") def vdebug(self, msg): self._arlo.vdebug(f"media-library: {msg}") class ArloBaseStationMediaLibrary(ArloMediaLibrary): """Arlo Media Library for Base Stations""" def __init__(self, arlo, base): super().__init__(arlo) self._base = base def _fetch_library(self, date_from, date_to): list = [] # Fetch each page individually, since the base station still only return results for one date at a time for date in range(int(date_from), int(date_to) + 1): for camera in self._arlo.cameras: if camera.parent_id == self._base.device_id: # This URL is mysterious -- it won't return multiple days of videos data = self._base.ratls.get(f"{RATLS_LIBRARY_PATH}/{date}/{date}/{camera.device_id}") if data and "data" in data: list += data["data"] return list class ArloMediaObject(object): """Object for Arlo Video file.""" def __init__(self, attrs, camera, arlo, base): """Video Object.""" self._arlo = arlo self._attrs = attrs self._camera = camera self._base = base def __repr__(self): """Representation string of object.""" return "<{0}:{1}>".format(self.__class__.__name__, self.name) @property def name(self): return "{0}:{1}".format( self._camera.device_id, arlotime_strftime(self.created_at) ) # pylint: disable=invalid-name @property def id(self): """Returns unique id representing the video.""" return self._attrs.get("name", None) @property def created_at(self): """Returns date video was creaed.""" return self._attrs.get("utcCreatedDate", None) def created_at_pretty(self, date_format=None): """Returns date video was taken formated with `last_date_format`""" if date_format: return arlotime_strftime(self.created_at, date_format=date_format) return arlotime_strftime(self.created_at) @property def created_today(self): """Returns `True` if video was taken today, `False` otherwise.""" return self.datetime.date() == datetime.today().date() @property def datetime(self): """Returns a python datetime object of when video was created.""" return arlotime_to_datetime(self.created_at) @property def content_type(self): """Returns the video content type. Usually `video/mp4` """ return self._attrs.get("contentType", None) @property def extension(self): if self.content_type.endswith("mp4"): return "mp4" if self.content_type in VIDEO_CONTENT_TYPES: return "mp4" return "jpg" @property def camera(self): return self._camera @property def triggered_by(self): return self._attrs.get("reason", None) @property def url(self): """Returns the URL of the video.""" return self._attrs.get("presignedContentUrl", None) @property def thumbnail_url(self): """Returns the URL of the thumbnail image.""" return self._attrs.get("presignedThumbnailUrl", None) def download_thumbnail(self, filename=None): return http_get(self.thumbnail_url, filename) class ArloVideo(ArloMediaObject): """Object for Arlo Video file.""" def __init__(self, attrs, camera, arlo, base): """Video Object.""" super().__init__(attrs, camera, arlo, base) @property def media_duration_seconds(self): """Returns how long the recording last.""" return self._attrs.get("mediaDurationSecond", None) @property def object_type(self): """Returns what object caused the video to start. Currently is `vehicle`, `person`, `animal` or `other`. """ return self._attrs.get("objCategory", None) @property def object_region(self): """Returns the region of the thumbnail showing the object.""" return self._attrs.get("objRegion", None) @property def video_url(self): """Returns the URL of the video.""" return self._attrs.get("presignedContentUrl", None) def download_video(self, filename=None): video_url = self.video_url if self._base: video_url = f"{RATLS_DOWNLOAD_PATH}/{video_url}" response = self._base.ratls.get(video_url, raw=True) if response is None: return False with open(filename, "wb") as data: data.write(response.read()) return True else: return http_get(video_url, filename) @property def created_at(self): """Returns date video was creaed, adjusted to ms""" timestamp = super().created_at if self._base: if timestamp: return timestamp * 1000 return None return timestamp @property def stream_video(self): if self._base: response = self._base.ratls.get(f"{RATLS_DOWNLOAD_PATH}/{self.video_url}", raw=True) response.raise_for_status() for data in response.iter_content(4096): yield data else: http_stream(self.video_url) class ArloSnapshot(ArloMediaObject): """Object for Arlo Snapshot file.""" def __init__(self, attrs, camera, arlo, base): """Snapshot Object.""" super().__init__(attrs, camera, arlo, base) @property def image_url(self): """Returns the URL of the video.""" return self._attrs.get("presignedContentUrl", None) # vim:sw=4:ts=4:et: pyaarlo-0.8.0.15/pyaarlo/ratls.py000066400000000000000000000124441475374251700166040ustar00rootroot00000000000000import ssl import json import os from urllib.request import Request, build_opener, HTTPSHandler from .security_utils import SecurityUtils from .constant import ( RATLS_CONNECTIVITY_PATH, RATLS_TOKEN_GENERATE_PATH, CREATE_DEVICE_CERTS_PATH ) class ArloRatls(object): def __init__(self, arlo, base, public=False): self._base_connection_details = None self._base_station_token = None self._arlo = arlo self._base = base self._public = public self._unique_id = base.unique_id self._device_id = base.device_id self._security = SecurityUtils(arlo.cfg.storage_dir) self._check_device_certs() self.open_port() def open_port(self): """ RATLS port will automatically close after 10 minutes """ self._base_station_token = self._get_station_token() self._arlo.debug(f"Opening port for {self._unique_id}") response = self._arlo.be.notify( self._base, { "action": "open", "resource": "storage/ratls", "from": self._base.user_id, "publishResponse": True }, wait_for="event" ) if response is None or not response['success']: raise Exception(f"Failed to open ratls port: {response}") self._base_connection_details = response['properties'] self._setup_base_client() response = self.get(RATLS_CONNECTIVITY_PATH) if response is None or not response['success']: raise Exception(f"Failed to gain connectivity to ratls!") return self._base_connection_details def get(self, path, raw=False): request = Request(f"{self.url}{path}") request.get_method = lambda: 'GET' for (k, v) in self._ratls_req_headers().items(): request.add_header(k, v) try: response = self._base_client.open(request) if raw: return response return json.loads(response.read()) except Exception as e: self._arlo.warning("request-error={}".format(type(e).__name__)) return None def _ratls_req_headers(self): return { "Authorization": f"Bearer {self._base_station_token}", "Accept": "application/json; charset=utf-8;", "Accept-Language": "en-US,en;q=0.9", "Origin": "https://my.arlo.com", "SchemaVersion": "1", "User-Agent": self._arlo.be.user_agent(self._arlo.cfg.user_agent) } def _get_station_token(self): """ Tokens expire after 10 minutes """ self._arlo.debug(f"Fetching token for {self._device_id}") response = self._arlo.be.get( RATLS_TOKEN_GENERATE_PATH + f"/{self._device_id}" ) if response is None or not 'ratlsToken' in response: raise Exception(f"Failed get station token: {response}") return response['ratlsToken'] def _setup_base_client(self): certs_path = self._security.certs_path device_certs_path = self._security.device_certs_path(self._unique_id) self._sslcontext = ssl.create_default_context(cafile=os.path.join(certs_path, "ica.crt")) # We are providing certs for the base station to trust us self._sslcontext.load_cert_chain(os.path.join(device_certs_path, "peer.crt"), self._security.private_key_path) # ... but we cannot validate the base station's certificate self._sslcontext.check_hostname = False self._sslcontext.verify_mode = ssl.CERT_NONE self._base_client = build_opener(HTTPSHandler(context=self._sslcontext)) def _check_device_certs(self): self._arlo.debug(f"Checking for existing certificates for {self._unique_id}") if not self._security.has_device_certs(self._unique_id): response = self._arlo.be.post( CREATE_DEVICE_CERTS_PATH, params={ "uuid": self._device_id, "uniqueIds": [ self._unique_id ], "publicKey": self._security.public_key.replace("\n", "").replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", ""), }, headers={"xcloudId": self._base.xcloud_id}, raw=True ) if not response["success"]: raise Exception(f"Error getting certs: {response['message']} - {response['reason']}") self._arlo.debug(f"Saving certificates for {self._unique_id}") self._security.save_device_certs(self._unique_id, response["data"]) @property def security(self): return self._security @property def url(self): if self._public: return self.publicUrl else: return self.privateUrl @property def privateIp(self): return self._base_connection_details['privateIP'] @property def publicIp(self): return self._base_connection_details['publicIP'] @property def port(self): return self._base_connection_details['port'] @property def privateUrl(self): return f"https://{self.privateIp}:{self.port}" @property def publicUrl(self): return f"https://{self.publicIp}:{self.port}" pyaarlo-0.8.0.15/pyaarlo/security_utils.py000066400000000000000000000073311475374251700205450ustar00rootroot00000000000000from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend import os import textwrap from .constant import CERT_BEGIN, CERT_END class SecurityUtils(object): def __init__(self, storage_dir: str) -> None: self.__storage_dir = storage_dir self.__private_key: str = None self.__public_key: str = None if not self.__load_keys(): self.__generate_keypair() @property def public_key(self) -> str: if self.__public_key is None: self.__generate_keypair() return self.__public_key @property def private_key(self) -> str: if self.__private_key is None: self.__generate_keypair() return self.__private_key @property def public_key_path(self) -> str: return os.path.join(self.__storage_dir, "certs", "public.pem") @property def private_key_path(self) -> str: return os.path.join(self.__storage_dir, "certs", "private.pem") def __load_keys(self) -> bool: if os.path.exists(self.private_key_path) and os.path.exists(self.public_key_path): self.__private_key = open(self.private_key_path).read() self.__public_key = open(self.public_key_path).read() return True return False def __generate_keypair(self): # generate private/public key pair key = rsa.generate_private_key(backend=default_backend(), public_exponent=65537, \ key_size=2048) # get private key in PEM container format pem = key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption()) pub_pem = key.public_key().public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) # decode to printable strings self.__private_key = pem.decode('utf-8') self.__public_key = pub_pem.decode('utf-8') os.makedirs(os.path.join(self.__storage_dir, "certs"), exist_ok=True) with open(self.public_key_path, "w") as public_key_file: public_key_file.write(self.__public_key) with open(self.private_key_path, "w") as private_key_file: private_key_file.write(self.__private_key) @property def certs_path(self) -> str: return os.path.join(self.__storage_dir, "certs") def device_certs_path(self, base_station_id: str) -> str: return os.path.join(self.__storage_dir, "certs", base_station_id) def has_device_certs(self, base_station_id: str) -> bool: return os.path.exists(os.path.join(self.device_certs_path(base_station_id), "peer.crt")) def save_device_certs(self, base_station_id: str, certs): device_cert = certs['certsData'][0]["deviceCert"] peer_cert = certs["certsData"][0]["peerCert"] ica_cert = certs["icaCert"] device_cert = textwrap.fill(device_cert, width=64) + '\n' peer_cert = textwrap.fill(peer_cert, width=64) + '\n' ica_cert = textwrap.fill(ica_cert, width=64) + '\n' os.makedirs(os.path.join(self.device_certs_path(base_station_id)), exist_ok=True) with open(os.path.join(self.device_certs_path(base_station_id), "device.crt"), 'w') as device_file: device_file.writelines([CERT_BEGIN, device_cert, CERT_END]) with open(os.path.join(self.device_certs_path(base_station_id), "peer.crt"), 'w') as peer_file: peer_file.writelines([CERT_BEGIN, peer_cert, CERT_END]) with open(os.path.join(self.__storage_dir, "certs", "ica.crt"), 'w') as ica_file: ica_file.writelines([CERT_BEGIN, ica_cert, CERT_END]) with open(os.path.join(self.device_certs_path(base_station_id), "combined.crt"), 'w') as combined_file: combined_file.writelines([CERT_BEGIN, peer_cert, CERT_END, CERT_BEGIN, ica_cert, CERT_END]) pyaarlo-0.8.0.15/pyaarlo/sensor.py000066400000000000000000000032451475374251700167670ustar00rootroot00000000000000 from .constant import ( ALS_STATE_KEY, BATTERY_KEY, CONTACT_STATE_KEY, MOTION_DETECTED_KEY, MOTION_STATE_KEY, TAMPER_STATE_KEY, TEMPERATURE_KEY, WATER_STATE_KEY, ) from .device import ArloChildDevice class ArloSensor(ArloChildDevice): def __init__(self, name, arlo, attrs): """An Arlo All-in-One Sensor. Currently we handle light level, battery, open/close, motion, tamper, temperature and water states. :param name: name of sensor :param arlo: controlling arlo instance :param attrs: initial attributes give by Arlo """ super().__init__(name, arlo, attrs) @property def resource_type(self): return "sensors" def _event_handler(self, resource, event): self.debug(self.name + " SENSOR got one " + resource) # pass on to lower layer super()._event_handler(resource, event) @property def has_motion(self): return self._load(MOTION_STATE_KEY, False) @property def is_open(self): return self._load(CONTACT_STATE_KEY, False) @property def is_wet(self): return self._load(WATER_STATE_KEY, False) @property def is_low_light(self): return self._load(ALS_STATE_KEY, False) @property def is_being_tampered_with(self): return self._load(TAMPER_STATE_KEY, False) @property def temperature(self): return self._load(TEMPERATURE_KEY, None) def has_capability(self, cap): if cap in (ALS_STATE_KEY, BATTERY_KEY, CONTACT_STATE_KEY, MOTION_DETECTED_KEY, TAMPER_STATE_KEY, TEMPERATURE_KEY): return True return False pyaarlo-0.8.0.15/pyaarlo/sseclient.py000066400000000000000000000142261475374251700174500ustar00rootroot00000000000000import codecs import http.client import re import time import warnings import requests # Technically, we should support streams that mix line endings. This regex, # however, assumes that a system will provide consistent line endings. end_of_field = re.compile(r"\r\n\r\n|\r\r|\n\n") class SSEClient(object): def __init__( self, log, url, last_id=None, retry=3000, session=None, chunk_size=1024, reconnect_cb=None, **kwargs ): self.log = log self.url = url self.last_id = last_id self.retry = retry self.chunk_size = chunk_size self.running = True self.reconnect_cb = reconnect_cb # Optional support for passing in a requests.Session() self.session = session # Any extra kwargs will be fed into the requests.get call later. self.requests_kwargs = kwargs # The SSE spec requires making requests with Cache-Control: nocache if "headers" not in self.requests_kwargs: self.requests_kwargs["headers"] = {} self.requests_kwargs["headers"]["Cache-Control"] = "no-cache" # The 'Accept' header is not required, but explicit > implicit self.requests_kwargs["headers"]["Accept"] = "text/event-stream" # Remove these. self.requests_kwargs["headers"]["Content-Type"] = None self.requests_kwargs["headers"]["host"] = None # Keep data here as it streams in self.buf = u"" self._connect() def stop(self): self.running = False def disconnect(self): self.running = False def _connect(self): if self.last_id: self.requests_kwargs["headers"]["Last-Event-ID"] = self.last_id # Use session if set. Otherwise fall back to requests module. requester = self.session or requests self.resp = requester.get(self.url, stream=True, **self.requests_kwargs) self.resp_iterator = self.resp.iter_content(chunk_size=self.chunk_size) # TODO: Ensure we're handling redirects. Might also stick the 'origin' # attribute on Events like the Javascript spec requires. self.resp.raise_for_status() def _event_complete(self): return re.search(end_of_field, self.buf) is not None def __iter__(self): return self def __next__(self): decoder = codecs.getincrementaldecoder(self.resp.encoding)(errors="replace") while not self._event_complete(): try: next_chunk = next(self.resp_iterator) if not next_chunk: raise EOFError() self.buf += decoder.decode(next_chunk) except ( StopIteration, requests.RequestException, EOFError, http.client.IncompleteRead, ) as e: if not self.running: self.debug("stopping #1") return None self.debug("error={}".format(type(e).__name__)) time.sleep(self.retry / 1000.0) self._connect() # signal up! if self.reconnect_cb: self.reconnect_cb() # The SSE spec only supports resuming from a whole message, so # if we have half a message we should throw it out. head, sep, tail = self.buf.rpartition("\n") self.buf = head + sep continue if not self.running: self.debug("stopping #2") return None # Split the complete event (up to the end_of_field) into event_string, # and retain anything after the current complete event in self.buf # for next time. (event_string, self.buf) = re.split(end_of_field, self.buf, maxsplit=1) msg = Event.parse(event_string) # If the server requests a specific retry delay, we need to honor it. if msg.retry: self.retry = msg.retry # last_id should only be set if included in the message. It's not # forgotten if a message omits it. if msg.id: self.last_id = msg.id return msg def debug(self, msg): self.log.debug(f"sseclient: {msg}") class Event(object): sse_line_pattern = re.compile("(?P[^:]*):?( ?(?P.*))?") def __init__(self, data="", event="message", id=None, retry=None): self.data = data self.event = event self.id = id self.retry = retry def dump(self): lines = [] if self.id: lines.append("id: %s" % self.id) # Only include an event line if it's not the default already. if self.event != "message": lines.append("event: %s" % self.event) if self.retry: lines.append("retry: %s" % self.retry) lines.extend("data: %s" % d for d in self.data.split("\n")) return "\n".join(lines) + "\n\n" @classmethod def parse(cls, raw): """ Given a possibly-multiline string representing an SSE message, parse it and return a Event object. """ msg = cls() for line in raw.splitlines(): m = cls.sse_line_pattern.match(line) if m is None: # Malformed line. Discard but warn. warnings.warn('Invalid SSE line: "%s"' % line, SyntaxWarning) continue name = m.group("name") if name == "": # line began with a ":", so is a comment. Ignore continue value = m.group("value") if name == "data": # If we already have some data, then join to it with a newline. # Else this is it. if msg.data: msg.data = "%s\n%s" % (msg.data, value) else: msg.data = value elif name == "event": msg.event = value elif name == "id": msg.id = value elif name == "retry": msg.retry = int(value) return msg def __str__(self): return self.data pyaarlo-0.8.0.15/pyaarlo/storage.py000066400000000000000000000043231475374251700171200ustar00rootroot00000000000000import fnmatch import pickle import pprint import threading class ArloStorage(object): def __init__(self, arlo): self._arlo = arlo self._state_file = self._arlo.cfg.state_file self.db = {} self.lock = threading.Lock() self.load() def _ekey(self, key): return key if not isinstance(key, list) else "/".join(key) def _keys_matching(self, key): mkeys = [] ekey = self._ekey(key) for mkey in self.db: if fnmatch.fnmatch(mkey, ekey): mkeys.append(mkey) return mkeys def load(self): if self._state_file is not None: try: with self.lock: with open(self._state_file, "rb") as dump: self.db = pickle.load(dump) except Exception: self._arlo.debug("storage: file not read") def save(self): if self._state_file is not None: try: with self.lock: with open(self._state_file, "wb") as dump: pickle.dump(self.db, dump) except Exception: self._arlo.warning("storage: file not written") def file_name(self): return self._state_file def get(self, key, default=None): with self.lock: ekey = self._ekey(key) return self.db.get(ekey, default) def get_matching(self, key, default=None): with self.lock: gets = [] for mkey in self._keys_matching(key): gets.append((mkey, self.db.get(mkey, default))) return gets def keys_matching(self, key): with self.lock: return self._keys_matching(key) def set(self, key, value, prefix=""): ekey = self._ekey(key) output = "set:" + ekey + "=" + str(value) self._arlo.debug(f"{prefix}: {output[:80]}") with self.lock: self.db[ekey] = value return value def unset(self, key): with self.lock: del self.db[self._ekey(key)] def clear(self): with self.lock: self.db = {} def dump(self): with self.lock: pprint.pprint(self.db) pyaarlo-0.8.0.15/pyaarlo/super.py000066400000000000000000000120771475374251700166170ustar00rootroot00000000000000import threading from typing import TYPE_CHECKING from unidecode import unidecode if TYPE_CHECKING: from . import PyArlo from .constant import ( RESOURCE_KEYS, RESOURCE_UPDATE_KEYS, ) class ArloSuper(object): """Object class for all Arlo objects. Has code for: - attribute handling - event handling - callback/monitoring handling The only guaranteed pieces are: name: the object name device_id: the object id device_type: the object type unique_id: usually the device id with a GUI style prefix ArloLocation is the odd piece out, Arlo doesn't supply a device type or unique_id for this Object so we create one. """ def __init__(self, name, arlo: 'PyArlo', attrs, id, type, uid=None): self._name = name self._arlo = arlo self._attrs = attrs self._id = id self._type = type self._uid = uid self._lock = threading.Lock() self._attr_cbs_ = [] # add a listener self._arlo.be.add_listener(self, self._event_handler) def __repr__(self): # Representation string of object. return f"<{self.__class__.__name__}:{self.device_type}:{self.name}>" def _to_storage_key(self, attr): # Build a key incorporating the type! if isinstance(attr, list): return [self.__class__.__name__, self._id] + attr else: return [self.__class__.__name__, self._id, attr] def _event_handler(self, resource, event): self.vdebug(f"{self._name}: object got {resource} event") # Find properties. Event either contains a item called properties or it # is the whole thing. self.update_resources(event.get("properties", event)) def _do_callbacks(self, attr, value): cbs = [] with self._lock: for watch, cb in self._attr_cbs_: if watch == attr or watch == "*": cbs.append(cb) for cb in cbs: cb(self, attr, value) def _save(self, attr, value): self._arlo.st.set(self._to_storage_key(attr), value, prefix=self._id) def _save_and_do_callbacks(self, attr, value): if value != self._load(attr): self._save(attr, value) self._do_callbacks(attr, value) self.debug(f"{attr}: NEW {str(value)[:80]}") else: self.vdebug(f"{attr}: OLD {str(value)[:80]}") def _load(self, attr, default=None): return self._arlo.st.get(self._to_storage_key(attr), default) def _load_matching(self, attr, default=None): return self._arlo.st.get_matching(self._to_storage_key(attr), default) @property def name(self): """Returns the device name.""" return self._name @property def device_id(self): """Returns the device id.""" return self._id @property def device_type(self): """Returns the device id.""" return self._type @property def entity_id(self): if self._arlo.cfg.serial_ids: return self.device_id elif self._arlo.cfg.no_unicode_squash: return self.name.lower().replace(" ", "_") else: return unidecode(self.name.lower().replace(" ", "_")) @property def unique_id(self): """Returns the unique name.""" if self._uid is None: self._uid = f"{self._type}-{self._id}" return self._uid def update_resources(self, props): for key in RESOURCE_KEYS + RESOURCE_UPDATE_KEYS: value = props.get(key, None) if value is not None: self._save_and_do_callbacks(key, value) def attribute(self, attr, default=None): """Return the value of attribute attr. PyArlo stores its state in key/value pairs. This returns the value associated with the key. See PyArlo for a non-exhaustive list of attributes. :param attr: Attribute to look up. :type attr: str :param default: value to return if not found. :return: The value associated with attribute or `default` if not found. """ value = self._load(attr, None) if value is None: value = self._attrs.get(attr, None) if value is None: value = self._attrs.get("properties", {}).get(attr, None) if value is None: value = default return value def add_attr_callback(self, attr, cb): """Add an callback to be triggered when an attribute changes. Used to register callbacks to track device activity. For example, get a notification whenever motion stop and starts. See PyArlo for a non-exhaustive list of attributes. :param attr: Attribute - eg `motionStarted` - to monitor. :type attr: str :param cb: Callback to run. """ with self._lock: self._attr_cbs_.append((attr, cb)) @property def state(self): return "ok" def debug(self, msg): self._arlo.debug(f"{self._name}: {msg}") def vdebug(self, msg): self._arlo.vdebug(f"{self._name}: {msg}") pyaarlo-0.8.0.15/pyaarlo/tfa.py000066400000000000000000000172061475374251700162320ustar00rootroot00000000000000import email import imaplib import re import time import ssl import requests class Arlo2FAConsole: """2FA authentication via console. Accepts input from console and returns that for 2FA. """ def __init__(self, arlo): self._arlo = arlo def start(self): self.debug("starting") return True def get(self): self.debug("checking") return input("Enter Code: ") def stop(self): self.debug("stopping") def debug(self, msg): self._arlo.debug(f"2fa-console: {msg}") class Arlo2FAImap: """2FA authentication via IMAP Connects to IMAP server and waits for email from Arlo with 2FA code in it. Note: will probably need tweaking for other IMAP setups... """ def __init__(self, arlo): self._arlo = arlo self._imap = None self._old_ids = None self._new_ids = None def start(self): self.debug("starting") # clean up if self._imap is not None: self.stop() try: # allow default ciphers to be specified cipher_list = self._arlo.cfg.cipher_list if cipher_list != "": ctx = ssl.create_default_context() ctx.set_ciphers(cipher_list) self.debug(f"imap is using custom ciphers {cipher_list}") else: ctx = None self._imap = imaplib.IMAP4_SSL(self._arlo.cfg.tfa_host, port=self._arlo.cfg.tfa_port, ssl_context=ctx) if self._arlo.cfg.verbose: self._imap.debug = 4 res, status = self._imap.login( self._arlo.cfg.tfa_username, self._arlo.cfg.tfa_password ) if res.lower() != "ok": self.debug("imap login failed") return False res, status = self._imap.select(mailbox='INBOX', readonly=True) if res.lower() != "ok": self.debug("imap select failed") return False res, self._old_ids = self._imap.search( None, "FROM", "do_not_reply@arlo.com" ) if res.lower() != "ok": self.debug("imap search failed") return False except Exception as e: self._arlo.error(f"imap connection failed{str(e)}") return False self._new_ids = self._old_ids self.debug("old-ids={}".format(self._old_ids)) if res.lower() == "ok": return True return False def get(self): self.debug("checking") # give tfa_total_timeout seconds for email to arrive start = time.time() while True: # wait a short while, stop after a total timeout # ok to do on first run gives email time to arrive time.sleep(self._arlo.cfg.tfa_timeout) if time.time() > (start + self._arlo.cfg.tfa_total_timeout): return None try: # grab new email ids self._imap.check() res, self._new_ids = self._imap.search( None, "FROM", "do_not_reply@arlo.com" ) self.debug("new-ids={}".format(self._new_ids)) if self._new_ids == self._old_ids: self.debug("no change in emails") continue # New message. Reverse so we look at the newest one first. old_ids = self._old_ids[0].split() msg_ids = self._new_ids[0].split() msg_ids.reverse() for msg_id in msg_ids: # Seen it? if msg_id in old_ids: continue # New message. Look at all the parts and try to grab the code, if we catch an exception # just move onto the next part. self.debug("new-msg={}".format(msg_id)) res, parts = self._imap.fetch(msg_id, "(BODY.PEEK[])") # res, parts = self._imap.fetch(msg_id, "(RFC822)") for msg in parts: try: if isinstance(msg[1], bytes): for part in email.message_from_bytes(msg[1]).walk(): if part.get_content_type() != "text/html": continue for line in part.get_payload(decode=True).splitlines(): # match code in email, this might need some work if the email changes code = re.match(r"^\W+(\d{6})\W*$", line.decode()) if code is not None: self.debug(f"code={code.group(1)}") return code.group(1) except Exception as e: self.debug(f"trying next part {str(e)}") # Update old so we don't keep trying new. # Yahoo can lose ids so we extend the old list. self._old_ids.extend(new_id for new_id in self._new_ids if new_id not in self._old_ids) # problem parsing the message, force a fail except Exception as e: self._arlo.error(f"imap message read failed{str(e)}") return None return None def stop(self): self.debug("stopping") self._imap.close() self._imap.logout() self._imap = None self._old_ids = None self._new_ids = None def debug(self, msg): self._arlo.debug(f"2fa-imap: {msg}") class Arlo2FARestAPI: """2FA authentication via rest API. Queries web site until code appears """ def __init__(self, arlo): self._arlo = arlo def start(self): self.debug("starting") if self._arlo.cfg.tfa_host is None or self._arlo.cfg.tfa_password is None: self.debug("invalid config") return False self.debug("clearing") response = requests.get( "{}/clear?email={}&token={}".format( self._arlo.cfg.tfa_host_with_scheme("https"), self._arlo.cfg.tfa_username, self._arlo.cfg.tfa_password, ), timeout=10, ) if response.status_code != 200: self.debug("possible problem clearing") return True def get(self): self.debug("checking") # give tfa_total_timeout seconds for email to arrive start = time.time() while True: # wait a short while, stop after a total timeout # ok to do on first run gives email time to arrive time.sleep(self._arlo.cfg.tfa_timeout) if time.time() > (start + self._arlo.cfg.tfa_total_timeout): return None # Try for the token. self.debug("checking") response = requests.get( "{}/get?email={}&token={}".format( self._arlo.cfg.tfa_host_with_scheme("https"), self._arlo.cfg.tfa_username, self._arlo.cfg.tfa_password, ), timeout=10, ) if response.status_code == 200: code = response.json().get("data", {}).get("code", None) if code is not None: self.debug("code={}".format(code)) return code self.debug("retrying") def stop(self): self.debug("stopping") def debug(self, msg): self._arlo.debug(f"2fa-rest-api: {msg}") pyaarlo-0.8.0.15/pyaarlo/util.py000066400000000000000000000064451475374251700164400ustar00rootroot00000000000000import base64 import time from datetime import datetime, timezone import requests def utc_to_local(utc_dt): return utc_dt.replace(tzinfo=timezone.utc).astimezone(tz=None) def the_epoch(): return utc_to_local(datetime.fromtimestamp(0, tz=timezone.utc)) def arlotime_to_time(timestamp): """Convert Arlo timestamp to Unix timestamp.""" return int(timestamp / 1000) def arlotime_to_datetime(timestamp): """Convert Arlo timestamp to Python datetime.""" return utc_to_local(datetime.fromtimestamp(int(timestamp / 1000), tz=timezone.utc)) def arlotime_strftime(timestamp, date_format="%Y-%m-%dT%H:%M:%S"): """Convert Arlo timestamp to time string.""" return arlotime_to_datetime(timestamp).strftime(date_format) def time_to_arlotime(timestamp=None): """Convert Unix timestamp to Arlo timestamp.""" if timestamp is None: timestamp = time.time() return int(timestamp * 1000) def now_strftime(date_format="%Y-%m-%dT%H:%M:%S"): """Convert now to time string.""" return datetime.now().strftime(date_format) def days_until(when): now = datetime.now() when = datetime.utcfromtimestamp(when) if when <= now: return 0 return (when - now).days def httptime_to_datetime(http_timestamp): """Convert HTTP timestamp to Python datetime.""" return utc_to_local(datetime.strptime(http_timestamp, "%a, %d %b %Y %H:%M:%S GMT")) def httptime_strftime(http_timestamp, date_format="%Y-%m-%dT%H:%M:%S"): """Convert HTTP timestamp to time string.""" return httptime_to_datetime(http_timestamp).strftime(date_format) def _http_get(url): """Download HTTP data.""" if url is None: return None try: ret = requests.get(url) except requests.exceptions.SSLError: return None except Exception: return None if ret.status_code != 200: return None return ret def http_get(url, filename=None): """Download HTTP data.""" ret = _http_get(url) if ret is None: return False if filename is None: return ret.content with open(filename, "wb") as data: data.write(ret.content) return True def http_get_img(url, ignore_date=False): """Download HTTP image data.""" ret = _http_get(url) if ret is None: return None, datetime.now().astimezone() date = None if not ignore_date: date = ret.headers.get("Last-Modified", None) if date is not None: date = httptime_to_datetime(date) if date is None: date = datetime.now().astimezone() return ret.content, date def http_stream(url, chunk=4096): """Generate stream for a given record video. :param url: url of stream to read :param chunk: chunk bytes to read per time :returns generator object """ ret = requests.get(url, stream=True) ret.raise_for_status() for data in ret.iter_content(chunk): yield data def rgb_to_hex(rgb): """Convert HA color to Arlo color.""" return "#{:02x}{:02x}{:02x}".format(rgb[0], rgb[1], rgb[2]) def hex_to_rgb(h): """Convert Arlo color to HA color.""" return {"red": int(h[1:3], 16), "green": int(h[3:5], 16), "blue": int(h[5:7], 16)} def to_b64(in_str): """Convert a string into a base64 string.""" return base64.b64encode(in_str.encode()).decode() pyaarlo-0.8.0.15/requirements.txt000066400000000000000000000001411475374251700167110ustar00rootroot00000000000000requests click pycryptodome unidecode cloudscraper>=1.2.71 paho-mqtt cryptography python-slugify pyaarlo-0.8.0.15/setup.py000066400000000000000000000035221475374251700151450ustar00rootroot00000000000000# coding=utf-8 """Python Arlo setup script.""" from setuptools import setup def readme(): with open('README.md') as desc: return desc.read() setup( name='pyaarlo', version='0.8.0.15', packages=['pyaarlo'], python_requires='>=3.7', install_requires=[ 'requests', 'click', 'pycryptodome', 'unidecode', 'cloudscraper>=1.2.71', 'paho-mqtt', 'cryptography', 'python-slugify' ], author='Steve Herrell', author_email='steve.herrell@gmail.com', description='PyAarlo is a library that provides asynchronous access to Arlo security cameras.', long_description=readme(), long_description_content_type='text/markdown', license='LGPLv3+', keywords=[ 'arlo', 'netgear', 'camera', 'home automation', 'python', ], url='https://github.com/twrecked/pyaarlo.git', project_urls={ "Bug Tracker": 'https://github.com/twrecked/pyaarlo/issues', "Documentation": 'https://github.com/twrecked/pyaarlo/blob/master/README.md', "Source Code": 'https://github.com/twrecked/pyaarlo', }, classifiers=[ 'Environment :: Other Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Topic :: Software Development :: Libraries :: Python Modules' ], entry_points={ 'console_scripts': [ 'pyaarlo = pyaarlo.main:main_func', ], }, test_suite='tests', ) pyaarlo-0.8.0.15/tests/000077500000000000000000000000001475374251700145735ustar00rootroot00000000000000pyaarlo-0.8.0.15/tests/__init__.py000066400000000000000000000000001475374251700166720ustar00rootroot00000000000000pyaarlo-0.8.0.15/tests/arlo.py000066400000000000000000000014241475374251700161030ustar00rootroot00000000000000import logging from pyaarlo.cfg import ArloCfg _LOGGER = logging.getLogger("pyaarlo") class PyArlo(object): def __init__(self, **kwargs): """Constructor for the PyArlo object.""" self._last_error = None self._cfg = ArloCfg(self, **kwargs) @property def cfg(self): return self._cfg def error(self, msg): self._last_error = msg _LOGGER.error(msg) @property def last_error(self): """Return the last reported error.""" return self._last_error def warning(self, msg): _LOGGER.warning(msg) def info(self, msg): _LOGGER.info(msg) def debug(self, msg): _LOGGER.debug(msg) def vdebug(self, msg): if self._cfg.verbose: _LOGGER.debug(msg) pyaarlo-0.8.0.15/tests/test_cfg.py000066400000000000000000000104671475374251700167530ustar00rootroot00000000000000from unittest import TestCase import tests.arlo class TestArloCfg(TestCase): def test_scheme(self): arlo = tests.arlo.PyArlo() self.assertEqual(arlo.cfg._remove_scheme("abc.com"), "abc.com") self.assertEqual(arlo.cfg._remove_scheme("https://abc.com"), "abc.com") self.assertEqual(arlo.cfg._add_scheme("abc.com"), "https://abc.com") self.assertEqual(arlo.cfg._add_scheme("https://abc.com"), "https://abc.com") self.assertEqual(arlo.cfg._add_scheme("http://abc.com"), "http://abc.com") self.assertEqual(arlo.cfg._add_scheme("abc.com", "imap"), "imap://abc.com") self.assertEqual(arlo.cfg._add_scheme("https://abc.com", "imap"), "https://abc.com") def test_host_00(self): arlo = tests.arlo.PyArlo() self.assertEqual(arlo.cfg.tfa_host, "pyaarlo-tfa.appspot.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "https://pyaarlo-tfa.appspot.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("roygbiv"), "https://pyaarlo-tfa.appspot.com") self.assertEqual(arlo.cfg.tfa_port, 993) def test_host_10(self): arlo = tests.arlo.PyArlo(tfa_host="test.host.com") self.assertEqual(arlo.cfg.tfa_host, "test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "https://test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("imap"), "imap://test.host.com") self.assertEqual(arlo.cfg.tfa_port, 993) def test_host_11(self): arlo = tests.arlo.PyArlo(tfa_host="test.host.com:998") self.assertEqual(arlo.cfg.tfa_host, "test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "https://test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("imap"), "imap://test.host.com") self.assertEqual(arlo.cfg.tfa_port, 998) def test_host_20(self): arlo = tests.arlo.PyArlo(tfa_host="imap://test.host.com") self.assertEqual(arlo.cfg.tfa_host, "test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "imap://test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("roygbiv"), "imap://test.host.com") self.assertEqual(arlo.cfg.tfa_port, 993) def test_host_21(self): arlo = tests.arlo.PyArlo(tfa_host="imap://test.host.com:998") self.assertEqual(arlo.cfg.tfa_host, "test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "imap://test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("imap"), "imap://test.host.com") self.assertEqual(arlo.cfg.tfa_port, 998) def test_host_30(self): arlo = tests.arlo.PyArlo(tfa_host="https://test.host.com") self.assertEqual(arlo.cfg.tfa_host, "test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "https://test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("roygbiv"), "https://test.host.com") self.assertEqual(arlo.cfg.tfa_port, 993) def test_host_31(self): arlo = tests.arlo.PyArlo(tfa_host="https://test.host.com:998") self.assertEqual(arlo.cfg.tfa_host, "test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme(), "https://test.host.com") self.assertEqual(arlo.cfg.tfa_host_with_scheme("roygbiv"), "https://test.host.com") self.assertEqual(arlo.cfg.tfa_port, 998) def test_host_40(self): arlo = tests.arlo.PyArlo(host="https://test.host.com", auth_host="https://test.host.com", mqtt_host="https://test.host.com") self.assertEqual(arlo.cfg.host, "https://test.host.com") self.assertEqual(arlo.cfg.auth_host, "https://test.host.com") self.assertEqual(arlo.cfg.mqtt_host, "test.host.com") def test_host_41(self): arlo = tests.arlo.PyArlo(host="test.host.com", auth_host="test.host.com", mqtt_host="test.host.com") self.assertEqual(arlo.cfg.host, "https://test.host.com") self.assertEqual(arlo.cfg.auth_host, "https://test.host.com") self.assertEqual(arlo.cfg.mqtt_host, "test.host.com") def test_host_42(self): arlo = tests.arlo.PyArlo(host="http://test.host.com", auth_host="http://test.host.com", mqtt_host="http://test.host.com") self.assertEqual(arlo.cfg.host, "http://test.host.com") self.assertEqual(arlo.cfg.auth_host, "http://test.host.com") self.assertEqual(arlo.cfg.mqtt_host, "test.host.com")