pax_global_header 0000666 0000000 0000000 00000000064 14753742517 0014531 g ustar 00root root 0000000 0000000 52 comment=d761d752440f31da6d3d545845bcdf70dca2a1d6
pyaarlo-0.8.0.15/ 0000775 0000000 0000000 00000000000 14753742517 0013431 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/.gitignore 0000664 0000000 0000000 00000002414 14753742517 0015422 0 ustar 00root root 0000000 0000000 # 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/LICENSE 0000664 0000000 0000000 00000016744 14753742517 0014452 0 ustar 00root root 0000000 0000000 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.md 0000664 0000000 0000000 00000043735 14753742517 0014724 0 ustar 00root root 0000000 0000000 # 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.
[](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/ 0000775 0000000 0000000 00000000000 14753742517 0014201 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/bin/pyaarlo 0000775 0000000 0000000 00000000324 14753742517 0015575 0 ustar 00root root 0000000 0000000 #!/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-encrypt 0000775 0000000 0000000 00000000275 14753742517 0017264 0 ustar 00root root 0000000 0000000 #!/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/changelog 0000664 0000000 0000000 00000017002 14753742517 0015303 0 ustar 00root root 0000000 0000000 0.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/ 0000775 0000000 0000000 00000000000 14753742517 0014361 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/docs/conf.py 0000664 0000000 0000000 00000003650 14753742517 0015664 0 ustar 00root root 0000000 0000000 # 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.rst 0000664 0000000 0000000 00000002026 14753742517 0016222 0 ustar 00root root 0000000 0000000 .. 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.md 0000664 0000000 0000000 00000015707 14753742517 0016347 0 ustar 00root root 0000000 0000000
# 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.1 0000664 0000000 0000000 00000005056 14753742517 0016120 0 ustar 00root root 0000000 0000000 .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/ 0000775 0000000 0000000 00000000000 14753742517 0015247 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/examples/archive 0000775 0000000 0000000 00000003031 14753742517 0016613 0 ustar 00root root 0000000 0000000 #!/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-snapshot 0000775 0000000 0000000 00000003425 14753742517 0020153 0 ustar 00root root 0000000 0000000 #!/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/basics 0000775 0000000 0000000 00000003622 14753742517 0016444 0 ustar 00root root 0000000 0000000 #!/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-tfa 0000775 0000000 0000000 00000002571 14753742517 0017264 0 ustar 00root root 0000000 0000000 #!/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/inject 0000775 0000000 0000000 00000003470 14753742517 0016455 0 ustar 00root root 0000000 0000000 #!/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-all 0000775 0000000 0000000 00000003432 14753742517 0017434 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14753742517 0014676 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/images/jetbrains.svg 0000664 0000000 0000000 00000011416 14753742517 0017403 0 ustar 00root root 0000000 0000000
pyaarlo-0.8.0.15/pyaarlo.py 0000775 0000000 0000000 00000000110 14753742517 0015445 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
from pyaarlo.main import main_func
main_func()
pyaarlo-0.8.0.15/pyaarlo/ 0000775 0000000 0000000 00000000000 14753742517 0015100 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/pyaarlo/__init__.py 0000664 0000000 0000000 00000066666 14753742517 0017235 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000143647 14753742517 0017060 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000011475 14753742517 0017601 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000054614 14753742517 0016376 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000151324 14753742517 0016710 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000020606 14753742517 0016215 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000024451 14753742517 0017311 0 ustar 00root root 0000000 0000000 DEFAULT_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.py 0000664 0000000 0000000 00000026576 14753742517 0016731 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000017240 14753742517 0017260 0 ustar 00root root 0000000 0000000 from .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.py 0000664 0000000 0000000 00000005011 14753742517 0016556 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000014056 14753742517 0017270 0 ustar 00root root 0000000 0000000 from .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.py 0000664 0000000 0000000 00000030242 14753742517 0016377 0 ustar 00root root 0000000 0000000 #!/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.py 0000664 0000000 0000000 00000040713 14753742517 0016536 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000012444 14753742517 0016604 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000007331 14753742517 0020545 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000003245 14753742517 0016767 0 ustar 00root root 0000000 0000000
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.py 0000664 0000000 0000000 00000014226 14753742517 0017450 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000004323 14753742517 0017120 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000012077 14753742517 0016617 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000017206 14753742517 0016232 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000006445 14753742517 0016440 0 ustar 00root root 0000000 0000000 import 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.txt 0000664 0000000 0000000 00000000141 14753742517 0016711 0 ustar 00root root 0000000 0000000 requests
click
pycryptodome
unidecode
cloudscraper>=1.2.71
paho-mqtt
cryptography
python-slugify
pyaarlo-0.8.0.15/setup.py 0000664 0000000 0000000 00000003522 14753742517 0015145 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14753742517 0014573 5 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/tests/__init__.py 0000664 0000000 0000000 00000000000 14753742517 0016672 0 ustar 00root root 0000000 0000000 pyaarlo-0.8.0.15/tests/arlo.py 0000664 0000000 0000000 00000001424 14753742517 0016103 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000010467 14753742517 0016753 0 ustar 00root root 0000000 0000000 from 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")