diff --git a/plugins/arlo/.gitignore b/plugins/arlo/.gitignore deleted file mode 100644 index c3f88796f..000000000 --- a/plugins/arlo/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.DS_Store -out/ -node_modules/ -dist/ -.venv diff --git a/plugins/arlo/.npmignore b/plugins/arlo/.npmignore deleted file mode 100644 index 53bc7ff98..000000000 --- a/plugins/arlo/.npmignore +++ /dev/null @@ -1,10 +0,0 @@ -.DS_Store -out/ -node_modules/ -*.map -fs -src -.vscode -dist/*.js -dist/*.txt -__pycache__ diff --git a/plugins/arlo/.vscode/launch.json b/plugins/arlo/.vscode/launch.json deleted file mode 100644 index b5210935d..000000000 --- a/plugins/arlo/.vscode/launch.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Scrypted Debugger", - "type": "python", - "request": "attach", - "connect": { - "host": "${config:scrypted.debugHost}", - "port": 10081 - }, - "justMyCode": false, - "preLaunchTask": "scrypted: deploy+debug", - "pathMappings": [ - { - "localRoot": "${workspaceFolder}/../../server/python/", - "remoteRoot": "${config:scrypted.serverRoot}/python", - }, - { - "localRoot": "${workspaceFolder}/src", - "remoteRoot": "${config:scrypted.volumeRoot}/plugin.zip" - }, - - ] - } - ] -} \ No newline at end of file diff --git a/plugins/arlo/.vscode/settings.json b/plugins/arlo/.vscode/settings.json deleted file mode 100644 index 1e3b833fa..000000000 --- a/plugins/arlo/.vscode/settings.json +++ /dev/null @@ -1,27 +0,0 @@ - -{ - // specify the following paths on the target scrypted server: - // 1) where @scrypted/server node module resides: this may either be a checkout or a install. - // 2) where the scrypted "volume" data is located on the server. ie, the docker volume. - // the following default examples are provided for local and docker installations, - // only modifying the debugHost should be necessary: - - // local installation - // "scrypted.debugHost": "192.168.2.119", - // "scrypted.serverRoot": "/home/pi/.scrypted/node_modules/@scrypted/server", - // "scrypted.volumeRoot": "/home/pi/.scrypted/volume", - - // docker installation - // "scrypted.debugHost": "192.168.2.109", - "scrypted.serverRoot": "/server/node_modules/@scrypted/server", - "scrypted.volumeRoot": "/server/volume", - - // local checkout - "scrypted.debugHost": "127.0.0.1", - //"scrypted.serverRoot": "/Volumes/Dev/scrypted/server", - //"scrypted.volumeRoot": "${config:scrypted.serverRoot}/volume", - - "python.analysis.extraPaths": [ - "./node_modules/@scrypted/sdk/types/scrypted_python" - ] -} \ No newline at end of file diff --git a/plugins/arlo/.vscode/tasks.json b/plugins/arlo/.vscode/tasks.json deleted file mode 100644 index 4d922a539..000000000 --- a/plugins/arlo/.vscode/tasks.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "scrypted: deploy+debug", - "type": "shell", - "presentation": { - "echo": true, - "reveal": "silent", - "focus": false, - "panel": "shared", - "showReuseMessage": true, - "clear": false - }, - "command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}", - }, - ] -} diff --git a/plugins/arlo/README.md b/plugins/arlo/README.md deleted file mode 100644 index 6b5ee47ba..000000000 --- a/plugins/arlo/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Arlo Plugin for Scrypted - -The Arlo Plugin connects Scrypted to Arlo Cloud, allowing you to access all of your Arlo cameras in Scrypted. - -It is highly recommended to create a dedicated Arlo account for use with this plugin and share your cameras from your main account, as Arlo only permits one active login to their servers per account. Using a separate account allows you to use the Arlo app or website simultaneously with this plugin, otherwise logging in from one place will log you out from all other devices. - -The account you use for this plugin must have either SMS or email set as the default 2FA option. Once you enter your username and password on the plugin settings page, you should receive a 2FA code through your default 2FA option. Enter that code into the provided box, and your cameras will appear in Scrypted. Or, see below for configuring IMAP to auto-login with 2FA. - -If you experience any trouble logging in, clear the username and password boxes, reload the plugin, and try again. - -If you are unable to see shared cameras in your separate Arlo account, ensure that both your primary and secondary accounts are upgraded according to this [forum post](https://web.archive.org/web/20230710141914/https://community.arlo.com/t5/Arlo-Secure/Invited-friend-cannot-see-devices-on-their-dashboard-Arlo-Pro-2/m-p/1889396#M1813). Verify the sharing worked by logging in via the Arlo web dashboard. - -**If you add or remove cameras from your main Arlo account, or share/un-share/re-share cameras with the Arlo account used with this plugin, ensure that you reload this plugin to get the updated camera state from Arlo Cloud.** - -## General Setup Notes - -* Ensure that your Arlo account's default 2FA option is set to either SMS or email. -* Motion events notifications should be turned on in the Arlo app. If you are receiving motion push notifications, Scrypted will also receive motion events. -* Disable smart detection and any cloud/local recording in the Arlo app. Arlo Cloud only permits one active stream per camera, so any smart detection or recording features may prevent downstream plugins (e.g. Homekit) from successfully pulling the video feed after a motion event. -* It is highly recommended to enable the Rebroadcast plugin to allow multiple downstream plugins to pull the video feed within Scrypted. -* If there is no audio on your camera, switch to the `FFmpeg (TCP)` parser under the `Cloud RTSP` settings. -* Prebuffering should only be enabled if the camera is wired to a persistent power source, such as a wall outlet. Prebuffering will only work if your camera does not have a battery or `Plugged In to External Power` is selected. -* The plugin supports pulling RTSP or DASH streams from Arlo Cloud. It is recommended to use RTSP for the lowest latency streams. DASH is inconsistent in reliability, and may return finicky codecs that require additional FFmpeg output arguments, e.g. `-vcodec h264`. *Note that both RTSP and DASH will ultimately pull the same video stream feed from your camera, and they cannot both be used at the same time due to the single stream limitation.* - -Note that streaming cameras uses extra Internet bandwidth, since video and audio packets will need to travel from the camera through your network, out to Arlo Cloud, and then back to your network and into Scrypted. - -## IMAP 2FA - -The Arlo Plugin supports using the IMAP protocol to check an email mailbox for Arlo 2FA codes. This requires you to specify an email 2FA option as the default in your Arlo account settings. - -The plugin should work with any mailbox that supports IMAP, but so far has been tested with Gmail. To configure a Gmail mailbox, see [here](https://support.google.com/mail/answer/7126229?hl=en) to see the Gmail IMAP settings, and [here](https://support.google.com/accounts/answer/185833?hl=en) to create an App Password. Enter the App Password in place of your normal Gmail password. - -The plugin searches for emails sent by Arlo's `do_not_reply@arlo.com` address when looking for 2FA codes. If you are using a service to forward emails to the mailbox registered with this plugin (e.g. a service like iCloud's Hide My Email), it is possible that Arlo's email sender address has been overwritten by the mail forwarder. Check the email registered with this plugin to see what address the mail forwarder uses to replace Arlo's sender address, and update that in the IMAP 2FA settings. - -## Virtual Security System for Arlo Sirens - -In external integrations like Homekit, sirens are exposed as simple on-off switches. This makes it easy to accidentally hit the switch when using the Home app. The Arlo Plugin creates a "virtual" security system device per siren to allow Scrypted to arm or disarm the siren switch to protect against accidental triggers. This fake security system device will be synced into Homekit as a separate accessory from the camera, with the siren itself merged into the security system accessory. - -Note that the virtual security system is NOT tied to your Arlo account at all, and will not make any changes such as switching your device's motion alert armed/disarmed modes. For more information, please see the README on the virtual security system device in Scrypted. - -## Video Clips - -The Arlo Plugin will show video clips available in Arlo Cloud for cameras with cloud recording enabled. These clips are not downloaded onto your Scrypted server, but rather streamed on-demand. Deleting clips is not available in Scrypted and should be done through the Arlo app or the Arlo web dashboard. \ No newline at end of file diff --git a/plugins/arlo/package-lock.json b/plugins/arlo/package-lock.json deleted file mode 100644 index e9a5813b9..000000000 --- a/plugins/arlo/package-lock.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "name": "@scrypted/arlo", - "version": "0.8.26", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "@scrypted/arlo", - "version": "0.8.26", - "license": "Apache", - "devDependencies": { - "@scrypted/sdk": "file:../../sdk" - } - }, - "../../sdk": { - "name": "@scrypted/sdk", - "version": "0.2.104", - "dev": true, - "license": "ISC", - "dependencies": { - "@babel/preset-typescript": "^7.18.6", - "adm-zip": "^0.4.13", - "axios": "^0.21.4", - "babel-loader": "^9.1.0", - "babel-plugin-const-enum": "^1.1.0", - "esbuild": "^0.15.9", - "ncp": "^2.0.0", - "raw-loader": "^4.0.2", - "rimraf": "^3.0.2", - "tmp": "^0.2.1", - "ts-loader": "^9.4.2", - "typescript": "^4.9.4", - "webpack": "^5.75.0", - "webpack-bundle-analyzer": "^4.5.0" - }, - "bin": { - "scrypted-changelog": "bin/scrypted-changelog.js", - "scrypted-debug": "bin/scrypted-debug.js", - "scrypted-deploy": "bin/scrypted-deploy.js", - "scrypted-deploy-debug": "bin/scrypted-deploy-debug.js", - "scrypted-package-json": "bin/scrypted-package-json.js", - "scrypted-setup-project": "bin/scrypted-setup-project.js", - "scrypted-webpack": "bin/scrypted-webpack.js" - }, - "devDependencies": { - "@types/node": "^18.11.18", - "@types/stringify-object": "^4.0.0", - "stringify-object": "^3.3.0", - "ts-node": "^10.4.0", - "typedoc": "^0.23.21" - } - }, - "node_modules/@scrypted/sdk": { - "resolved": "../../sdk", - "link": true - } - }, - "dependencies": { - "@scrypted/sdk": { - "version": "file:../../sdk", - "requires": { - "@babel/preset-typescript": "^7.18.6", - "@types/node": "^18.11.18", - "@types/stringify-object": "^4.0.0", - "adm-zip": "^0.4.13", - "axios": "^0.21.4", - "babel-loader": "^9.1.0", - "babel-plugin-const-enum": "^1.1.0", - "esbuild": "^0.15.9", - "ncp": "^2.0.0", - "raw-loader": "^4.0.2", - "rimraf": "^3.0.2", - "stringify-object": "^3.3.0", - "tmp": "^0.2.1", - "ts-loader": "^9.4.2", - "ts-node": "^10.4.0", - "typedoc": "^0.23.21", - "typescript": "^4.9.4", - "webpack": "^5.75.0", - "webpack-bundle-analyzer": "^4.5.0" - } - } - } -} diff --git a/plugins/arlo/package.json b/plugins/arlo/package.json deleted file mode 100644 index 5483c6be6..000000000 --- a/plugins/arlo/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@scrypted/arlo", - "version": "0.8.26", - "description": "Arlo Plugin for Scrypted", - "license": "Apache", - "keywords": [ - "scrypted", - "plugin", - "arlo", - "camera" - ], - "scripts": { - "scrypted-setup-project": "scrypted-setup-project", - "prescrypted-setup-project": "scrypted-package-json", - "build": "scrypted-webpack", - "prepublishOnly": "NODE_ENV=production scrypted-webpack", - "prescrypted-vscode-launch": "scrypted-webpack", - "scrypted-vscode-launch": "scrypted-deploy-debug", - "scrypted-deploy-debug": "scrypted-deploy-debug", - "scrypted-debug": "scrypted-debug", - "scrypted-deploy": "scrypted-deploy", - "scrypted-readme": "scrypted-readme", - "scrypted-package-json": "scrypted-package-json" - }, - "scrypted": { - "name": "Arlo Camera Plugin", - "runtime": "python", - "type": "DeviceProvider", - "interfaces": [ - "Settings", - "DeviceProvider" - ], - "pluginDependencies": [ - "@scrypted/snapshot", - "@scrypted/prebuffer-mixin" - ] - }, - "devDependencies": { - "@scrypted/sdk": "file:../../sdk" - } -} diff --git a/plugins/arlo/src/arlo_plugin/__init__.py b/plugins/arlo/src/arlo_plugin/__init__.py deleted file mode 100644 index e00943990..000000000 --- a/plugins/arlo/src/arlo_plugin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .provider import ArloProvider \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/arlo/__init__.py b/plugins/arlo/src/arlo_plugin/arlo/__init__.py deleted file mode 100644 index f313abdaf..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .arlo_async import Arlo \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/arlo/arlo_async.py b/plugins/arlo/src/arlo_plugin/arlo/arlo_async.py deleted file mode 100644 index 58c7ce27f..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/arlo_async.py +++ /dev/null @@ -1,1133 +0,0 @@ -# This file has been modified to support async semantics and better -# integration with scrypted. -# Original: https://github.com/jeffreydwalter/arlo - -""" -Copyright 2016 Jeffrey D. Walter - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS ISBASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -# 14 Sep 2016, Len Shustek: Added Logout() -# 17 Jul 2017, Andreas Jakl: Port to Python 3 (https://www.andreasjakl.com/using-netgear-arlo-security-cameras-for-periodic-recording/) - -# Import helper classes that are part of this library. - -from .request import Request -from .host_picker import pick_host -from .mqtt_stream_async import MQTTStream -from .sse_stream_async import EventStream -from .logging import logger - -# Import all of the other stuff. -from datetime import datetime, timedelta -from cachetools import cached, TTLCache -import scrypted_arlo_go - -import asyncio -import sys -import base64 -import math -import random -import time -import uuid -from urllib.parse import urlparse, parse_qs - -stream_class = MQTTStream - -def change_stream_class(s_class): - global stream_class - if s_class == "MQTT": - stream_class = MQTTStream - elif s_class == "SSE": - stream_class = EventStream - else: - raise NotImplementedError(s_class) - - -# https://github.com/twrecked/pyaarlo/blob/03c99b40b67529d81c0ba399fe91a3e6d1a35a80/pyaarlo/constant.py#L265-L285 -USER_AGENTS = { - "arlo": - "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 13_1_3 like Mac OS X) " - "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.1 Mobile/15E148 Safari/604.1", - "ipad": - "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) " - "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Mobile/15E148 Safari/604.1", - "mac": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) " - "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15", - "firefox": - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:85.0) " - "Gecko/20100101 Firefox/85.0", - "linux": - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36", - - # extracted from cloudscraper as a working UA for cloudflare - "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" -} - -# user agents for media players, e.g. the android app -MEDIA_USER_AGENTS = { - "android": "ijkplayer-android-4.5_28538" -} - - -class Arlo(object): - BASE_URL = 'my.arlo.com' - AUTH_URL = 'ocapi-app.arlo.com' - BACKUP_AUTH_HOSTS = ["NTIuMzEuMTU3LjE4MQ==","MzQuMjQ4LjE1My42OQ==","My4yNDguMTI4Ljc3","MzQuMjQ2LjE0LjI5"] - #BACKUP_AUTH_HOSTS = BACKUP_AUTH_HOSTS[2:3] - TRANSID_PREFIX = 'web' - - random.shuffle(BACKUP_AUTH_HOSTS) - - def __init__(self, username, password): - self.username = username - self.password = password - self.event_stream = None - self.request = None - self.logged_in = False - - def to_timestamp(self, dt): - if sys.version[0] == '2': - epoch = datetime.utcfromtimestamp(0) - return int((dt - epoch).total_seconds() * 1e3) - else: - return int(dt.timestamp() * 1e3) - - def genTransId(self, trans_type=TRANSID_PREFIX): - def float2hex(f): - MAXHEXADECIMALS = 15 - w = f // 1 - d = f % 1 - - # Do the whole: - if w == 0: result = '0' - else: result = '' - while w: - w, r = divmod(w, 16) - r = int(r) - if r > 9: r = chr(r+55) - else: r = str(r) - result = r + result - - # And now the part: - if d == 0: return result - - result += '.' - count = 0 - while d: - d = d * 16 - w, d = divmod(d, 1) - w = int(w) - if w > 9: w = chr(w+55) - else: w = str(w) - result += w - count += 1 - if count > MAXHEXADECIMALS: break - - return result - - now = datetime.today() - return trans_type+"!" + float2hex(random.random() * math.pow(2, 32)).lower() + "!" + str(int((time.mktime(now.timetuple())*1e3 + now.microsecond/1e3))) - - def UseExistingAuth(self, user_id, headers): - self.user_id = user_id - headers['Content-Type'] = 'application/json; charset=UTF-8' - headers['User-Agent'] = USER_AGENTS['arlo'] - self.request = Request(mode="cloudscraper") - self.request.session.headers.update(headers) - self.BASE_URL = 'myapi.arlo.com' - self.logged_in = True - - def LoginMFA(self): - device_id = str(uuid.uuid4()) - headers = { - 'DNT': '1', - 'schemaVersion': '1', - 'Auth-Version': '2', - 'Content-Type': 'application/json; charset=UTF-8', - 'Origin': f'https://{self.BASE_URL}', - 'Referer': f'https://{self.BASE_URL}/', - 'Source': 'arloCamWeb', - 'TE': 'Trailers', - 'x-user-device-id': device_id, - 'x-user-device-automation-name': 'QlJPV1NFUg==', - 'x-user-device-type': 'BROWSER', - 'Host': self.AUTH_URL, - } - - self.request = Request() - try: - #raise Exception("testing backup hosts") - auth_host = self.AUTH_URL - self.request.options(f'https://{auth_host}/api/auth', headers=headers) - logger.info("Using primary authentication host") - except Exception as e: - # in case cloudflare rejects our auth request... - logger.warning(f"Using fallback authentication host due to: {e}") - - auth_host = pick_host([ - base64.b64decode(h.encode("utf-8")).decode("utf-8") - for h in self.BACKUP_AUTH_HOSTS - ], self.AUTH_URL, "/api/auth") - logger.debug(f"Selected backup authentication host {auth_host}") - - self.request = Request(mode="ip") - - # Authenticate - self.request.options(f'https://{auth_host}/api/auth', headers=headers) - auth_body = self.request.post( - f'https://{auth_host}/api/auth', - params={ - 'email': self.username, - 'password': str(base64.b64encode(self.password.encode('utf-8')), 'utf-8'), - 'language': 'en', - 'EnvSource': 'prod' - }, - headers=headers, - raw=True - ) - self.user_id = auth_body['data']['userId'] - self.request.session.headers.update({'Authorization': base64.b64encode(auth_body['data']['token'].encode('utf-8')).decode()}) - - # Retrieve MFA factor id - factors_body = self.request.get( - f'https://{auth_host}/api/getFactors', - params={'data': auth_body['data']['issued']}, - headers=headers, - raw=True - ) - factor_id = next( - iter([ - i for i in factors_body['data']['items'] - if (i['factorType'] == 'EMAIL' or i['factorType'] == 'SMS') - and i['factorRole'] == "PRIMARY" - ]), - {} - ).get('factorId') - if not factor_id: - raise Exception("Could not find valid 2FA method - is the primary 2FA set to either Email or SMS?") - - # Start factor auth - start_auth_body = self.request.post( - f'https://{auth_host}/api/startAuth', - params={'factorId': factor_id}, - headers=headers, - raw=True - ) - factor_auth_code = start_auth_body['data']['factorAuthCode'] - - def complete_auth(code): - nonlocal self, factor_auth_code, headers - - finish_auth_body = self.request.post( - f'https://{auth_host}/api/finishAuth', - params={ - 'factorAuthCode': factor_auth_code, - 'otp': code - }, - headers=headers, - raw=True - ) - - if finish_auth_body.get('data', {}).get('token') is None: - raise Exception("Could not complete 2FA, maybe invalid token? If the error persists, please try reloading the plugin and logging in again.") - - self.request = Request(mode="cloudscraper") - - # Update Authorization code with new code - headers = { - 'Auth-Version': '2', - 'Authorization': finish_auth_body['data']['token'], - 'User-Agent': USER_AGENTS['arlo'], - 'Content-Type': 'application/json; charset=UTF-8', - } - self.request.session.headers.update(headers) - self.BASE_URL = 'myapi.arlo.com' - self.logged_in = True - - return complete_auth - - def Logout(self): - self.Unsubscribe() - return self.request.put(f'https://{self.BASE_URL}/hmsweb/logout') - - async def Subscribe(self, basestation_camera_tuples=[]): - """ - Arlo uses the EventStream interface in the browser to do pub/sub style messaging. - Unfortunately, this appears to be the only way Arlo communicates these messages. - - This function makes the initial GET request to /subscribe, which returns the EventStream socket. - Once we have that socket, the API requires a POST request to /notify with the subscriptions resource. - This call registers the device (which should be the basestation) so that events will be sent to the EventStream - when subsequent calls to /notify are made. - """ - async def heartbeat(self, basestations, interval=30): - while self.event_stream and self.event_stream.active: - for basestation in basestations: - try: - self.Ping(basestation) - except: - pass - await asyncio.sleep(interval) - - if not self.event_stream or (not self.event_stream.initializing and not self.event_stream.connected): - self.event_stream = stream_class(self) - await self.event_stream.start() - - while not self.event_stream.connected: - await asyncio.sleep(0.5) - - # if tuples are provided, then this is the Subscribe initiated - # by the top level plugin, and we should add mqtt subscriptions - # and register basestation heartbeats - if len(basestation_camera_tuples) > 0: - # find unique basestations and cameras - basestations, cameras = {}, {} - for basestation, camera in basestation_camera_tuples: - basestations[basestation['deviceId']] = basestation - cameras[camera['deviceId']] = camera - - # filter out cameras without basestation, where they are their own basestations - # this is so battery-powered devices do not drain due to pings - # for wired devices, keep doorbells, sirens, and arloq in the list so they get pings - # we also add arlo baby devices (abc1000, abc1000a) since they are standalone-only - # and seem to want pings - devices_to_ping = {} - for basestation in basestations.values(): - if basestation['deviceId'] == basestation.get('parentId') and \ - basestation['deviceType'] not in ['doorbell', 'siren', 'arloq', 'arloqs'] and \ - basestation['modelId'].lower() not in ['abc1000', 'abc1000a']: - continue - # avd2001 is the battery doorbell, and we don't want to drain its battery, so disable pings - if basestation['modelId'].lower().startswith('avd2001'): - continue - devices_to_ping[basestation['deviceId']] = basestation - - logger.info(f"Will send heartbeat to the following devices: {list(devices_to_ping.keys())}") - - # start heartbeat loop with only pingable devices - asyncio.get_event_loop().create_task(heartbeat(self, list(devices_to_ping.values()))) - - # subscribe to all camera topics - topics = [ - f"d/{basestation['xCloudId']}/out/cameras/{camera['deviceId']}/#" - for basestation, camera in basestation_camera_tuples - ] - - # subscribe to basestation topics - for basestation in basestations.values(): - x_cloud_id = basestation['xCloudId'] - topics += [ - f"d/{x_cloud_id}/out/wifi/#", - f"d/{x_cloud_id}/out/subscriptions/#", - f"d/{x_cloud_id}/out/audioPlayback/#", - f"d/{x_cloud_id}/out/modes/#", - f"d/{x_cloud_id}/out/basestation/#", - f"d/{x_cloud_id}/out/doorbells/#", - f"d/{x_cloud_id}/out/siren/#", - f"d/{x_cloud_id}/out/devices/#", - f"d/{x_cloud_id}/out/storage/#", - f"d/{x_cloud_id}/out/schedule/#", - f"d/{x_cloud_id}/out/diagnostics/#", - f"d/{x_cloud_id}/out/automaticRevisionUpdate/#", - f"d/{x_cloud_id}/out/audio/#", - f"d/{x_cloud_id}/out/activeAutomations/#", - f"d/{x_cloud_id}/out/lte/#", - ] - - self.event_stream.subscribe(topics) - - def Unsubscribe(self): - """ This method stops the EventStream subscription and removes it from the event_stream collection. """ - if self.event_stream and self.event_stream.connected: - self.event_stream.disconnect() - self.request.get(f'https://{self.BASE_URL}/hmsweb/client/unsubscribe') - - self.event_stream = None - - def Notify(self, basestation, body): - """ - The following are examples of the json you would need to pass in the body of the Notify() call to interact with Arlo: - - ############################################################################################################################## - ############################################################################################################################## - NOTE: While you can call Notify() directly, responses from these notify calls are sent to the EventStream (see Subscribe()), - and so it's better to use the Get/Set methods that are implemented using the NotifyAndGetResponse() method. - ############################################################################################################################## - ############################################################################################################################## - - Set System Mode (Armed, Disarmed) - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"set","resource":"modes","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"active":"mode0"}} - Set System Mode (Calendar) - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"set","resource":"schedule","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"active":true}} - Configure The Schedule (Calendar) - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"set","resource":"schedule","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"schedule":[{"modeId":"mode0","startTime":0},{"modeId":"mode2","startTime":28800000},{"modeId":"mode0","startTime":64800000},{"modeId":"mode0","startTime":86400000},{"modeId":"mode2","startTime":115200000},{"modeId":"mode0","startTime":151200000},{"modeId":"mode0","startTime":172800000},{"modeId":"mode2","startTime":201600000},{"modeId":"mode0","startTime":237600000},{"modeId":"mode0","startTime":259200000},{"modeId":"mode2","startTime":288000000},{"modeId":"mode0","startTime":324000000},{"modeId":"mode0","startTime":345600000},{"modeId":"mode2","startTime":374400000},{"modeId":"mode0","startTime":410400000},{"modeId":"mode0","startTime":432000000},{"modeId":"mode0","startTime":518400000}]} - Create Mode - - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"add","resource":"rules","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"name":"Record video on Camera 1 if Camera 1 detects motion","id":"ruleNew","triggers":[{"type":"pirMotionActive","deviceId":"XXXXXXXXXXXXX","sensitivity":80}],"actions":[{"deviceId":"XXXXXXXXXXXXX","type":"recordVideo","stopCondition":{"type":"timeout","timeout":15}},{"type":"sendEmailAlert","recipients":["__OWNER_EMAIL__"]},{"type":"pushNotification"}]}} - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"add","resource":"modes","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"name":"Test","rules":["rule3"]}} - Delete Mode - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"delete","resource":"modes/mode3","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true} - Camera Off - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"set","resource":"cameras/XXXXXXXXXXXXX","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"privacyActive":false}} - Night Vision On - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"set","resource":"cameras/XXXXXXXXXXXXX","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"zoom":{"topleftx":0,"toplefty":0,"bottomrightx":1280,"bottomrighty":720},"mirror":true,"flip":true,"nightVisionMode":1,"powerSaveMode":2}} - Motion Detection Test - {"from":"XXX-XXXXXXX_web","to":"XXXXXXXXXXXXX","action":"set","resource":"cameras/XXXXXXXXXXXXX","transId":"web!XXXXXXXX.XXXXXXXXXXXXXXXXXXXX","publishResponse":true,"properties":{"motionSetupModeEnabled":true,"motionSetupModeSensitivity":80}} - - device_id = locations.data.uniqueIds - - System Properties: ("resource":"modes") - active (string) - Mode Selection (mode2 = All Motion On, mode1 = Armed, mode0 = Disarmed, etc.) - - System Properties: ("resource":"schedule") - active (bool) - Mode Selection (true = Calendar) - - Camera Properties: ("resource":"cameras/{id}") - privacyActive (bool) - Camera On/Off - zoom (topleftx (int), toplefty (int), bottomrightx (int), bottomrighty (int)) - Camera Zoom Level - mirror (bool) - Mirror Image (left-to-right or right-to-left) - flip (bool) - Flip Image Vertically - nightVisionMode (int) - Night Mode Enabled/Disabled (1, 0) - powerSaveMode (int) - PowerSaver Mode (3 = Best Video, 2 = Optimized, 1 = Best Battery Life) - motionSetupModeEnabled (bool) - Motion Detection Setup Enabled/Disabled - motionSetupModeSensitivity (int 0-100) - Motion Detection Sensitivity - """ - basestation_id = basestation.get('deviceId') - - body['transId'] = self.genTransId() - body['from'] = self.user_id+'_web' - body['to'] = basestation_id - - self.request.post(f'https://{self.BASE_URL}/hmsweb/users/devices/notify/'+body['to'], params=body, headers={"xcloudId":basestation.get('xCloudId')}) - return body.get('transId') - - def Ping(self, basestation): - basestation_id = basestation.get('deviceId') - return self.Notify(basestation, {"action":"set","resource":"subscriptions/"+self.user_id+"_web","publishResponse":False,"properties":{"devices":[basestation_id]}}) - - def SubscribeToErrorEvents(self, basestation, camera, callback): - """ - Use this method to subscribe to error events. You must provide a callback function which will get called once per error event. - - The callback function should have the following signature: - def callback(code, message) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - resource = f"cameras/{camera.get('deviceId')}" - - # Note: It looks like sometimes a message is returned as an 'is' action - # where a 'stateChangeReason' property contains the error message. This is - # a bit of a hack but we will listen to both events with an 'error' key as - # well as 'stateChangeReason' events. - - def callbackwrapper(self, event): - if 'error' in event: - error = event['error'] - elif 'properties' in event: - error = event['properties'].get('stateChangeReason', {}) - else: - return None - message = error.get('message') - code = error.get('code') - stop = callback(code, message) - if not stop: - return None - return stop - - return asyncio.get_event_loop().create_task( - self.HandleEvents(basestation, resource, ['error', ('is', 'stateChangeReason')], callbackwrapper) - ) - - def SubscribeToMotionEvents(self, basestation, camera, callback, logger) -> asyncio.Task: - """ - Use this method to subscribe to motion events. You must provide a callback function which will get called once per motion event. - - The callback function should have the following signature: - def callback(event) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - return self._subscribe_to_motion_or_audio_events(basestation, camera, callback, logger, "motionDetected") - - def SubscribeToAudioEvents(self, basestation, camera, callback, logger): - """ - Use this method to subscribe to audio events. You must provide a callback function which will get called once per audio event. - - The callback function should have the following signature: - def callback(event) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - return self._subscribe_to_motion_or_audio_events(basestation, camera, callback, logger, "audioDetected") - - def _subscribe_to_motion_or_audio_events(self, basestation, camera, callback, logger, event_key) -> asyncio.Task: - """ - Helper class to implement force reset of events (when event end signal is dropped) and delay of end - of event signals (when the sensor turns off and on quickly) - - event_key is either motionDetected or audioDetected - """ - - resource = f"cameras/{camera.get('deviceId')}" - - # if we somehow miss the *Detected = False event, this task - # is used to force the caller to register the end of the event - force_reset_event_task: asyncio.Task = None - - # when we receive a normal *Detected = False event, this - # task is used to delay the delivery in case the sensor - # registers an event immediately afterwards - delayed_event_end_task: asyncio.Task = None - - async def reset_event(sleep_duration: float) -> None: - nonlocal force_reset_event_task, delayed_event_end_task - await asyncio.sleep(sleep_duration) - - logger.debug(f"{event_key}: delivering False") - callback(False) - - force_reset_event_task = None - delayed_event_end_task = None - - def callbackwrapper(self, event): - nonlocal force_reset_event_task, delayed_event_end_task - properties = event.get('properties', {}) - - stop = None - if event_key in properties: - event_detected = properties[event_key] - delivery_delay = 10 - - logger.debug(f"{event_key}: {event_detected} {'will delay delivery by ' + str(delivery_delay) + 's' if not event_detected else ''}".rstrip()) - - if force_reset_event_task: - logger.debug(f"{event_key}: cancelling previous force reset task") - force_reset_event_task.cancel() - force_reset_event_task = None - if delayed_event_end_task: - logger.debug(f"{event_key}: cancelling previous delay event task") - delayed_event_end_task.cancel() - delayed_event_end_task = None - - if event_detected: - stop = callback(event_detected) - - # schedule a callback to reset the sensor - # if we somehow miss the *Detected = False event - force_reset_event_task = asyncio.get_event_loop().create_task(reset_event(60)) - else: - delayed_event_end_task = asyncio.get_event_loop().create_task(reset_event(delivery_delay)) - - if not stop: - return None - return stop - - return asyncio.get_event_loop().create_task( - self.HandleEvents(basestation, resource, [('is', event_key)], callbackwrapper) - ) - - def SubscribeToBatteryEvents(self, basestation, camera, callback): - """ - Use this method to subscribe to battery events. You must provide a callback function which will get called once per battery event. - - The callback function should have the following signature: - def callback(event) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - resource = f"cameras/{camera.get('deviceId')}" - - def callbackwrapper(self, event): - properties = event.get('properties', {}) - stop = None - if 'batteryLevel' in properties: - stop = callback(properties['batteryLevel']) - if not stop: - return None - return stop - - return asyncio.get_event_loop().create_task( - self.HandleEvents(basestation, resource, [('is', 'batteryLevel')], callbackwrapper) - ) - - def SubscribeToDoorbellEvents(self, basestation, doorbell, callback): - """ - Use this method to subscribe to doorbell events. You must provide a callback function which will get called once per doorbell event. - - The callback function should have the following signature: - def callback(event) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - - resource = f"doorbells/{doorbell.get('deviceId')}" - - async def unpress_doorbell(callback): - # It's unclear what events correspond to arlo doorbell presses - # and which ones are unpresses, so we sleep and unset after - # a period of time - await asyncio.sleep(1) - callback(False) - - def callbackwrapper(self, event): - properties = event.get('properties', {}) - stop = None - if 'buttonPressed' in properties: - stop = callback(properties.get('buttonPressed')) - asyncio.get_event_loop().create_task(unpress_doorbell(callback)) - if not stop: - return None - return stop - - return asyncio.get_event_loop().create_task( - self.HandleEvents(basestation, resource, [('is', 'buttonPressed')], callbackwrapper) - ) - - def SubscribeToSDPAnswers(self, basestation, camera, callback): - """ - Use this method to subscribe to pushToTalk SDP answer events. You must provide a callback function which will get called once per SDP event. - - The callback function should have the following signature: - def callback(event) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - - resource = f"cameras/{camera.get('deviceId')}" - - def callbackwrapper(self, event): - properties = event.get("properties", {}) - stop = None - if properties.get("type") == "answerSdp": - stop = callback(properties.get("data")) - if not stop: - return None - return stop - - return asyncio.get_event_loop().create_task( - self.HandleEvents(basestation, resource, ['pushToTalk'], callbackwrapper) - ) - - def SubscribeToCandidateAnswers(self, basestation, camera, callback): - """ - Use this method to subscribe to pushToTalk ICE candidate answer events. You must provide a callback function which will get called once per candidate event. - - The callback function should have the following signature: - def callback(event) - - This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents() - that has a big switch statement in it to handle all the various events Arlo produces. - - Returns the Task object that contains the subscription loop. - """ - - resource = f"cameras/{camera.get('deviceId')}" - - def callbackwrapper(self, event): - properties = event.get("properties", {}) - stop = None - if properties.get("type") == "answerCandidate": - stop = callback(properties.get("data")) - if not stop: - return None - return stop - - return asyncio.get_event_loop().create_task( - self.HandleEvents(basestation, resource, ['pushToTalk'], callbackwrapper) - ) - - async def HandleEvents(self, basestation, resource, actions, callback): - """ - Use this method to subscribe to the event stream and provide a callback that will be called for event event received. - This function will allow you to potentially write a callback that can handle all of the events received from the event stream. - """ - if not callable(callback): - raise Exception('The callback(self, event) should be a callable function.') - - await self.Subscribe() - - async def loop_action_listener(action): - # in this function, action can either be a tuple or a string - # if it is a tuple, we expect there to be a property key in the tuple - property = None - if isinstance(action, tuple): - action, property = action - if not isinstance(action, str): - raise Exception('Actions must be either a tuple or a str') - - seen_events = {} - while self.event_stream.active: - event, _ = await self.event_stream.get(resource, action, property, seen_events) - - if event is None or self.event_stream is None \ - or self.event_stream.event_stream_stop_event.is_set(): - return None - - seen_events[event.uuid] = event - response = callback(self, event.item) - - # always requeue so other listeners can see the event too - self.event_stream.requeue(event, resource, action, property) - - if response is not None: - return response - - # remove events that have expired - for uuid in list(seen_events): - if seen_events[uuid].expired: - del seen_events[uuid] - - if self.event_stream and self.event_stream.active: - listeners = [loop_action_listener(action) for action in actions] - done, pending = await asyncio.wait(listeners, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - return done.pop().result() - - async def TriggerAndHandleEvent(self, basestation, resource, actions, trigger, callback): - """ - Use this method to subscribe to the event stream and provide a callback that will be called for event event received. - This function will allow you to potentially write a callback that can handle all of the events received from the event stream. - NOTE: Use this function if you need to run some code after subscribing to the eventstream, but before your callback to handle the events runs. - """ - if trigger is not None and not callable(trigger): - raise Exception('The trigger(self, camera) should be a callable function.') - if not callable(callback): - raise Exception('The callback(self, event) should be a callable function.') - - await self.Subscribe() - if trigger: - trigger(self) - - # NOTE: Calling HandleEvents() calls Subscribe() again, which basically turns into a no-op. Hackie I know, but it cleans up the code a bit. - return await self.HandleEvents(basestation, resource, actions, callback) - - def GetDevices(self, device_type=None, filter_provisioned=None): - """ - This method returns an array that contains the basestation, cameras, etc. and their metadata. - If you pass in a valid device type, as a string or a list, this method will return an array of just those devices that match that type. An example would be ['basestation', 'camera'] - To filter provisioned or unprovisioned devices pass in a True/False value for filter_provisioned. By default both types are returned. - """ - devices = self._getDevicesImpl() - if device_type: - devices = [ device for device in devices if device.get('deviceType') in device_type] - - if filter_provisioned is not None: - if filter_provisioned: - devices = [ device for device in devices if device.get("state") == 'provisioned'] - else: - devices = [ device for device in devices if device.get("state") != 'provisioned'] - - return devices - - @cached(cache=TTLCache(maxsize=1, ttl=60)) - def _getDevicesImpl(self): - devices = self.request.get(f'https://{self.BASE_URL}/hmsweb/v2/users/devices') - return devices - - def GetDeviceCapabilities(self, device: dict) -> dict: - return self._getDeviceCapabilitiesImpl(device['modelId'].lower(), device['interfaceVersion']) - - @cached(cache=TTLCache(maxsize=64, ttl=60)) - def _getDeviceCapabilitiesImpl(self, model_id: str, interface_version: str) -> dict: - return self.request.get( - f'https://{self.BASE_URL}/resources/capabilities/{model_id}/{model_id}_{interface_version}.json', - raw=True - ) - - async def StartStream(self, basestation, camera, mode="rtsp", eager=True): - """ - This function returns the url of the rtsp video stream. - This stream needs to be called within 30 seconds or else it becomes invalid. - It can be streamed with: ffmpeg -re -i 'rtsps://' -acodec copy -vcodec copy test.mp4 - The request to /users/devices/startStream returns: { url:rtsp://:443/vzmodulelive?egressToken=b&userAgent=iOS&cameraId=} - - If mode is set to "dash", returns the url to the mpd file for DASH streaming. Note that DASH - has very specific header requirements - see GetMPDHeaders() - - If 'eager' is True, will return the stream url without waiting for Arlo to report that - the stream has started. - """ - resource = f"cameras/{camera.get('deviceId')}" - - if mode not in ["rtsp", "dash"]: - raise ValueError("mode must be 'rtsp' or 'dash'") - - # nonlocal variable hack for Python 2.x. - class nl: - stream_url_dict = None - - def trigger(self): - ua = USER_AGENTS['arlo'] if mode == "rtsp" else USER_AGENTS["firefox"] - nl.stream_url_dict = self.request.post( - f'https://{self.BASE_URL}/hmsweb/users/devices/startStream', - params={ - "to": camera.get('parentId'), - "from": self.user_id + "_web", - "resource": "cameras/" + camera.get('deviceId'), - "action": "set", - "responseUrl": "", - "publishResponse": True, - "transId": self.genTransId(), - "properties": { - "activityState": "startUserStream", - "cameraId": camera.get('deviceId') - } - }, - headers={"xcloudId":camera.get('xCloudId'), 'User-Agent': ua} - ) - if mode == "rtsp": - nl.stream_url_dict['url'] = nl.stream_url_dict['url'].replace("rtsp://", "rtsps://") - else: - nl.stream_url_dict['url'] = nl.stream_url_dict['url'].replace(":80", "") - - if eager: - trigger(self) - return nl.stream_url_dict['url'] - - def callback(self, event): - #return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://") - if "error" in event: - return None - properties = event.get("properties", {}) - if properties.get("activityState") == "userStreamActive": - return nl.stream_url_dict['url'] - return None - - return await self.TriggerAndHandleEvent( - basestation, - resource, - [("is", "activityState")], - trigger, - callback, - ) - - def GetMPDHeaders(self, url: str) -> dict: - parsed = urlparse(url) - query = parse_qs(parsed.query) - - headers = { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate", - "Accept-Language": "en-US,en;q=0.9", - "Connection": "keep-alive", - "DNT": "1", - "Egress-Token": query['egressToken'][0], # this is very important - "Origin": "https://my.arlo.com", - "Referer": "https://my.arlo.com/", - "User-Agent": USER_AGENTS["firefox"], - } - return headers - - def GetSIPInfo(self): - resp = self.request.get(f'https://{self.BASE_URL}/hmsweb/users/devices/sipInfo') - return resp - - def GetSIPInfoV2(self, camera): - resp = self.request.get( - f'https://{self.BASE_URL}/hmsweb/users/devices/sipInfo/v2', - headers={ - "xcloudId": camera.get('xCloudId'), - "cameraId": camera.get('deviceId'), - } - ) - return resp - - def StartPushToTalk(self, basestation, camera): - url = f'https://{self.BASE_URL}/hmsweb/users/devices/{self.user_id}_{camera.get("deviceId")}/pushtotalk' - resp = self.request.get(url) - return resp.get("uSessionId"), resp.get("data") - - def NotifyPushToTalkSDP(self, basestation, camera, uSessionId, localSdp): - resource = f"cameras/{camera.get('deviceId')}" - - self.Notify(basestation, { - "action": "pushToTalk", - "resource": resource, - "publishResponse": True, - "properties": { - "data": localSdp, - "type": "offerSdp", - "uSessionId": uSessionId - } - }) - - def NotifyPushToTalkCandidate(self, basestation, camera, uSessionId, localCandidate): - resource = f"cameras/{camera.get('deviceId')}" - - self.Notify(basestation, { - "action": "pushToTalk", - "resource": resource, - "publishResponse": False, - "properties": { - "data": localCandidate, - "type": "offerCandidate", - "uSessionId": uSessionId - } - }) - - async def TriggerFullFrameSnapshot(self, basestation, camera): - """ - This function causes the camera to record a fullframe snapshot. - """ - resource = f"cameras/{camera.get('deviceId')}" - - def trigger(self): - self.request.post( - f"https://{self.BASE_URL}/hmsweb/users/devices/fullFrameSnapshot", - params={ - "to": camera.get("parentId"), - "from": self.user_id + "_web", - "resource": "cameras/" + camera.get("deviceId"), - "action": "set", - "publishResponse": True, - "transId": self.genTransId(), - "properties": { - "activityState": "fullFrameSnapshot" - } - }, - headers={"xcloudId":camera.get("xCloudId")} - ) - - def callback(self, event): - if "error" in event: - return None - properties = event.get("properties", {}) - url = properties.get("presignedFullFrameSnapshotUrl") - if url: - return url - url = properties.get("presignedLastImageUrl") - if url: - return url - return None - - return await self.TriggerAndHandleEvent( - basestation, - resource, - [ - (action, property) - for action in ["fullFrameSnapshotAvailable", "lastImageSnapshotAvailable", "is"] - for property in ["presignedFullFrameSnapshotUrl", "presignedLastImageUrl"] - ], - trigger, - callback, - ) - - def SirenOn(self, basestation, camera=None): - if camera is not None: - resource = f"siren/{camera.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "sirenState": "on", - "duration": 300, - "volume": 8, - "pattern": "alarm" - } - }) - return self.Notify(basestation, { - "action": "set", - "resource": "siren", - "publishResponse": True, - "properties": { - "sirenState": "on", - "duration": 300, - "volume": 8, - "pattern": "alarm" - } - }) - - def SirenOff(self, basestation, camera=None): - if camera is not None: - resource = f"siren/{camera.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "sirenState": "off", - "duration": 300, - "volume": 8, - "pattern": "alarm" - } - }) - return self.Notify(basestation, { - "action": "set", - "resource": "siren", - "publishResponse": True, - "properties": { - "sirenState": "off", - "duration": 300, - "volume": 8, - "pattern": "alarm" - } - }) - - def SpotlightOn(self, basestation, camera): - resource = f"cameras/{camera.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "spotlight": { - "enabled": True, - }, - }, - }) - - def SpotlightOff(self, basestation, camera): - resource = f"cameras/{camera.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "spotlight": { - "enabled": False, - }, - }, - }) - - def FloodlightOn(self, basestation, camera): - resource = f"cameras/{camera.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "floodlight": { - "on": True, - }, - }, - }) - - def FloodlightOff(self, basestation, camera): - resource = f"cameras/{camera.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "floodlight": { - "on": False, - }, - }, - }) - - def NightlightOn(self, basestation): - resource = f"cameras/{basestation.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "nightLight": { - "enabled": True - } - } - }) - - def NightlightOff(self, basestation): - resource = f"cameras/{basestation.get('deviceId')}" - return self.Notify(basestation, { - "action": "set", - "resource": resource, - "publishResponse": True, - "properties": { - "nightLight": { - "enabled": False - } - } - }) - - def GetLibrary(self, device, from_date: datetime, to_date: datetime): - """ - This call returns the following: - presignedContentUrl is a link to the actual video in Amazon AWS. - presignedThumbnailUrl is a link to the thumbnail .jpg of the actual video in Amazon AWS. - [ - { - "mediaDurationSecond": 30, - "contentType": "video/mp4", - "name": "XXXXXXXXXXXXX", - "presignedContentUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX.mp4?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "lastModified": 1472881430181, - "localCreatedDate": XXXXXXXXXXXXX, - "presignedThumbnailUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX_thumb.jpg?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "reason": "motionRecord", - "deviceId": "XXXXXXXXXXXXX", - "createdBy": "XXXXXXXXXXXXX", - "createdDate": "20160903", - "timeZone": "America/Chicago", - "ownerId": "XXX-XXXXXXX", - "utcCreatedDate": XXXXXXXXXXXXX, - "currentState": "new", - "mediaDuration": "00:00:30" - } - ] - """ - # give the query range a bit of buffer - from_date_internal = from_date - timedelta(days=1) - to_date_internal = to_date + timedelta(days=1) - - return [ - result for result in - self._getLibraryCached(from_date_internal.strftime("%Y%m%d"), to_date_internal.strftime("%Y%m%d")) - if result["deviceId"] == device["deviceId"] - and datetime.fromtimestamp(int(result["name"]) / 1000.0) <= to_date - and datetime.fromtimestamp(int(result["name"]) / 1000.0) >= from_date - ] - - @cached(cache=TTLCache(maxsize=512, ttl=60)) - def _getLibraryCached(self, from_date: str, to_date: str): - logger.debug(f"Library cache miss for {from_date}, {to_date}") - return self.request.post( - f'https://{self.BASE_URL}/hmsweb/users/library', - params={ - 'dateFrom': from_date, - 'dateTo': to_date - } - ) - - def GetSmartFeatures(self, device) -> dict: - smart_features = self._getSmartFeaturesCached() - key = f"{device['owner']['ownerId']}_{device['deviceId']}" - return smart_features["features"].get(key, {}) - - @cached(cache=TTLCache(maxsize=1, ttl=60)) - def _getSmartFeaturesCached(self) -> dict: - return self.request.get(f'https://{self.BASE_URL}/hmsweb/users/subscription/smart/features') \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/arlo/host_picker.py b/plugins/arlo/src/arlo_plugin/arlo/host_picker.py deleted file mode 100644 index 9deab7aeb..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/host_picker.py +++ /dev/null @@ -1,31 +0,0 @@ -import ssl -from socket import setdefaulttimeout -import requests -from requests_toolbelt.adapters import host_header_ssl -import scrypted_arlo_go - -from .logging import logger - - -setdefaulttimeout(15) - - -def pick_host(hosts, hostname_to_match, endpoint_to_test): - setdefaulttimeout(5) - - try: - session = requests.Session() - session.mount('https://', host_header_ssl.HostHeaderSSLAdapter()) - - for host in hosts: - try: - c = ssl.get_server_certificate((host, 443)) - scrypted_arlo_go.VerifyCertHostname(c, hostname_to_match) - r = session.post(f"https://{host}{endpoint_to_test}", headers={"Host": hostname_to_match}) - r.raise_for_status() - return host - except Exception as e: - logger.warning(f"{host} is invalid: {e}") - raise Exception("no valid hosts found!") - finally: - setdefaulttimeout(15) diff --git a/plugins/arlo/src/arlo_plugin/arlo/logging.py b/plugins/arlo/src/arlo_plugin/arlo/logging.py deleted file mode 100644 index c27edf093..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/logging.py +++ /dev/null @@ -1,16 +0,0 @@ -import logging -import sys - -# construct logger instance to be used by package arlo -logger = logging.getLogger("lib") -logger.setLevel(logging.INFO) - -# output logger to stdout -ch = logging.StreamHandler(sys.stdout) - -# log formatting -fmt = logging.Formatter("[Arlo]: %(message)s") -ch.setFormatter(fmt) - -# configure handler to logger -logger.addHandler(ch) \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/arlo/mqtt_stream_async.py b/plugins/arlo/src/arlo_plugin/arlo/mqtt_stream_async.py deleted file mode 100644 index 22eb1072e..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/mqtt_stream_async.py +++ /dev/null @@ -1,85 +0,0 @@ -import asyncio -import json -import random -import paho.mqtt.client as mqtt - -from .stream_async import Stream -from .logging import logger - -class MQTTStream(Stream): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.cached_topics = [] - - def _gen_client_number(self): - return random.randint(1000000000, 9999999999) - - async def start(self): - if self.event_stream is not None: - return - - def on_connect(client, userdata, flags, rc): - self.connected = True - self.initializing = False - - logger.info(f"MQTT {id(client)} connected") - - client.subscribe([ - (f"u/{self.arlo.user_id}/in/userSession/connect", 0), - (f"u/{self.arlo.user_id}/in/userSession/disconnect", 0), - ]) - - def on_disconnect(client, *args, **kwargs): - logger.info(f"MQTT {id(client)} disconnected") - - def on_message(client, userdata, msg): - payload = msg.payload.decode() - logger.debug(f"Received event: {payload}") - - try: - response = json.loads(payload.strip()) - except json.JSONDecodeError: - return - - if response.get('resource') is not None: - self.event_loop.call_soon_threadsafe(self._queue_response, response) - - self.event_stream = mqtt.Client(client_id=f"user_{self.arlo.user_id}_{self._gen_client_number()}", transport="websockets", clean_session=False) - self.event_stream.username_pw_set(self.arlo.user_id, password=self.arlo.request.session.headers.get('Authorization')) - self.event_stream.ws_set_options(path="/mqtt", headers={"Origin": "https://my.arlo.com"}) - self.event_stream.on_connect = on_connect - self.event_stream.on_disconnect = on_disconnect - self.event_stream.on_message = on_message - self.event_stream.tls_set() - self.event_stream.connect_async("mqtt-cluster.arloxcld.com", port=443) - self.event_stream.loop_start() - - while not self.connected and not self.event_stream_stop_event.is_set(): - await asyncio.sleep(0.5) - if not self.event_stream_stop_event.is_set(): - self.resubscribe() - - async def restart(self): - self.reconnecting = True - self.connected = False - self.event_stream.disconnect() - self.event_stream = None - await self.start() - # give it an extra sleep to ensure any previous connections have disconnected properly - # this is so we can mark reconnecting to False properly - await asyncio.sleep(1) - self.reconnecting = False - - def subscribe(self, topics): - if topics: - new_subscriptions = [(topic, 0) for topic in topics] - self.event_stream.subscribe(new_subscriptions) - self.cached_topics.extend(new_subscriptions) - - def resubscribe(self): - if self.cached_topics: - self.event_stream.subscribe(self.cached_topics) - - def disconnect(self): - super().disconnect() - self.event_stream.disconnect() \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/arlo/request.py b/plugins/arlo/src/arlo_plugin/arlo/request.py deleted file mode 100644 index 927547bea..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/request.py +++ /dev/null @@ -1,114 +0,0 @@ -## -# Copyright 2016 Jeffrey D. Walter -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -## - -from functools import partialmethod -import requests -from requests.exceptions import HTTPError -from requests_toolbelt.adapters import host_header_ssl -import cloudscraper -from curl_cffi import requests as curl_cffi_requests -import time -import uuid - -from .logging import logger - - - -#from requests_toolbelt.utils import dump -#def print_raw_http(response): -# data = dump.dump_all(response, request_prefix=b'', response_prefix=b'') -# print('\n' * 2 + data.decode('utf-8')) - -class Request(object): - """HTTP helper class""" - - def __init__(self, timeout=5, mode="curl"): - if mode == "curl": - logger.debug("HTTP helper using curl_cffi") - self.session = curl_cffi_requests.Session(impersonate="chrome110") - elif mode == "cloudscraper": - logger.debug("HTTP helper using cloudscraper") - from .arlo_async import USER_AGENTS - self.session = cloudscraper.CloudScraper(browser={"custom": USER_AGENTS["android"]}) - elif mode == "ip": - logger.debug("HTTP helper using requests with HostHeaderSSLAdapter") - self.session = requests.Session() - self.session.mount('https://', host_header_ssl.HostHeaderSSLAdapter()) - else: - logger.debug("HTTP helper using requests") - self.session = requests.Session() - self.timeout = timeout - - def gen_event_id(self): - return f'FE!{str(uuid.uuid4())}' - - def get_time(self): - return int(time.time_ns() / 1_000_000) - - def _request(self, url, method='GET', params={}, headers={}, raw=False, skip_event_id=False): - - ## uncomment for debug logging - """ - import logging - import http.client - http.client.HTTPConnection.debuglevel = 1 - #logging.basicConfig() - logging.getLogger().setLevel(logging.DEBUG) - req_log = logging.getLogger('requests.packages.urllib3') - req_log.setLevel(logging.DEBUG) - req_log.propagate = True - #""" - - if not skip_event_id: - url = f'{url}?eventId={self.gen_event_id()}&time={self.get_time()}' - - if method == 'GET': - #print('COOKIES: ', self.session.cookies.get_dict()) - r = self.session.get(url, params=params, headers=headers, timeout=self.timeout) - r.raise_for_status() - elif method == 'PUT': - r = self.session.put(url, json=params, headers=headers, timeout=self.timeout) - r.raise_for_status() - elif method == 'POST': - r = self.session.post(url, json=params, headers=headers, timeout=self.timeout) - r.raise_for_status() - elif method == 'OPTIONS': - r = self.session.options(url, headers=headers, timeout=self.timeout) - r.raise_for_status() - return - - body = r.json() - - if raw: - return body - else: - if ('success' in body and body['success'] == True) or ('meta' in body and body['meta']['code'] == 200): - if 'data' in body: - return body['data'] - else: - raise HTTPError('Request ({0} {1}) failed: {2}'.format(method, url, r.json()), response=r) - - def get(self, url, **kwargs): - return self._request(url, 'GET', **kwargs) - - def put(self, url, **kwargs): - return self._request(url, 'PUT', **kwargs) - - def post(self, url, **kwargs): - return self._request(url, 'POST', **kwargs) - - def options(self, url, **kwargs): - return self._request(url, 'OPTIONS', **kwargs) diff --git a/plugins/arlo/src/arlo_plugin/arlo/sse_stream_async.py b/plugins/arlo/src/arlo_plugin/arlo/sse_stream_async.py deleted file mode 100644 index 69e002332..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/sse_stream_async.py +++ /dev/null @@ -1,82 +0,0 @@ -import asyncio -import json -import threading - -import scrypted_arlo_go - -from .stream_async import Stream -from .logging import logger - - -class EventStream(Stream): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.shutting_down_stream = None # record the eventstream that is currently shutting down - - async def start(self): - if self.event_stream is not None: - return - - def thread_main(self): - event_stream = self.event_stream - while True: - try: - event = event_stream.Next() - except: - logger.info(f"SSE {event_stream.UUID} exited") - if self.shutting_down_stream is event_stream: - self.shutting_down_stream = None - return None - - logger.debug(f"Received event: {event}") - - if event.strip() == "": - continue - - try: - response = json.loads(event.strip()) - except json.JSONDecodeError: - continue - - if response.get('action') == 'logout': - if self.event_stream_stop_event.is_set() or \ - self.shutting_down_stream is event_stream: - logger.info(f"SSE {event_stream.UUID} disconnected") - self.shutting_down_stream = None - event_stream.Close() - return None - elif response.get('status') == 'connected': - if not self.connected: - logger.info(f"SSE {event_stream.UUID} connected") - self.initializing = False - self.connected = True - else: - self.event_loop.call_soon_threadsafe(self._queue_response, response) - - self.event_stream = scrypted_arlo_go.NewSSEClient( - 'https://myapi.arlo.com/hmsweb/client/subscribe?token='+self.arlo.request.session.headers.get('Authorization'), - scrypted_arlo_go.HeadersMap(self.arlo.request.session.headers) - ) - self.event_stream.Start() - self.event_stream_thread = threading.Thread(name="EventStream", target=thread_main, args=(self, )) - self.event_stream_thread.setDaemon(True) - self.event_stream_thread.start() - - while not self.connected and not self.event_stream_stop_event.is_set(): - await asyncio.sleep(0.5) - - async def restart(self): - self.reconnecting = True - self.connected = False - self.shutting_down_stream = self.event_stream - self.shutting_down_stream.Close() - self.event_stream = None - await self.start() - while self.shutting_down_stream is not None: - # ensure any previous connections have disconnected properly - # this is so we can mark reconnecting to False properly - await asyncio.sleep(1) - self.reconnecting = False - - def subscribe(self, topics): - pass \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/arlo/stream_async.py b/plugins/arlo/src/arlo_plugin/arlo/stream_async.py deleted file mode 100644 index a8e5d7b37..000000000 --- a/plugins/arlo/src/arlo_plugin/arlo/stream_async.py +++ /dev/null @@ -1,238 +0,0 @@ -# This file has been modified to support async semantics and better -# integration with scrypted. -# Original: https://github.com/jeffreydwalter/arlo - -## -# Copyright 2016 Jeffrey D. Walter -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -## - -import asyncio -import random -import threading -import time -import uuid - -from .logging import logger - -class Stream: - """This class provides a queue-based EventStream object.""" - def __init__(self, arlo, expire=5): - self.event_stream = None - self.initializing = True - self.connected = False - self.reconnecting = False - self.queues = {} - self.expire = expire - self.refresh = 0 - self.refresh_loop_signal = asyncio.Queue() - self.event_stream_stop_event = threading.Event() - self.event_stream_thread = None - self.arlo = arlo - self.event_loop = asyncio.get_event_loop() - self.event_loop.create_task(self._clean_queues()) - self.event_loop.create_task(self._refresh_interval()) - - def __del__(self): - self.disconnect() - - @property - def active(self): - """Represents if this stream is connected or in the process of reconnecting.""" - return self.connected or self.reconnecting - - async def _refresh_interval(self): - while not self.event_stream_stop_event.is_set(): - if self.refresh == 0: - # to avoid spinning, wait until an interval is set - signal = await self.refresh_loop_signal.get() - if signal is None: - # exit signal received from disconnect() - return - continue - - interval = self.refresh * 60 # interval in seconds from refresh in minutes - signal_task = asyncio.create_task(self.refresh_loop_signal.get()) - - # wait until either we receive a signal or the refresh interval expires - done, pending = await asyncio.wait([signal_task, asyncio.sleep(interval)], return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - - done_task = done.pop() - if done_task is signal_task and done_task.result() is None: - # exit signal received from disconnect() - return - - logger.info("Refreshing event stream") - await self.restart() - - def set_refresh_interval(self, interval): - self.refresh = interval - self.refresh_loop_signal.put_nowait(object()) - - async def _clean_queues(self): - interval = self.expire * 4 - - await asyncio.sleep(interval) - while not self.event_stream_stop_event.is_set(): - # since we interrupt the cleanup loop after every queue, there's - # a chance the self.queues dict is modified during iteration. - # so, we first make a copy of all the items of the dict and any - # new queues will be processed on the next loop through - queue_items = [i for i in self.queues.items()] - for key, q in queue_items: - if q.empty(): - continue - - items = [] - num_dropped = 0 - - while not q.empty(): - item = q.get_nowait() - q.task_done() - - if not item: - # exit signal received - return - - if item.expired: - num_dropped += 1 - continue - - items.append(item) - - for item in items: - q.put_nowait(item) - - if num_dropped > 0: - logger.debug(f"Cleaned {num_dropped} events from queue {key}") - - # cleanup is not urgent, so give other tasks a chance - await asyncio.sleep(0.1) - - await asyncio.sleep(interval) - - async def get(self, resource, action, property=None, skip_uuids={}): - if not property: - key = f"{resource}/{action}" - else: - key = f"{resource}/{action}/{property}" - - if key not in self.queues: - q = self.queues[key] = asyncio.Queue() - else: - q = self.queues[key] - - first_requeued = None - while True: - event = await q.get() - q.task_done() - - if not event: - # exit signal received - return None, action - - if first_requeued is not None and first_requeued is event: - # if we reach here, we've cycled through the whole queue - # and found nothing for us, so sleep and give the next - # subscriber a chance - q.put_nowait(event) - await asyncio.sleep(random.uniform(0, 0.01)) - continue - - if event.expired: - continue - elif event.uuid in skip_uuids: - q.put_nowait(event) - if first_requeued is None: - first_requeued = event - else: - return event, action - - async def start(self): - raise NotImplementedError() - - async def restart(self): - raise NotImplementedError() - - def subscribe(self, topics): - raise NotImplementedError() - - def _queue_response(self, response): - resource = response.get('resource') - action = response.get('action') - key = f"{resource}/{action}" - - now = time.time() - event = StreamEvent(response, now, now + self.expire) - self._queue_impl(key, event) - - # specialized setup for error responses - if 'error' in response: - key = f"{resource}/error" - self._queue_impl(key, event) - - # for optimized lookups, notify listeners of individual properties - properties = response.get('properties', {}) - for property in properties.keys(): - key = f"{resource}/{action}/{property}" - self._queue_impl(key, event) - - def _queue_impl(self, key, event): - if key not in self.queues: - q = self.queues[key] = asyncio.Queue() - else: - q = self.queues[key] - q.put_nowait(event) - - def requeue(self, event, resource, action, property=None): - if not property: - key = f"{resource}/{action}" - else: - key = f"{resource}/{action}/{property}" - self.queues[key].put_nowait(event) - - def disconnect(self): - if self.reconnecting: - # disconnect may be called when an old stream is being refreshed/restarted, - # so don't completely shut down if we are reconnecting - return - - self.connected = False - - def exit_queues(self): - for q in self.queues.values(): - q.put_nowait(None) - self.refresh_loop_signal.put_nowait(None) - self.event_loop.call_soon_threadsafe(exit_queues, self) - - self.event_stream_stop_event.set() - - -class StreamEvent: - item = None - timestamp = None - expiration = None - uuid = None - - def __init__(self, item, timestamp, expiration): - self.item = item - self.timestamp = timestamp - self.expiration = expiration - self.uuid = str(uuid.uuid4()) - - @property - def expired(self): - return time.time() > self.expiration \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/base.py b/plugins/arlo/src/arlo_plugin/base.py deleted file mode 100644 index 1409219c6..000000000 --- a/plugins/arlo/src/arlo_plugin/base.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import traceback -from typing import List, TYPE_CHECKING - -from scrypted_sdk import ScryptedDeviceBase -from scrypted_sdk.types import Device - -from .logging import ScryptedDeviceLoggerMixin -from .util import BackgroundTaskMixin - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - - -class ArloDeviceBase(ScryptedDeviceBase, ScryptedDeviceLoggerMixin, BackgroundTaskMixin): - nativeId: str = None - arlo_device: dict = None - arlo_basestation: dict = None - arlo_capabilities: dict = None - provider: ArloProvider = None - stop_subscriptions: bool = False - - def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None: - super().__init__(nativeId=nativeId) - - self.logger_name = nativeId - - self.nativeId = nativeId - self.arlo_device = arlo_device - self.arlo_basestation = arlo_basestation - self.provider = provider - self.logger.setLevel(self.provider.get_current_log_level()) - - try: - self.arlo_capabilities = self.provider.arlo.GetDeviceCapabilities(self.arlo_device) - except Exception as e: - self.logger.warning(f"Could not load device capabilities: {e}") - self.arlo_capabilities = {} - - def __del__(self) -> None: - self.stop_subscriptions = True - self.cancel_pending_tasks() - - def get_applicable_interfaces(self) -> List[str]: - """Returns the list of Scrypted interfaces that applies to this device.""" - return [] - - def get_device_type(self) -> str: - """Returns the Scrypted device type that applies to this device.""" - return "" - - def get_device_manifest(self) -> Device: - """Returns the Scrypted device manifest representing this device.""" - parent = None - if self.arlo_device.get("parentId") and self.arlo_device["parentId"] != self.arlo_device["deviceId"]: - parent = self.arlo_device["parentId"] - - if parent in self.provider.hidden_device_ids: - parent = None - - return { - "info": { - "model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(), - "manufacturer": "Arlo", - "firmware": self.arlo_device.get("firmwareVersion"), - "serialNumber": self.arlo_device["deviceId"], - }, - "nativeId": self.arlo_device["deviceId"], - "name": self.arlo_device["deviceName"], - "interfaces": self.get_applicable_interfaces(), - "type": self.get_device_type(), - "providerNativeId": parent, - } - - def get_builtin_child_device_manifests(self) -> List[Device]: - """Returns the list of child device manifests representing hardware features built into this device.""" - return [] \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/basestation.py b/plugins/arlo/src/arlo_plugin/basestation.py deleted file mode 100644 index 97d56b965..000000000 --- a/plugins/arlo/src/arlo_plugin/basestation.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from typing import List, TYPE_CHECKING - -from scrypted_sdk import ScryptedDeviceBase -from scrypted_sdk.types import Device, DeviceProvider, Setting, SettingValue, Settings, ScryptedInterface, ScryptedDeviceType - -from .base import ArloDeviceBase -from .vss import ArloSirenVirtualSecuritySystem - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - - -class ArloBasestation(ArloDeviceBase, DeviceProvider, Settings): - MODELS_WITH_SIRENS = [ - "vmb4000", - "vmb4500" - ] - - vss: ArloSirenVirtualSecuritySystem = None - - def __init__(self, nativeId: str, arlo_basestation: dict, provider: ArloProvider) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_basestation, arlo_basestation=arlo_basestation, provider=provider) - - @property - def has_siren(self) -> bool: - return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloBasestation.MODELS_WITH_SIRENS]) - - def get_applicable_interfaces(self) -> List[str]: - return [ - ScryptedInterface.DeviceProvider.value, - ScryptedInterface.Settings.value, - ] - - def get_device_type(self) -> str: - return ScryptedDeviceType.DeviceProvider.value - - def get_builtin_child_device_manifests(self) -> List[Device]: - if not self.has_siren: - # this basestation has no builtin siren, so no manifests to return - return [] - - vss = self.get_or_create_vss() - return [ - { - "info": { - "model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(), - "manufacturer": "Arlo", - "firmware": self.arlo_device.get("firmwareVersion"), - "serialNumber": self.arlo_device["deviceId"], - }, - "nativeId": vss.nativeId, - "name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System', - "interfaces": vss.get_applicable_interfaces(), - "type": vss.get_device_type(), - "providerNativeId": self.nativeId, - }, - ] + vss.get_builtin_child_device_manifests() - - async def getDevice(self, nativeId: str) -> ScryptedDeviceBase: - if not nativeId.startswith(self.nativeId): - # must be a camera, so get it from the provider - return await self.provider.getDevice(nativeId) - if not nativeId.endswith("vss"): - return None - return self.get_or_create_vss() - - def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem: - vss_id = f'{self.arlo_device["deviceId"]}.vss' - if not self.vss: - self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self) - return self.vss - - async def getSettings(self) -> List[Setting]: - return [ - { - "group": "General", - "key": "print_debug", - "title": "Debug Info", - "description": "Prints information about this device to console.", - "type": "button", - } - ] - - async def putSetting(self, key: str, value: SettingValue) -> None: - if key == "print_debug": - self.logger.info(f"Device Capabilities: {self.arlo_capabilities}") - await self.onDeviceEvent(ScryptedInterface.Settings.value, None) \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/camera.py b/plugins/arlo/src/arlo_plugin/camera.py deleted file mode 100644 index a810ea731..000000000 --- a/plugins/arlo/src/arlo_plugin/camera.py +++ /dev/null @@ -1,1049 +0,0 @@ -from __future__ import annotations - -from aioice import Candidate -from aiortc import RTCSessionDescription, RTCIceGatherer, RTCIceServer -from aiortc.rtcicetransport import candidate_to_aioice, candidate_from_aioice -import asyncio -import aiohttp -from async_timeout import timeout as async_timeout -from datetime import datetime, timedelta -import json -import socket -import time -import threading -from typing import List, TYPE_CHECKING - -import scrypted_arlo_go - -import scrypted_sdk -from scrypted_sdk.types import Setting, Settings, SettingValue, Device, Camera, VideoCamera, RequestMediaStreamOptions, VideoClips, VideoClip, VideoClipOptions, MotionSensor, AudioSensor, Battery, Charger, ChargeState, DeviceProvider, MediaObject, ResponsePictureOptions, ResponseMediaStreamOptions, ScryptedMimeTypes, ScryptedInterface, ScryptedDeviceType - -from .experimental import EXPERIMENTAL -from .arlo.arlo_async import USER_AGENTS -from .base import ArloDeviceBase -from .spotlight import ArloSpotlight, ArloFloodlight, ArloNightlight -from .vss import ArloSirenVirtualSecuritySystem -from .child_process import HeartbeatChildProcess -from .util import BackgroundTaskMixin, async_print_exception_guard -from .rtcpeerconnection import BackgroundRTCPeerConnection - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - - -class ArloCameraIntercomSession(BackgroundTaskMixin): - def __init__(self, camera: ArloCamera) -> None: - super().__init__() - self.camera = camera - self.logger = camera.logger - self.provider = camera.provider - self.arlo_device = camera.arlo_device - self.arlo_basestation = camera.arlo_basestation - - async def initialize_push_to_talk(self, media: MediaObject) -> None: - raise NotImplementedError("not implemented") - - async def shutdown(self) -> None: - raise NotImplementedError("not implemented") - - -class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider, VideoClips, MotionSensor, AudioSensor, Battery, Charger): - MODELS_WITH_SPOTLIGHTS = [ - "vmc2030", - "vmc2032", - "vmc4040p", - "vmc4041p", - "vmc4050p", - "vmc4060p", - "vmc5040", - "vml2030", - "vml4030", - ] - - MODELS_WITH_FLOODLIGHTS = ["fb1001"] - - MODELS_WITH_NIGHTLIGHTS = [ - "abc1000", - "abc1000a", - ] - - MODELS_WITH_SIRENS = [ - "fb1001", - "vmc2020", - "vmc2030", - "vmc2032", - "vmc4030", - "vmc4030p", - "vmc4040p", - "vmc4041p", - "vmc4050p", - "vmc4060p", - "vmc5040", - "vml2030", - "vml4030", - ] - - MODELS_WITH_AUDIO_SENSORS = [ - "abc1000", - "abc1000a", - "fb1001", - "vmc3040", - "vmc3040s", - "vmc4030", - "vmc4030p", - "vmc4040p", - "vmc4041p", - "vmc4050p", - "vmc5040", - "vml4030", - ] - - MODELS_WITHOUT_BATTERY = [ - "avd1001", - "vmc2040", - "vmc3040", - "vmc3040s", - ] - - PTT_IMPL_CHOICES = [ - "scrypted-arlo-go", - "aiortc", - ] - - timeout: int = 30 - intercom_session: ArloCameraIntercomSession = None - light: ArloSpotlight = None - vss: ArloSirenVirtualSecuritySystem = None - - # eco mode bookkeeping - picture_lock: asyncio.Lock = None - last_picture: bytes = None - last_picture_time: datetime = datetime(1970, 1, 1) - - # socket logger - logger_loop: asyncio.AbstractEventLoop = None - logger_server: asyncio.AbstractServer = None - logger_server_port: int = 0 - - def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider) - self.picture_lock = asyncio.Lock() - - self.start_error_subscription() - self.start_motion_subscription() - self.start_audio_subscription() - self.start_battery_subscription() - self.create_task(self.delayed_init()) - - def __del__(self) -> None: - super().__del__() - def logger_exit_callback(): - self.logger_server.close() - self.logger_loop.stop() - self.logger_loop.close() - self.logger_loop.call_soon_threadsafe(logger_exit_callback) - - async def delayed_init(self) -> None: - await self.create_tcp_logger_server() - - if not self.has_battery: - return - - iterations = 1 - while not self.stop_subscriptions: - if iterations > 100: - self.logger.error("Delayed init exceeded iteration limit, giving up") - return - - try: - self.chargeState = ChargeState.Charging.value if self.wired_to_power else ChargeState.NotCharging.value - return - except Exception as e: - self.logger.debug(f"Delayed init failed, will try again: {e}") - await asyncio.sleep(0.1) - iterations += 1 - - @async_print_exception_guard - async def create_tcp_logger_server(self) -> None: - self.logger_loop = asyncio.new_event_loop() - - def thread_main(): - asyncio.set_event_loop(self.logger_loop) - self.logger_loop.run_forever() - - threading.Thread(target=thread_main).start() - - # this is a bit convoluted since we need the async functions to run in the - # logger loop thread instead of in the current thread - def setup_callback(): - async def callback(reader, writer): - try: - while not reader.at_eof(): - line = await reader.readline() - if not line: - break - line = str(line, 'utf-8') - line = line.rstrip() - self.logger.info(line) - writer.close() - await writer.wait_closed() - except Exception: - self.logger.exception("Logger server callback raised an exception") - - async def setup(): - self.logger_server = await asyncio.start_server(callback, host='localhost', port=0, family=socket.AF_INET, flags=socket.SOCK_STREAM) - self.logger_server_port = self.logger_server.sockets[0].getsockname()[1] - self.logger.info(f"Started logging server at localhost:{self.logger_server_port}") - - self.logger_loop.create_task(setup()) - - self.logger_loop.call_soon_threadsafe(setup_callback) - - - def start_error_subscription(self) -> None: - def callback(code, message): - self.logger.error(f"Arlo returned error code {code} with message: {message}") - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToErrorEvents(self.arlo_basestation, self.arlo_device, callback) - ) - - def start_motion_subscription(self) -> None: - def callback(motionDetected): - self.motionDetected = motionDetected - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToMotionEvents(self.arlo_basestation, self.arlo_device, callback, self.logger) - ) - - def start_audio_subscription(self) -> None: - if not self.has_audio_sensor: - return - - def callback(audioDetected): - self.audioDetected = audioDetected - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToAudioEvents(self.arlo_basestation, self.arlo_device, callback, self.logger) - ) - - def start_battery_subscription(self) -> None: - if not self.has_battery: - return - - def callback(batteryLevel): - self.batteryLevel = batteryLevel - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToBatteryEvents(self.arlo_basestation, self.arlo_device, callback) - ) - - def get_applicable_interfaces(self) -> List[str]: - results = set([ - ScryptedInterface.VideoCamera.value, - ScryptedInterface.Camera.value, - ScryptedInterface.MotionSensor.value, - ScryptedInterface.Settings.value, - ]) - - if self.has_push_to_talk: - results.add(ScryptedInterface.Intercom.value) - - if self.has_battery: - results.add(ScryptedInterface.Battery.value) - results.add(ScryptedInterface.Charger.value) - - if self.has_siren or self.has_spotlight or self.has_floodlight: - results.add(ScryptedInterface.DeviceProvider.value) - - if self.has_audio_sensor: - results.add(ScryptedInterface.AudioSensor.value) - - if self.has_cloud_recording: - results.add(ScryptedInterface.VideoClips.value) - - return list(results) - - def get_device_type(self) -> str: - return ScryptedDeviceType.Camera.value - - def get_builtin_child_device_manifests(self) -> List[Device]: - results = [] - if self.has_spotlight or self.has_floodlight or self.has_nightlight: - light = self.get_or_create_light() - results.append({ - "info": { - "model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(), - "manufacturer": "Arlo", - "firmware": self.arlo_device.get("firmwareVersion"), - "serialNumber": self.arlo_device["deviceId"], - }, - "nativeId": light.nativeId, - "name": f'{self.arlo_device["deviceName"]} {"Spotlight" if self.has_spotlight else "Floodlight" if self.has_floodlight else "Nightlight"}', - "interfaces": light.get_applicable_interfaces(), - "type": light.get_device_type(), - "providerNativeId": self.nativeId, - }) - if self.has_siren: - vss = self.get_or_create_vss() - results.extend([ - { - "info": { - "model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(), - "manufacturer": "Arlo", - "firmware": self.arlo_device.get("firmwareVersion"), - "serialNumber": self.arlo_device["deviceId"], - }, - "nativeId": vss.nativeId, - "name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System', - "interfaces": vss.get_applicable_interfaces(), - "type": vss.get_device_type(), - "providerNativeId": self.nativeId, - }, - ] + vss.get_builtin_child_device_manifests()) - return results - - @property - def wired_to_power(self) -> bool: - if self.storage: - return True if self.storage.getItem("wired_to_power") else False - else: - return False - - @property - def eco_mode(self) -> bool: - if self.storage: - return True if self.storage.getItem("eco_mode") else False - else: - return False - - @property - def disable_eager_streams(self) -> bool: - if self.storage: - return True if self.storage.getItem("disable_eager_streams") else False - else: - return False - - @property - def ptt_impl(self) -> str: - impl = self.storage.getItem("ptt_impl") - if impl is None: - impl = ArloCamera.PTT_IMPL_CHOICES[0] - #self.storage.setItem("ptt_impl", impl) - return impl - - @property - def snapshot_throttle_interval(self) -> int: - interval = self.storage.getItem("snapshot_throttle_interval") - if interval is None: - interval = 60 - self.storage.setItem("snapshot_throttle_interval", interval) - return int(interval) - - @property - def has_cloud_recording(self) -> bool: - return self.provider.arlo.GetSmartFeatures(self.arlo_device).get("planFeatures", {}).get("eventRecording", False) - - @property - def has_spotlight(self) -> bool: - return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_SPOTLIGHTS]) - - @property - def has_floodlight(self) -> bool: - return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_FLOODLIGHTS]) - - @property - def has_nightlight(self) -> bool: - return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_NIGHTLIGHTS]) - - @property - def has_siren(self) -> bool: - return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_SIRENS]) - - @property - def has_audio_sensor(self) -> bool: - return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_AUDIO_SENSORS]) - - @property - def has_battery(self) -> bool: - return not any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITHOUT_BATTERY]) - - @property - def has_push_to_talk(self) -> bool: - return bool(self.arlo_capabilities.get("Capabilities", {}).get("PushToTalk", {}).get("fullDuplex")) - - @property - def uses_sip_push_to_talk(self) -> bool: - return "sip" in self.arlo_capabilities.get("Capabilities", {}).get("PushToTalk", {}).get("signal", []) - - async def getSettings(self) -> List[Setting]: - result = [] - if self.has_battery: - result.append( - { - "group": "General", - "key": "wired_to_power", - "title": "Plugged In to External Power", - "value": self.wired_to_power, - "description": "Informs Scrypted that this device is plugged in to an external power source. " + \ - "Will allow features like persistent prebuffer to work. " + \ - "Note that a persistent prebuffer may cause excess battery drain if the external power is not able to charge faster than the battery consumption rate.", - "type": "boolean", - }, - ) - result.extend([ - { - "group": "General", - "key": "eco_mode", - "title": "Eco Mode", - "value": self.eco_mode, - "description": "Configures Scrypted to limit the number of requests made to this camera. " + \ - "Additional eco mode settings will appear when this is turned on.", - "type": "boolean", - }, - { - "group": "General", - "key": "disable_eager_streams", - "title": "Disable Eager Streams", - "value": self.disable_eager_streams, - "description": "If eager streams are disabled, Scrypted will wait for Arlo Cloud to report that " + \ - "the camera stream has started before passing the stream URL to downstream consumers.", - "type": "boolean", - }, - ]) - if self.has_push_to_talk and EXPERIMENTAL: - result.append({ - "group": "General", - "key": "ptt_impl", - "title": "Two Way Audio Implementation", - "value": self.ptt_impl, - "description": "Implementation used to perform two-way audio negotiation.", - "choices": ArloCamera.PTT_IMPL_CHOICES, - }) - if self.eco_mode: - result.append( - { - "group": "Eco Mode", - "key": "snapshot_throttle_interval", - "title": "Snapshot Throttle Interval", - "value": self.snapshot_throttle_interval, - "description": "Time, in minutes, to throttle snapshot requests. " + \ - "When eco mode is on, snapshot requests to the camera will be throttled for the given duration. " + \ - "Cached snapshots may be returned if the time since the last snapshot has not exceeded the interval. " + \ - "A value of 0 will disable throttling even when eco mode is on.", - "type": "number", - } - ) - result.append( - { - "group": "General", - "key": "print_debug", - "title": "Debug Info", - "description": "Prints information about this device to console.", - "type": "button", - } - ) - return result - - @async_print_exception_guard - async def putSetting(self, key: str, value: SettingValue) -> None: - if not self.validate_setting(key, value): - await self.onDeviceEvent(ScryptedInterface.Settings.value, None) - return - - if key in ["wired_to_power"]: - self.storage.setItem(key, value == "true" or value == True) - await self.provider.discover_devices() - elif key in ["eco_mode", "disable_eager_streams"]: - self.storage.setItem(key, value == "true" or value == True) - elif key == "print_debug": - self.logger.info(f"Device Capabilities: {self.arlo_capabilities}") - else: - self.storage.setItem(key, value) - await self.onDeviceEvent(ScryptedInterface.Settings.value, None) - - def validate_setting(self, key: str, val: SettingValue) -> bool: - if key == "snapshot_throttle_interval": - try: - val = int(val) - except ValueError: - self.logger.error(f"Invalid snapshot throttle interval '{val}' - must be an integer") - return False - return True - - async def getPictureOptions(self) -> List[ResponsePictureOptions]: - return [] - - @async_print_exception_guard - async def takePicture(self, options: dict = None) -> MediaObject: - self.logger.info("Taking picture") - - real_device = await scrypted_sdk.systemManager.api.getDeviceById(self.getScryptedProperty("id")) - msos = await real_device.getVideoStreamOptions() - if any(["prebuffer" in m for m in msos]): - self.logger.info("Getting snapshot from prebuffer") - try: - return await real_device.getVideoStream({"refresh": False}) - except Exception as e: - self.logger.warning(f"Could not fetch from prebuffer due to: {e}") - self.logger.warning("Will try to fetch snapshot from Arlo cloud") - - async with self.picture_lock: - if self.eco_mode and self.snapshot_throttle_interval > 0: - if datetime.now() - self.last_picture_time <= timedelta(minutes=self.snapshot_throttle_interval): - self.logger.info("Using cached image") - return await scrypted_sdk.mediaManager.createMediaObject(self.last_picture, "image/jpeg") - - pic_url = await asyncio.wait_for(self.provider.arlo.TriggerFullFrameSnapshot(self.arlo_basestation, self.arlo_device), timeout=self.timeout) - self.logger.debug(f"Got snapshot URL at {pic_url}") - - if pic_url is None: - raise Exception("Error taking snapshot: no url returned") - - async with async_timeout(self.timeout): - async with aiohttp.ClientSession() as session: - async with session.get(pic_url) as resp: - if resp.status != 200: - raise Exception(f"Unexpected status downloading snapshot image: {resp.status}") - self.last_picture = await resp.read() - self.last_picture_time = datetime.now() - - return await scrypted_sdk.mediaManager.createMediaObject(self.last_picture, "image/jpeg") - - async def getVideoStreamOptions(self, id: str = None) -> List[ResponseMediaStreamOptions]: - options = [ - { - "id": 'default', - "name": 'Cloud RTSP', - "container": 'rtsp', - "video": { - "codec": 'h264', - }, - "audio": None if self.arlo_device.get("modelId") == "VMC3030" else { - "codec": 'aac', - }, - "source": 'cloud', - "tool": 'scrypted', - "userConfigurable": False, - }, - { - "id": 'dash', - "name": 'Cloud DASH', - "container": 'dash', - "video": { - "codec": 'unknown', - }, - "audio": None if self.arlo_device.get("modelId") == "VMC3030" else { - "codec": 'unknown', - }, - "source": 'cloud', - "tool": 'ffmpeg', - "userConfigurable": False, - } - ] - - if id is None: - return options - - return next(iter([o for o in options if o['id'] == id])) - - async def _getVideoStreamURL(self, container: str) -> str: - self.logger.info(f"Requesting {container} stream") - url = await asyncio.wait_for(self.provider.arlo.StartStream(self.arlo_basestation, self.arlo_device, mode=container, eager=not self.disable_eager_streams), timeout=self.timeout) - self.logger.debug(f"Got {container} stream URL at {url}") - return url - - @async_print_exception_guard - async def getVideoStream(self, options: RequestMediaStreamOptions = {}) -> MediaObject: - self.logger.debug("Entered getVideoStream") - - mso = await self.getVideoStreamOptions(id=options.get("id", "default")) - mso['refreshAt'] = round(time.time() * 1000) + 30 * 60 * 1000 - container = mso["container"] - - url = await self._getVideoStreamURL(container) - additional_ffmpeg_args = [] - - if container == "dash": - headers = self.provider.arlo.GetMPDHeaders(url) - ffmpeg_headers = '\r\n'.join([ - f'{k}: {v}' - for k, v in headers.items() - ]) - additional_ffmpeg_args = ['-headers', ffmpeg_headers+'\r\n'] - - ffmpeg_input = { - 'url': url, - 'container': container, - 'mediaStreamOptions': mso, - 'inputArguments': [ - '-f', container, - *additional_ffmpeg_args, - '-i', url, - ] - } - return await scrypted_sdk.mediaManager.createFFmpegMediaObject(ffmpeg_input) - - @async_print_exception_guard - async def startIntercom(self, media: MediaObject) -> None: - self.logger.info("Starting intercom") - - if self.uses_sip_push_to_talk: - # signaling happens over sip - self.intercom_session = ArloCameraSIPIntercomSession(self) - else: - # we need to do signaling through arlo cloud apis - if self.ptt_impl == "scrypted-arlo-go": - self.intercom_session = ArloCameraWebRTCIntercomSession(self) - else: - self.intercom_session = ArloCameraPyAVIntercomSession(self) - await self.intercom_session.initialize_push_to_talk(media) - - self.logger.info("Intercom initialized") - - @async_print_exception_guard - async def stopIntercom(self) -> None: - self.logger.info("Stopping intercom") - if self.intercom_session is not None: - await self.intercom_session.shutdown() - self.intercom_session = None - - async def getVideoClip(self, videoId: str) -> MediaObject: - self.logger.info(f"Getting video clip {videoId}") - - id_as_time = int(videoId) / 1000.0 - start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10) - end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10) - - library = self.provider.arlo.GetLibrary(self.arlo_device, start, end) - for recording in library: - if videoId == recording["name"]: - return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedContentUrl"]) - self.logger.warn(f"Clip {videoId} not found") - return None - - async def getVideoClipThumbnail(self, thumbnailId: str) -> MediaObject: - self.logger.info(f"Getting video clip thumbnail {thumbnailId}") - - id_as_time = int(thumbnailId) / 1000.0 - start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10) - end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10) - - library = self.provider.arlo.GetLibrary(self.arlo_device, start, end) - for recording in library: - if thumbnailId == recording["name"]: - return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedThumbnailUrl"]) - self.logger.warn(f"Clip thumbnail {thumbnailId} not found") - return None - - async def getVideoClips(self, options: VideoClipOptions = None) -> List[VideoClip]: - self.logger.info(f"Fetching remote video clips {options}") - - start = datetime.fromtimestamp(options["startTime"] / 1000.0) - end = datetime.fromtimestamp(options["endTime"] / 1000.0) - - library = self.provider.arlo.GetLibrary(self.arlo_device, start, end) - clips = [] - for recording in library: - clip = { - "duration": recording["mediaDurationSecond"] * 1000.0, - "id": recording["name"], - "thumbnailId": recording["name"], - "videoId": recording["name"], - "startTime": recording["utcCreatedDate"], - "description": recording["reason"], - "resources": { - "thumbnail": { - "href": recording["presignedThumbnailUrl"], - }, - "video": { - "href": recording["presignedContentUrl"], - }, - }, - } - clips.append(clip) - - if options.get("reverseOrder"): - clips.reverse() - return clips - - @async_print_exception_guard - async def removeVideoClips(self, videoClipIds: List[str]) -> None: - # Arlo Cloud does support deleting, but let's be safe and not expose that here - raise Exception("deleting Arlo video clips is not implemented by this plugin - please delete clips through the Arlo app") - - async def getDevice(self, nativeId: str) -> ArloDeviceBase: - if (nativeId.endswith("spotlight") and self.has_spotlight) or (nativeId.endswith("floodlight") and self.has_floodlight) or (nativeId.endswith("nightlight") and self.has_nightlight): - return self.get_or_create_light() - if nativeId.endswith("vss") and self.has_siren: - return self.get_or_create_vss() - return None - - def get_or_create_light(self) -> ArloSpotlight: - if self.has_spotlight: - light_id = f'{self.arlo_device["deviceId"]}.spotlight' - if not self.light: - self.light = ArloSpotlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self) - elif self.has_floodlight: - light_id = f'{self.arlo_device["deviceId"]}.floodlight' - if not self.light: - self.light = ArloFloodlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self) - elif self.has_nightlight: - light_id = f'{self.arlo_device["deviceId"]}.nightlight' - if not self.light: - self.light = ArloNightlight(light_id, self.arlo_device, self.provider, self) - return self.light - - def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem: - if self.has_siren: - vss_id = f'{self.arlo_device["deviceId"]}.vss' - if not self.vss: - self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self) - return self.vss - - -class ArloCameraWebRTCIntercomSession(ArloCameraIntercomSession): - def __init__(self, camera: ArloCamera) -> None: - super().__init__(camera) - - self.arlo_pc = None - self.arlo_sdp_answered = False - - self.intercom_ffmpeg_subprocess = None - - self.stop_subscriptions = False - self.start_sdp_answer_subscription() - self.start_candidate_answer_subscription() - - def __del__(self) -> None: - self.stop_subscriptions = True - self.cancel_pending_tasks() - - def start_sdp_answer_subscription(self) -> None: - def callback(sdp): - if self.arlo_pc and not self.arlo_sdp_answered: - if "a=mid:" not in sdp: - # arlo appears to not return a mux id in the response, which - # doesn't play nicely with our webrtc peers. let's add it - sdp += "a=mid:0\r\n" - self.logger.info(f"Arlo response sdp:\n{sdp}") - - sdp = scrypted_arlo_go.WebRTCSessionDescription(scrypted_arlo_go.NewWebRTCSDPType("answer"), sdp) - self.arlo_pc.SetRemoteDescription(sdp) - self.arlo_sdp_answered = True - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToSDPAnswers(self.arlo_basestation, self.arlo_device, callback) - ) - - def start_candidate_answer_subscription(self) -> None: - def callback(candidate): - if self.arlo_pc: - prefix = "a=candidate:" - if candidate.startswith(prefix): - candidate = candidate[len(prefix):] - candidate = candidate.strip() - self.logger.info(f"Arlo response candidate: {candidate}") - - candidate = scrypted_arlo_go.WebRTCICECandidateInit(candidate, "0", 0) - self.arlo_pc.AddICECandidate(candidate) - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToCandidateAnswers(self.arlo_basestation, self.arlo_device, callback) - ) - - @async_print_exception_guard - async def initialize_push_to_talk(self, media: MediaObject) -> None: - self.logger.info("Initializing push to talk") - - session_id, ice_servers = self.provider.arlo.StartPushToTalk(self.arlo_basestation, self.arlo_device) - self.logger.debug(f"Received ice servers: {[ice['url'] for ice in ice_servers]}") - - ice_servers = scrypted_arlo_go.Slice_webrtc_ICEServer([ - scrypted_arlo_go.NewWebRTCICEServer( - scrypted_arlo_go.go.Slice_string([ice['url']]), - ice.get('username', ''), - ice.get('credential', '') - ) - for ice in ice_servers - ]) - - self.arlo_pc = scrypted_arlo_go.NewWebRTCManager(self.camera.logger_server_port, ice_servers) - - ffmpeg_params = json.loads(await scrypted_sdk.mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput.value)) - self.logger.debug(f"Received ffmpeg params: {ffmpeg_params}") - audio_port = self.arlo_pc.InitializeAudioRTPListener(scrypted_arlo_go.WebRTCMimeTypeOpus) - - ffmpeg_path = await scrypted_sdk.mediaManager.getFFmpegPath() - ffmpeg_args = [ - "-y", - "-hide_banner", - "-loglevel", "error", - "-analyzeduration", "0", - "-fflags", "-nobuffer", - "-probesize", "500000", - *ffmpeg_params["inputArguments"], - "-acodec", "libopus", - "-flags", "+global_header", - "-vbr", "off", - "-ar", "48k", - "-b:a", "32k", - "-bufsize", "96k", - "-ac", "2", - "-application", "lowdelay", - "-dn", "-sn", "-vn", - "-frame_duration", "20", - "-f", "rtp", - "-flush_packets", "1", - f"rtp://localhost:{audio_port}?pkt_size={scrypted_arlo_go.UDP_PACKET_SIZE()}", - ] - self.logger.debug(f"Starting ffmpeg at {ffmpeg_path} with '{' '.join(ffmpeg_args)}'") - - self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("FFmpeg", self.camera.logger_server_port, ffmpeg_path, *ffmpeg_args) - self.intercom_ffmpeg_subprocess.start() - - self.sdp_answered = False - - offer = self.arlo_pc.CreateOffer() - offer_sdp = scrypted_arlo_go.WebRTCSessionDescriptionSDP(offer) - self.logger.info(f"Arlo offer sdp:\n{offer_sdp}") - - self.arlo_pc.SetLocalDescription(offer) - - self.provider.arlo.NotifyPushToTalkSDP( - self.arlo_basestation, self.arlo_device, - session_id, offer_sdp - ) - - def trickle_candidates(): - count = 0 - try: - while True: - candidate = self.arlo_pc.GetNextICECandidate() - candidate = scrypted_arlo_go.WebRTCICECandidateInit( - scrypted_arlo_go.WebRTCICECandidate(handle=candidate.handle).ToJSON() - ).Candidate - self.logger.debug(f"Sending candidate to Arlo: {candidate}") - self.provider.arlo.NotifyPushToTalkCandidate( - self.arlo_basestation, self.arlo_device, - session_id, candidate, - ) - count += 1 - except RuntimeError as e: - if str(e) == "no more candidates": - self.logger.debug(f"End of candidates, found {count} candidate(s)") - else: - self.logger.exception("Exception while processing trickle candidates") - except Exception: - self.logger.exception("Exception while processing trickle candidates") - - # we can trickle candidates asynchronously so the caller to startIntercom - # knows we are ready to receive packets - threading.Thread(target=trickle_candidates).start() - - @async_print_exception_guard - async def shutdown(self) -> None: - if self.intercom_ffmpeg_subprocess is not None: - self.intercom_ffmpeg_subprocess.stop() - self.intercom_ffmpeg_subprocess = None - if self.arlo_pc is not None: - self.arlo_pc.Close() - self.arlo_pc = None - - -class ArloCameraSIPIntercomSession(ArloCameraIntercomSession): - def __init__(self, camera: ArloCamera) -> None: - super().__init__(camera) - - self.arlo_sip = None - self.intercom_ffmpeg_subprocess = None - - @async_print_exception_guard - async def initialize_push_to_talk(self, media: MediaObject) -> None: - self.logger.info("Initializing push to talk") - - sip_info = self.provider.arlo.GetSIPInfo() - sip_call_info = sip_info["sipCallInfo"] - - # though GetSIPInfo returns ice servers, there doesn't seem to be any indication - # that they are used on the arlo web dashboard, so just use what Chrome inserts - ice_servers = [{"url": "stun:stun.l.google.com:19302"}] - self.logger.debug(f"Will use ice servers: {[ice['url'] for ice in ice_servers]}") - - ice_servers = scrypted_arlo_go.Slice_webrtc_ICEServer([ - scrypted_arlo_go.NewWebRTCICEServer( - scrypted_arlo_go.go.Slice_string([ice['url']]), - ice.get('username', ''), - ice.get('credential', '') - ) - for ice in ice_servers - ]) - sip_cfg = scrypted_arlo_go.SIPInfo( - DeviceID=self.camera.nativeId, - CallerURI=f"sip:{sip_call_info['id']}@{sip_call_info['domain']}:{sip_call_info['port']}", - CalleeURI=sip_call_info['calleeUri'], - Password=sip_call_info['password'], - UserAgent="SIP.js/0.20.1", - WebsocketURI="wss://livestream-z2-prod.arlo.com:7443", - WebsocketOrigin="https://my.arlo.com", - WebsocketHeaders=scrypted_arlo_go.HeadersMap({"User-Agent": USER_AGENTS["arlo"]}), - ) - - self.arlo_sip = scrypted_arlo_go.NewSIPWebRTCManager(self.camera.logger_server_port, ice_servers, sip_cfg) - - ffmpeg_params = json.loads(await scrypted_sdk.mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput.value)) - self.logger.debug(f"Received ffmpeg params: {ffmpeg_params}") - audio_port = self.arlo_sip.InitializeAudioRTPListener(scrypted_arlo_go.WebRTCMimeTypeOpus) - - ffmpeg_path = await scrypted_sdk.mediaManager.getFFmpegPath() - ffmpeg_args = [ - "-y", - "-hide_banner", - "-loglevel", "error", - "-analyzeduration", "0", - "-fflags", "-nobuffer", - "-probesize", "500000", - *ffmpeg_params["inputArguments"], - "-acodec", "libopus", - "-flags", "+global_header", - "-vbr", "off", - "-ar", "48k", - "-b:a", "32k", - "-bufsize", "96k", - "-ac", "2", - "-application", "lowdelay", - "-dn", "-sn", "-vn", - "-frame_duration", "20", - "-f", "rtp", - "-flush_packets", "1", - f"rtp://localhost:{audio_port}?pkt_size={scrypted_arlo_go.UDP_PACKET_SIZE()}", - ] - self.logger.debug(f"Starting ffmpeg at {ffmpeg_path} with '{' '.join(ffmpeg_args)}'") - - self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("FFmpeg", self.camera.logger_server_port, ffmpeg_path, *ffmpeg_args) - self.intercom_ffmpeg_subprocess.start() - - def sip_start(): - try: - self.arlo_sip.Start() - except Exception: - self.logger.exception("Exception starting sip call") - - # do remaining setup asynchronously so the caller to startIntercom - # can start sending packets - threading.Thread(target=sip_start).start() - - @async_print_exception_guard - async def shutdown(self) -> None: - if self.intercom_ffmpeg_subprocess is not None: - self.intercom_ffmpeg_subprocess.stop() - self.intercom_ffmpeg_subprocess = None - if self.arlo_sip is not None: - self.arlo_sip.Close() - self.arlo_sip = None - -class ArloCameraPyAVIntercomSession(ArloCameraWebRTCIntercomSession): - def start_sdp_answer_subscription(self) -> None: - def callback(sdp): - if self.arlo_pc and not self.arlo_sdp_answered: - if "a=mid:" not in sdp: - # arlo appears to not return a mux id in the response, which - # doesn't play nicely with our webrtc peers. let's add it - sdp += "a=mid:0\r\n" - self.logger.info(f"Arlo response sdp:\n{sdp}") - - sdp = RTCSessionDescription(sdp=sdp, type="answer") - self.create_task(self.arlo_pc.setRemoteDescription(sdp)) - self.arlo_sdp_answered = True - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToSDPAnswers(self.arlo_basestation, self.arlo_device, callback) - ) - - def start_candidate_answer_subscription(self) -> None: - def callback(candidate): - if self.arlo_pc: - prefix = "a=candidate:" - if candidate.startswith(prefix): - candidate = candidate[len(prefix):] - candidate = candidate.strip() - self.logger.info(f"Arlo response candidate: {candidate}") - - candidate = candidate_from_aioice(Candidate.from_sdp(candidate)) - if candidate.sdpMid is None: - # arlo appears to not return a mux id in the response, which - # doesn't play nicely with aiortc. let's add it - candidate.sdpMid = 0 - self.create_task(self.arlo_pc.addIceCandidate(candidate)) - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToCandidateAnswers(self.arlo_basestation, self.arlo_device, callback) - ) - - @async_print_exception_guard - async def initialize_push_to_talk(self, media: MediaObject) -> None: - self.logger.info("Initializing push to talk") - - ffmpeg_params = json.loads(await scrypted_sdk.mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput.value)) - self.logger.debug(f"Received ffmpeg params: {ffmpeg_params}") - - session_id, ice_servers = self.provider.arlo.StartPushToTalk(self.arlo_basestation, self.arlo_device) - self.logger.debug(f"Received ice servers: {[ice['url'] for ice in ice_servers]}") - - ice_servers = [ - RTCIceServer(urls=ice["url"], credential=ice.get("credential"), username=ice.get("username")) - for ice in ice_servers - ] - ice_gatherer = RTCIceGatherer(ice_servers) - await ice_gatherer.gather() - - local_candidates = [ - f"candidate:{Candidate.to_sdp(candidate_to_aioice(candidate))}" - for candidate in ice_gatherer.getLocalCandidates() - ] - - log_candidates = '\n'.join(local_candidates) - self.logger.info(f"Local candidates:\n{log_candidates}") - - # MediaPlayer/PyAV will block until the intercom stream starts, and it seems that scrypted waits - # for startIntercom to exit before sending data. So, let's do the remaining setup in a coroutine - # so this function can return early. - # This is required even if we use BackgroundRTCPeerConnection, since setting up MediaPlayer may - # block the background thread's event loop and prevent other async functions from running. - async def async_setup(): - pc = self.arlo_pc = BackgroundRTCPeerConnection(self.logger) - self.sdp_answered = False - - pc.add_rtsp_audio(ffmpeg_params["url"]) - - offer = await pc.createOffer() - self.logger.info(f"Arlo offer sdp:\n{offer.sdp}") - - await pc.setLocalDescription(offer) - - self.provider.arlo.NotifyPushToTalkSDP( - self.arlo_basestation, self.arlo_device, - session_id, offer.sdp - ) - for candidate in local_candidates: - self.provider.arlo.NotifyPushToTalkCandidate( - self.arlo_basestation, self.arlo_device, - session_id, candidate - ) - - self.create_task(async_setup()) - - @async_print_exception_guard - async def shutdown(self) -> None: - if self.arlo_pc is not None: - await self.arlo_pc.close() - self.arlo_pc = None diff --git a/plugins/arlo/src/arlo_plugin/child_process.py b/plugins/arlo/src/arlo_plugin/child_process.py deleted file mode 100644 index fdf74dd79..000000000 --- a/plugins/arlo/src/arlo_plugin/child_process.py +++ /dev/null @@ -1,93 +0,0 @@ -import multiprocessing -import subprocess -import time -import threading - -import scrypted_arlo_go - - -HEARTBEAT_INTERVAL = 5 - - -def multiprocess_main(name, logger_port, child_conn, exe, args): - logger = scrypted_arlo_go.NewTCPLogger(logger_port, "HeartbeatChildProcess") - - logger.Send(f"{name} starting\n") - sp = subprocess.Popen([exe, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # pull stdout and stderr from the subprocess and forward it over to - # our tcp logger - def logging_thread(stdstream): - while True: - line = stdstream.readline() - if not line: - break - line = str(line, 'utf-8') - logger.Send(line) - stdout_t = threading.Thread(target=logging_thread, args=(sp.stdout,)) - stderr_t = threading.Thread(target=logging_thread, args=(sp.stderr,)) - stdout_t.start() - stderr_t.start() - - while True: - has_data = child_conn.poll(HEARTBEAT_INTERVAL * 3) - if not has_data: - break - - # check if the subprocess is still alive, if not then exit - if sp.poll() is not None: - break - - keep_alive = child_conn.recv() - if not keep_alive: - break - - logger.Send(f"{name} exiting\n") - - sp.terminate() - sp.wait() - - stdout_t.join() - stderr_t.join() - - logger.Send(f"{name} exited\n") - logger.Close() - - -class HeartbeatChildProcess: - """Class to manage running a child process that gets cleaned up if the parent exits. - - When spawining subprocesses in Python, if the parent is forcibly killed (as is the case - when Scrypted restarts plugins), subprocesses get orphaned. This approach uses parent-child - heartbeats for the child to ensure that the parent process is still alive, and to cleanly - exit the child if the parent has terminated. - """ - - def __init__(self, name, logger_port, exe, *args): - self.name = name - self.logger_port = logger_port - self.exe = exe - self.args = args - - self.parent_conn, self.child_conn = multiprocessing.Pipe() - self.process = multiprocessing.Process(target=multiprocess_main, args=(name, logger_port, self.child_conn, exe, args)) - self.process.daemon = True - self._stop = False - - self.thread = threading.Thread(target=self.heartbeat) - - def start(self): - self.process.start() - self.thread.start() - - def stop(self): - self._stop = True - self.parent_conn.send(False) - - def heartbeat(self): - while not self._stop: - time.sleep(HEARTBEAT_INTERVAL) - if not self.process.is_alive(): - self.stop() - break - self.parent_conn.send(True) diff --git a/plugins/arlo/src/arlo_plugin/doorbell.py b/plugins/arlo/src/arlo_plugin/doorbell.py deleted file mode 100644 index d4f812f93..000000000 --- a/plugins/arlo/src/arlo_plugin/doorbell.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from typing import List, TYPE_CHECKING - -from scrypted_sdk.types import BinarySensor, ScryptedInterface, ScryptedDeviceType - -from .camera import ArloCamera - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - - -class ArloDoorbell(ArloCamera, BinarySensor): - def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider) - self.start_doorbell_subscription() - - def start_doorbell_subscription(self) -> None: - def callback(doorbellPressed): - self.binaryState = doorbellPressed - return self.stop_subscriptions - - self.register_task( - self.provider.arlo.SubscribeToDoorbellEvents(self.arlo_basestation, self.arlo_device, callback) - ) - - def get_device_type(self) -> str: - return ScryptedDeviceType.Doorbell.value - - def get_applicable_interfaces(self) -> List[str]: - camera_interfaces = super().get_applicable_interfaces() - camera_interfaces.append(ScryptedInterface.BinarySensor.value) - return camera_interfaces diff --git a/plugins/arlo/src/arlo_plugin/experimental.py b/plugins/arlo/src/arlo_plugin/experimental.py deleted file mode 100644 index 59729733f..000000000 --- a/plugins/arlo/src/arlo_plugin/experimental.py +++ /dev/null @@ -1,3 +0,0 @@ -import os - -EXPERIMENTAL = os.environ.get("SCRYPTED_ARLO_EXPERIMENTAL", "0") not in ["", "0"] \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/logging.py b/plugins/arlo/src/arlo_plugin/logging.py deleted file mode 100644 index b97be3036..000000000 --- a/plugins/arlo/src/arlo_plugin/logging.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging - - -class ScryptedDeviceLoggingWrapper(logging.Handler): - scrypted_device = None - - def __init__(self, scrypted_device): - super().__init__() - self.scrypted_device = scrypted_device - - def emit(self, record): - self.scrypted_device.print(self.format(record)) - - -def createScryptedLogger(scrypted_device, name): - logger = logging.getLogger(name) - if logger.hasHandlers(): - return logger - - logger.setLevel(logging.INFO) - - # configure logger to output to scrypted's log stream - sh = ScryptedDeviceLoggingWrapper(scrypted_device) - - # log formatting - fmt = logging.Formatter("[Arlo %(name)s]: %(message)s") - sh.setFormatter(fmt) - - # configure handler to logger - logger.addHandler(sh) - - return logger - - -class ScryptedDeviceLoggerMixin: - _logger = None - logger_name = None - - @property - def logger(self): - if self._logger is None: - self._logger = createScryptedLogger(self, self.logger_name) - return self._logger \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/provider.py b/plugins/arlo/src/arlo_plugin/provider.py deleted file mode 100644 index 3823ad1cb..000000000 --- a/plugins/arlo/src/arlo_plugin/provider.py +++ /dev/null @@ -1,814 +0,0 @@ -import asyncio -from bs4 import BeautifulSoup -import email -import functools -import imaplib -import json -import logging -import re -import requests -from typing import List - -import scrypted_sdk -from scrypted_sdk import ScryptedDeviceBase -from scrypted_sdk.types import Setting, SettingValue, Settings, DeviceProvider, ScryptedInterface - -from .arlo import Arlo -from .arlo.arlo_async import change_stream_class -from .arlo.logging import logger as arlo_lib_logger -from .logging import ScryptedDeviceLoggerMixin -from .util import BackgroundTaskMixin, async_print_exception_guard -from .camera import ArloCamera -from .doorbell import ArloDoorbell -from .basestation import ArloBasestation -from .base import ArloDeviceBase - - -class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceLoggerMixin, BackgroundTaskMixin): - arlo_cameras = None - arlo_basestations = None - all_device_ids: set = set() - _arlo_mfa_code = None - scrypted_devices = None - _arlo: Arlo = None - _arlo_mfa_complete_auth = None - device_discovery_lock: asyncio.Lock = None - - plugin_verbosity_choices = { - "Normal": logging.INFO, - "Verbose": logging.DEBUG - } - - arlo_transport_choices = ["MQTT", "SSE"] - - mfa_strategy_choices = ["Manual", "IMAP"] - - def __init__(self, nativeId: str = None) -> None: - super().__init__(nativeId=nativeId) - self.logger_name = "Provider" - - self.arlo_cameras = {} - self.arlo_basestations = {} - self.scrypted_devices = {} - self.imap = None - self.imap_signal = None - self.imap_skip_emails = None - self.device_discovery_lock = asyncio.Lock() - - self.propagate_verbosity() - self.propagate_transport() - - def load(self): - if self.mfa_strategy == "IMAP": - self.initialize_imap() - else: - _ = self.arlo - - asyncio.get_event_loop().call_soon(load, self) - self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None)) - - def print(self, *args, **kwargs) -> None: - """Overrides the print() from ScryptedDeviceBase to avoid double-printing in the main plugin console.""" - print(*args, **kwargs) - - @property - def arlo_username(self) -> str: - return self.storage.getItem("arlo_username") - - @property - def arlo_password(self) -> str: - return self.storage.getItem("arlo_password") - - @property - def arlo_auth_headers(self) -> str: - return self.storage.getItem("arlo_auth_headers") - - @property - def arlo_user_id(self) -> str: - return self.storage.getItem("arlo_user_id") - - @property - def arlo_transport(self) -> str: - return "SSE" - # This code is here for posterity, however it looks that as of 06/01/2023 - # Arlo has disabled the MQTT backend - transport = self.storage.getItem("arlo_transport") - if transport is None or transport not in ArloProvider.arlo_transport_choices: - transport = "SSE" - self.storage.setItem("arlo_transport", transport) - return transport - - @property - def plugin_verbosity(self) -> str: - verbosity = self.storage.getItem("plugin_verbosity") - if verbosity is None or verbosity not in ArloProvider.plugin_verbosity_choices: - verbosity = "Normal" - self.storage.setItem("plugin_verbosity", verbosity) - return verbosity - - @property - def mfa_strategy(self) -> str: - strategy = self.storage.getItem("mfa_strategy") - if strategy is None or strategy not in ArloProvider.mfa_strategy_choices: - strategy = "Manual" - self.storage.setItem("mfa_strategy", strategy) - return strategy - - @property - def refresh_interval(self) -> int: - interval = self.storage.getItem("refresh_interval") - if interval is None: - interval = 90 - self.storage.setItem("refresh_interval", interval) - return int(interval) - - @property - def imap_mfa_host(self) -> str: - return self.storage.getItem("imap_mfa_host") - - @property - def imap_mfa_port(self) -> int: - port = self.storage.getItem("imap_mfa_port") - if port is None: - port = 993 - self.storage.setItem("imap_mfa_port", port) - return int(port) - - @property - def imap_mfa_username(self) -> str: - return self.storage.getItem("imap_mfa_username") - - @property - def imap_mfa_password(self) -> str: - return self.storage.getItem("imap_mfa_password") - - @property - def imap_mfa_sender(self) -> str: - sender = self.storage.getItem("imap_mfa_sender") - if sender is None or sender == "": - sender = "do_not_reply@arlo.com" - self.storage.setItem("imap_mfa_sender", sender) - return sender - - @property - def imap_mfa_interval(self) -> int: - interval = self.storage.getItem("imap_mfa_interval") - if interval is None: - interval = 7 - self.storage.setItem("imap_mfa_interval", interval) - return int(interval) - - @property - def hidden_devices(self) -> List[str]: - hidden = self.storage.getItem("hidden_devices") - if hidden is None: - hidden = [] - self.storage.setItem("hidden_devices", hidden) - return hidden - - @property - def hidden_device_ids(self) -> List[str]: - ids = [] - for id in self.hidden_devices: - m = re.match(r".*\((.*)\)$", id) - if m is not None: - ids.append(m.group(1)) - return ids - - @property - def arlo(self) -> Arlo: - if self._arlo is not None: - if self._arlo_mfa_complete_auth is not None: - if not self._arlo_mfa_code: - return None - - self.logger.info("Completing Arlo MFA...") - try: - self._arlo_mfa_complete_auth(self._arlo_mfa_code) - finally: - self._arlo_mfa_complete_auth = None - self._arlo_mfa_code = None - self.logger.info("Arlo MFA done") - - self.storage.setItem("arlo_auth_headers", json.dumps(dict(self._arlo.request.session.headers.items()))) - self.storage.setItem("arlo_user_id", self._arlo.user_id) - - self.create_task(self.do_arlo_setup()) - - return self._arlo - - if not self.arlo_username or not self.arlo_password: - return None - - self.logger.info("Trying to initialize Arlo client...") - try: - self._arlo = Arlo(self.arlo_username, self.arlo_password) - headers = self.arlo_auth_headers - if headers: - self._arlo.UseExistingAuth(self.arlo_user_id, json.loads(headers)) - self.logger.info(f"Initialized Arlo client, reusing stored auth headers") - self.create_task(self.do_arlo_setup()) - return self._arlo - else: - self._arlo_mfa_complete_auth = self._arlo.LoginMFA() - self.logger.info(f"Initialized Arlo client, waiting for MFA code") - return None - except Exception: - self.logger.exception("Error initializing Arlo client") - self._arlo = None - self._arlo_mfa_complete_auth = None - self._arlo_mfa_code = None - raise - - async def do_arlo_setup(self) -> None: - try: - await self.discover_devices() - await self.arlo.Subscribe([ - (self.arlo_basestations[camera["parentId"]], camera) for camera in self.arlo_cameras.values() - ]) - - self.arlo.event_stream.set_refresh_interval(self.refresh_interval) - except requests.exceptions.HTTPError: - self.logger.exception("Error logging in") - self.logger.error("Will retry with fresh login") - self._arlo = None - self._arlo_mfa_code = None - self.storage.setItem("arlo_auth_headers", None) - _ = self.arlo - except Exception: - self.logger.exception("Error logging in") - - def invalidate_arlo_client(self) -> None: - if self._arlo is not None: - self._arlo.Unsubscribe() - self._arlo = None - self._arlo_mfa_code = None - self._arlo_mfa_complete_auth = None - self.storage.setItem("arlo_auth_headers", "") - self.storage.setItem("arlo_user_id", "") - - def get_current_log_level(self) -> int: - return ArloProvider.plugin_verbosity_choices[self.plugin_verbosity] - - def propagate_verbosity(self) -> None: - self.print(f"Setting plugin verbosity to {self.plugin_verbosity}") - log_level = self.get_current_log_level() - self.logger.setLevel(log_level) - for _, device in self.scrypted_devices.items(): - device.logger.setLevel(log_level) - arlo_lib_logger.setLevel(log_level) - - def propagate_transport(self) -> None: - self.print(f"Setting plugin transport to {self.arlo_transport}") - change_stream_class(self.arlo_transport) - - def initialize_imap(self, try_count=1) -> None: - if not self.imap_mfa_host or not self.imap_mfa_port or \ - not self.imap_mfa_username or not self.imap_mfa_password or \ - not self.imap_mfa_interval: - return - - self.exit_imap() - try: - self.logger.info(f"Trying connect to IMAP (attempt {try_count})") - self.imap = imaplib.IMAP4_SSL(self.imap_mfa_host, port=self.imap_mfa_port) - - res, _ = self.imap.login(self.imap_mfa_username, self.imap_mfa_password) - if res.lower() != "ok": - raise Exception(f"IMAP login failed: {res}") - res, _ = self.imap.select(mailbox="INBOX", readonly=True) - if res.lower() != "ok": - raise Exception(f"IMAP failed to fetch INBOX: {res}") - - # fetch existing arlo emails so we skip them going forward - res, self.imap_skip_emails = self.imap.search(None, "FROM", "do_not_reply@arlo.com") - if res.lower() != "ok": - raise Exception(f"IMAP failed to fetch old Arlo emails: {res}") - except Exception: - self.logger.exception("IMAP initialization error") - - if try_count >= 10: - self.logger.error("Tried to connect to IMAP too many times. Will request a plugin restart.") - self.create_task(scrypted_sdk.deviceManager.requestRestart()) - - asyncio.get_event_loop().call_later(try_count*try_count, functools.partial(self.initialize_imap, try_count=try_count+1)) - else: - self.logger.info("Connected to IMAP") - self.imap_signal = asyncio.Queue() - self.create_task(self.imap_relogin_loop()) - - def exit_imap(self) -> None: - if self.imap_signal: - self.imap_signal.put_nowait(None) - self.imap_signal = None - self.imap_skip_emails = None - self.imap = None - - async def imap_relogin_loop(self) -> None: - imap_signal = self.imap_signal - self.logger.info(f"Starting IMAP refresh loop {id(imap_signal)}") - while True: - self.logger.info("Performing IMAP login flow") - - # save old client and details in case of error - old_arlo = self._arlo - old_headers = self.storage.getItem("arlo_auth_headers") - old_user_id = self.storage.getItem("arlo_user_id") - - # clear everything - self._arlo = None - self._arlo_mfa_code = None - self._arlo_mfa_complete_auth = None - self.storage.setItem("arlo_auth_headers", "") - self.storage.setItem("arlo_user_id", "") - - # initialize login and prompt for MFA - try: - _ = self.arlo - except Exception: - self.logger.exception("Unrecoverable login error") - self.logger.error("Will request a plugin restart") - await scrypted_sdk.deviceManager.requestRestart() - return - - # do imap lookup - # adapted from https://github.com/twrecked/pyaarlo/blob/77c202b6f789c7104a024f855a12a3df4fc8df38/pyaarlo/tfa.py - try: - try_count = 0 - while True: - try_count += 1 - - sleep_duration = 1 - if try_count > 5: - sleep_duration = 2 - elif try_count > 10: - sleep_duration = 5 - elif try_count > 20: - sleep_duration = 10 - - self.logger.info(f"Checking IMAP for MFA codes (attempt {try_count})") - - self.imap.check() - res, emails = self.imap.search(None, "FROM", self.imap_mfa_sender) - if res.lower() != "ok": - raise Exception("IMAP error: {res}") - - if emails == self.imap_skip_emails: - self.logger.info("No new emails found, will sleep and retry") - await asyncio.sleep(sleep_duration) - continue - - skip_emails = self.imap_skip_emails[0].split() - def search_email(msg_id): - if msg_id in skip_emails: - return None - - res, msg = self.imap.fetch(msg_id, "(BODY.PEEK[])") - if res.lower() != "ok": - raise Exception("IMAP error: {res}") - - if isinstance(msg[0][1], bytes): - for part in email.message_from_bytes(msg[0][1]).walk(): - if part.get_content_type() != "text/html": - continue - try: - soup = BeautifulSoup(part.get_payload(decode=True), 'html.parser') - for line in soup.get_text().splitlines(): - code = re.match(r"^\W*(\d{6})\W*$", line) - if code is not None: - return code.group(1) - except: - continue - return None - - for msg_id in emails[0].split(): - res = search_email(msg_id) - if res is not None: - self._arlo_mfa_code = res - break - - # update previously seen emails list - self.imap_skip_emails = emails - - if self._arlo_mfa_code is not None: - self.logger.info("Found MFA code") - break - - self.logger.info("No MFA code found, will sleep and retry") - await asyncio.sleep(sleep_duration) - except Exception: - self.logger.exception("Error while checking for MFA codes") - - self._arlo = old_arlo - self.storage.setItem("arlo_auth_headers", old_headers) - self.storage.setItem("arlo_user_id", old_user_id) - self._arlo_mfa_code = None - self._arlo_mfa_complete_auth = None - - self.logger.error("Will reload IMAP connection") - asyncio.get_event_loop().call_soon(self.initialize_imap) - else: - # finish login - if old_arlo: - old_arlo.Unsubscribe() - - try: - _ = self.arlo - except Exception: - self.logger.exception("Unrecoverable login error") - self.logger.error("Will request a plugin restart") - await scrypted_sdk.deviceManager.requestRestart() - return - - # continue by sleeping/waiting for a signal - interval = self.imap_mfa_interval * 24 * 60 * 60 # convert interval days to seconds - signal_task = asyncio.create_task(imap_signal.get()) - - # wait until either we receive a signal or the refresh interval expires - done, pending = await asyncio.wait([signal_task, asyncio.sleep(interval)], return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - - done_task = done.pop() - if done_task is signal_task and done_task.result() is None: - # exit signal received - self.logger.info(f"Exiting IMAP refresh loop {id(imap_signal)}") - return - - async def getSettings(self) -> List[Setting]: - results = [ - { - "group": "General", - "key": "arlo_username", - "title": "Arlo Username", - "value": self.arlo_username, - }, - { - "group": "General", - "key": "arlo_password", - "title": "Arlo Password", - "type": "password", - "value": self.arlo_password, - }, - { - "group": "General", - "key": "mfa_strategy", - "title": "Two Factor Strategy", - "description": "Mechanism to fetch the two factor code for Arlo login. Save after changing this field for more settings.", - "value": self.mfa_strategy, - "choices": self.mfa_strategy_choices, - }, - ] - - if self.mfa_strategy == "Manual": - results.extend([ - { - "group": "General", - "key": "arlo_mfa_code", - "title": "Two Factor Code", - "description": "Enter the code sent by Arlo to your email or phone number.", - }, - { - "group": "General", - "key": "force_reauth", - "title": "Force Re-Authentication", - "description": "Resets the authentication flow of the plugin. Will also re-do 2FA.", - "value": False, - "type": "boolean", - }, - ]) - else: - results.extend([ - { - "group": "IMAP 2FA", - "key": "imap_mfa_host", - "title": "IMAP Hostname", - "value": self.imap_mfa_host, - }, - { - "group": "IMAP 2FA", - "key": "imap_mfa_port", - "title": "IMAP Port", - "value": self.imap_mfa_port, - }, - { - "group": "IMAP 2FA", - "key": "imap_mfa_username", - "title": "IMAP Username", - "value": self.imap_mfa_username, - }, - { - "group": "IMAP 2FA", - "key": "imap_mfa_password", - "title": "IMAP Password", - "type": "password", - "value": self.imap_mfa_password, - }, - { - "group": "IMAP 2FA", - "key": "imap_mfa_sender", - "title": "IMAP Email Sender", - "value": self.imap_mfa_sender, - "description": "The sender email address to search for when loading 2FA codes. See plugin README for more details.", - }, - { - "group": "IMAP 2FA", - "key": "imap_mfa_interval", - "title": "Refresh Login Interval", - "description": "Interval, in days, to refresh the login session to Arlo Cloud. " - "Must be a value greater than 0.", - "type": "number", - "value": self.imap_mfa_interval, - } - ]) - - results.extend([ - { - "group": "General", - "key": "arlo_transport", - "title": "Underlying Transport Protocol", - "description": "Arlo Cloud currently only supports the SSE protocol.", - "value": self.arlo_transport, - "readonly": True, - }, - { - "group": "General", - "key": "refresh_interval", - "title": "Refresh Event Stream Interval", - "description": "Interval, in minutes, to refresh the underlying event stream connection to Arlo Cloud. " - "A value of 0 disables this feature.", - "type": "number", - "value": self.refresh_interval, - }, - { - "group": "General", - "key": "plugin_verbosity", - "title": "Verbose Logging", - "description": "Enable this option to show debug messages, including events received from connected Arlo cameras.", - "value": self.plugin_verbosity == "Verbose", - "type": "boolean", - }, - { - "group": "General", - "key": "hidden_devices", - "title": "Hidden Devices", - "description": "Select the Arlo devices to hide in this plugin. Hidden devices will be removed from Scrypted and will " - "not be re-added when the plugin reloads.", - "value": self.hidden_devices, - "multiple": True, - "choices": [id for id in self.all_device_ids], - }, - ]) - - return results - - @async_print_exception_guard - async def putSetting(self, key: str, value: SettingValue) -> None: - if not self.validate_setting(key, value): - await self.onDeviceEvent(ScryptedInterface.Settings.value, None) - return - - skip_arlo_client = False - if key == "arlo_mfa_code": - self._arlo_mfa_code = value - elif key == "force_reauth": - # force arlo client to be invalidated and reloaded - self.invalidate_arlo_client() - elif key == "plugin_verbosity": - self.storage.setItem(key, "Verbose" if value == "true" or value == True else "Normal") - self.propagate_verbosity() - skip_arlo_client = True - else: - self.storage.setItem(key, value) - - if key == "arlo_transport": - self.propagate_transport() - # force arlo client to be invalidated and reloaded, but - # keep any mfa codes - if self._arlo is not None: - self._arlo.Unsubscribe() - self._arlo = None - elif key == "mfa_strategy": - if value == "IMAP": - self.initialize_imap() - else: - self.exit_imap() - skip_arlo_client = True - elif key == "refresh_interval": - if self._arlo is not None and self._arlo.event_stream: - self._arlo.event_stream.set_refresh_interval(self.refresh_interval) - skip_arlo_client = True - elif key.startswith("imap_mfa"): - self.initialize_imap() - skip_arlo_client = True - elif key == "hidden_devices": - if self._arlo is not None and self._arlo.logged_in: - self._arlo.Unsubscribe() - await self.do_arlo_setup() - skip_arlo_client = True - else: - # force arlo client to be invalidated and reloaded - self.invalidate_arlo_client() - - if not skip_arlo_client: - # initialize Arlo client or continue MFA - _ = self.arlo - await self.onDeviceEvent(ScryptedInterface.Settings.value, None) - - def validate_setting(self, key: str, val: SettingValue) -> bool: - if key == "refresh_interval": - try: - val = int(val) - except ValueError: - self.logger.error(f"Invalid refresh interval '{val}' - must be an integer") - return False - if val < 0: - self.logger.error(f"Invalid refresh interval '{val}' - must be nonnegative") - return False - elif key == "imap_mfa_port": - try: - val = int(val) - except ValueError: - self.logger.error(f"Invalid IMAP port '{val}' - must be an integer") - return False - if val < 0: - self.logger.error(f"Invalid IMAP port '{val}' - must be nonnegative") - return False - elif key == "imap_mfa_interval": - try: - val = int(val) - except ValueError: - self.logger.error(f"Invalid IMAP interval '{val}' - must be an integer") - return False - if val < 1: - self.logger.error(f"Invalid IMAP interval '{val}' - must be positive") - return False - return True - - @async_print_exception_guard - async def discover_devices(self) -> None: - async with self.device_discovery_lock: - return await self.discover_devices_impl() - - async def discover_devices_impl(self) -> None: - if not self._arlo or not self._arlo.logged_in: - raise Exception("Arlo client not connected, cannot discover devices") - - self.logger.info("Discovering devices...") - self.arlo_cameras = {} - self.arlo_basestations = {} - self.all_device_ids = set() - self.scrypted_devices = {} - - camera_devices = [] - provider_to_device_map = {None: []} - - basestations = self.arlo.GetDevices(['basestation', 'siren']) - for basestation in basestations: - nativeId = basestation["deviceId"] - self.all_device_ids.add(f"{basestation['deviceName']} ({nativeId})") - - self.logger.debug(f"Adding {nativeId}") - - if nativeId in self.arlo_basestations: - self.logger.info(f"Skipping basestation {nativeId} ({basestation['modelId']}) as it has already been added") - continue - - self.arlo_basestations[nativeId] = basestation - - if nativeId in self.hidden_device_ids: - self.logger.info(f"Skipping manifest for basestation {nativeId} ({basestation['modelId']}) as it is hidden") - continue - - device = await self.getDevice_impl(nativeId) - scrypted_interfaces = device.get_applicable_interfaces() - manifest = device.get_device_manifest() - self.logger.debug(f"Interfaces for {nativeId} ({basestation['modelId']}): {scrypted_interfaces}") - - # for basestations, we want to add them to the top level DeviceProvider - provider_to_device_map.setdefault(None, []).append(manifest) - - # we want to trickle discover them so they are added without deleting all existing - # root level devices - this is for backward compatibility - await scrypted_sdk.deviceManager.onDeviceDiscovered(manifest) - - # add any builtin child devices and trickle discover them - child_manifests = device.get_builtin_child_device_manifests() - for child_manifest in child_manifests: - await scrypted_sdk.deviceManager.onDeviceDiscovered(child_manifest) - provider_to_device_map.setdefault(child_manifest["providerNativeId"], []).append(child_manifest) - - self.logger.info(f"Discovered {len(self.arlo_basestations)} basestations") - - cameras = self.arlo.GetDevices(['camera', "arloq", "arloqs", "doorbell"]) - for camera in cameras: - nativeId = camera["deviceId"] - self.all_device_ids.add(f"{camera['deviceName']} ({nativeId})") - - self.logger.debug(f"Adding {nativeId}") - - if camera["deviceId"] != camera["parentId"] and camera["parentId"] not in self.arlo_basestations: - self.logger.info(f"Skipping camera {camera['deviceId']} ({camera['modelId']}) because its basestation was not found") - continue - - if nativeId in self.arlo_cameras: - self.logger.info(f"Skipping camera {nativeId} ({camera['modelId']}) as it has already been added") - continue - - if nativeId in self.hidden_device_ids: - self.logger.info(f"Skipping camera {camera['deviceId']} ({camera['modelId']}) because it is hidden") - continue - - self.arlo_cameras[nativeId] = camera - - if camera["deviceId"] == camera["parentId"]: - # these are standalone cameras with no basestation, so they act as their - # own basestation - self.arlo_basestations[camera["deviceId"]] = camera - - device = await self.getDevice_impl(nativeId) - scrypted_interfaces = device.get_applicable_interfaces() - manifest = device.get_device_manifest() - self.logger.debug(f"Interfaces for {nativeId} ({camera['modelId']} parent {camera['parentId']}): {scrypted_interfaces}") - - if camera["deviceId"] == camera["parentId"] or camera["parentId"] in self.hidden_device_ids: - provider_to_device_map.setdefault(None, []).append(manifest) - else: - provider_to_device_map.setdefault(camera["parentId"], []).append(manifest) - - # trickle discover this camera so it exists for later steps - await scrypted_sdk.deviceManager.onDeviceDiscovered(manifest) - - # add any builtin child devices and trickle discover them - child_manifests = device.get_builtin_child_device_manifests() - for child_manifest in child_manifests: - await scrypted_sdk.deviceManager.onDeviceDiscovered(child_manifest) - provider_to_device_map.setdefault(child_manifest["providerNativeId"], []).append(child_manifest) - - camera_devices.append(manifest) - - if len(cameras) != len(camera_devices): - self.logger.info(f"Discovered {len(cameras)} cameras, but only {len(camera_devices)} are usable") - self.logger.info("This could be because some cameras are hidden.") - self.logger.info("If a camera is not hidden but is still missing, ensure all cameras shared with " - "admin permissions in the Arlo app.") - else: - self.logger.info(f"Discovered {len(cameras)} cameras") - - for provider_id in provider_to_device_map.keys(): - if provider_id is None: - continue - - if len(provider_to_device_map[provider_id]) > 0: - self.logger.debug(f"Sending {provider_id} and children to scrypted server") - else: - self.logger.debug(f"Sending {provider_id} to scrypted server") - - await scrypted_sdk.deviceManager.onDevicesChanged({ - "devices": provider_to_device_map[provider_id], - "providerNativeId": provider_id, - }) - - # ensure devices at the root match all that was discovered - self.logger.debug("Sending top level devices to scrypted server") - await scrypted_sdk.deviceManager.onDevicesChanged({ - "devices": provider_to_device_map[None] - }) - self.logger.debug("Done discovering devices") - - # force a settings refresh so the hidden devices list can be updated - await self.onDeviceEvent(ScryptedInterface.Settings.value, None) - - async def getDevice(self, nativeId: str) -> ArloDeviceBase: - self.logger.debug(f"Scrypted requested to load device {nativeId}") - async with self.device_discovery_lock: - return await self.getDevice_impl(nativeId) - - async def getDevice_impl(self, nativeId: str) -> ArloDeviceBase: - ret = self.scrypted_devices.get(nativeId) - if ret is None: - ret = self.create_device(nativeId) - if ret is not None: - self.scrypted_devices[nativeId] = ret - return ret - - def create_device(self, nativeId: str) -> ArloDeviceBase: - if nativeId not in self.arlo_cameras and nativeId not in self.arlo_basestations: - self.logger.warning(f"Cannot create device for nativeId {nativeId}, maybe it hasn't been loaded yet?") - return None - - arlo_device = self.arlo_cameras.get(nativeId) - if not arlo_device: - # this is a basestation, so build the basestation object - arlo_device = self.arlo_basestations[nativeId] - return ArloBasestation(nativeId, arlo_device, self) - - if arlo_device["parentId"] not in self.arlo_basestations: - self.logger.warning(f"Cannot create camera with nativeId {nativeId} when {arlo_device['parentId']} is not a valid basestation") - return None - arlo_basestation = self.arlo_basestations[arlo_device["parentId"]] - - if arlo_device["deviceType"] == "doorbell": - return ArloDoorbell(nativeId, arlo_device, arlo_basestation, self) - else: - return ArloCamera(nativeId, arlo_device, arlo_basestation, self) \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/rtcpeerconnection.py b/plugins/arlo/src/arlo_plugin/rtcpeerconnection.py deleted file mode 100644 index b19f0d13c..000000000 --- a/plugins/arlo/src/arlo_plugin/rtcpeerconnection.py +++ /dev/null @@ -1,107 +0,0 @@ -from aiortc import RTCPeerConnection -from aiortc.contrib.media import MediaPlayer -import asyncio -import threading -import queue - - -class BackgroundRTCPeerConnection: - """Proxy class to use RTCPeerConnection in a background thread. - - The purpose of this proxy is to ensure that RTCPeerConnection operations - do not block the main asyncio thread. From testing, it seems that the - close() function blocks until the source RTSP server exits, which we - have no control over. Additionally, since asyncio coroutines are tied - to the event loop they were constructed from, it is not possible to only - run close() in a separate thread. Therefore, each instance of RTCPeerConnection - is launched within its own ephemeral thread, which cleans itself up once - close() completes. - """ - - def __init__(self, logger): - self.main_loop = asyncio.get_event_loop() - self.background_loop = asyncio.new_event_loop() - self.logger = logger - - self.thread_started = queue.Queue(1) - self.thread = threading.Thread(target=self.__background_main) - self.thread.start() - self.thread_started.get() - - def __background_main(self): - self.logger.info(f"Background RTC loop {self.thread.name} starting") - self.pc = RTCPeerConnection() - - asyncio.set_event_loop(self.background_loop) - self.thread_started.put(True) - self.background_loop.run_forever() - - self.logger.info(f"Background RTC loop {self.thread.name} exiting") - - async def __run_background(self, coroutine, await_result=True, stop_loop=False): - fut = self.main_loop.create_future() - - def background_callback(): - # callback to run on main_loop. - def to_main(result, is_error): - if is_error: - fut.set_exception(result) - else: - fut.set_result(result) - - # callback to run on background_loop., after the coroutine completes - def callback(task): - is_error = False - if task.exception(): - result = task.exception() - is_error = True - else: - result = task.result() - - # send results to the main loop - self.main_loop.call_soon_threadsafe(to_main, result, is_error) - - # stopping the loop here ensures that the coroutine completed - # and doesn't raise any "task not awaited" exceptions - if stop_loop: - self.background_loop.stop() - - task = self.background_loop.create_task(coroutine) - task.add_done_callback(callback) - - # start the callback in the background loop - self.background_loop.call_soon_threadsafe(background_callback) - - if not await_result: - return None - return await fut - - async def createOffer(self): - return await self.__run_background(self.pc.createOffer()) - - async def setLocalDescription(self, sdp): - return await self.__run_background(self.pc.setLocalDescription(sdp)) - - async def setRemoteDescription(self, sdp): - return await self.__run_background(self.pc.setRemoteDescription(sdp)) - - async def addIceCandidate(self, candidate): - return await self.__run_background(self.pc.addIceCandidate(candidate)) - - async def close(self): - await self.__run_background(self.pc.close(), await_result=False, stop_loop=True) - - def add_rtsp_audio(self, rtsp_url): - """Adds an audio track to the RTCPeerConnection given a source RTSP url. - - This constructs a MediaPlayer in the background thread's asyncio loop, - since MediaPlayer also utilizes coroutines and asyncio. - - Note that this may block the background thread's event loop if the RTSP - server is not yet ready. - """ - def add_rtsp_audio_background(): - media_player = MediaPlayer(rtsp_url, format="rtsp") - self.pc.addTrack(media_player.audio) - - self.background_loop.call_soon_threadsafe(add_rtsp_audio_background) \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/siren.py b/plugins/arlo/src/arlo_plugin/siren.py deleted file mode 100644 index a1c0e9e97..000000000 --- a/plugins/arlo/src/arlo_plugin/siren.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from typing import List, TYPE_CHECKING - -from scrypted_sdk.types import OnOff, SecuritySystemMode, ScryptedInterface, ScryptedDeviceType - -from .base import ArloDeviceBase -from .util import async_print_exception_guard - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - from .vss import ArloSirenVirtualSecuritySystem - - -class ArloSiren(ArloDeviceBase, OnOff): - vss: ArloSirenVirtualSecuritySystem = None - - def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, vss: ArloSirenVirtualSecuritySystem) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider) - self.vss = vss - - def get_applicable_interfaces(self) -> List[str]: - return [ScryptedInterface.OnOff.value] - - def get_device_type(self) -> str: - return ScryptedDeviceType.Siren.value - - @async_print_exception_guard - async def turnOn(self) -> None: - from .basestation import ArloBasestation - self.logger.info("Turning on") - - if self.vss.securitySystemState["mode"] == SecuritySystemMode.Disarmed.value: - self.logger.info("Virtual security system is disarmed, ignoring trigger") - - # set and unset this property to force homekit to display the - # switch as off - self.on = True - self.on = False - self.vss.securitySystemState = { - **self.vss.securitySystemState, - "triggered": False, - } - return - - if isinstance(self.vss.parent, ArloBasestation): - self.logger.debug("Parent device is a basestation") - self.provider.arlo.SirenOn(self.arlo_basestation) - else: - self.logger.debug("Parent device is a camera") - self.provider.arlo.SirenOn(self.arlo_basestation, self.arlo_device) - - self.on = True - self.vss.securitySystemState = { - **self.vss.securitySystemState, - "triggered": True, - } - - @async_print_exception_guard - async def turnOff(self) -> None: - from .basestation import ArloBasestation - self.logger.info("Turning off") - if isinstance(self.vss.parent, ArloBasestation): - self.provider.arlo.SirenOff(self.arlo_basestation) - else: - self.provider.arlo.SirenOff(self.arlo_basestation, self.arlo_device) - self.on = False - self.vss.securitySystemState = { - **self.vss.securitySystemState, - "triggered": False, - } diff --git a/plugins/arlo/src/arlo_plugin/spotlight.py b/plugins/arlo/src/arlo_plugin/spotlight.py deleted file mode 100644 index 89e47ca9f..000000000 --- a/plugins/arlo/src/arlo_plugin/spotlight.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from typing import List, TYPE_CHECKING - -from scrypted_sdk.types import OnOff, ScryptedInterface, ScryptedDeviceType - -from .base import ArloDeviceBase -from .util import async_print_exception_guard - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - from .camera import ArloCamera - - -class ArloSpotlight(ArloDeviceBase, OnOff): - camera: ArloCamera = None - - def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, camera: ArloCamera) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider) - self.camera = camera - - def get_applicable_interfaces(self) -> List[str]: - return [ScryptedInterface.OnOff.value] - - def get_device_type(self) -> str: - return ScryptedDeviceType.Light.value - - @async_print_exception_guard - async def turnOn(self) -> None: - self.logger.info("Turning on") - self.provider.arlo.SpotlightOn(self.arlo_basestation, self.arlo_device) - self.on = True - - @async_print_exception_guard - async def turnOff(self) -> None: - self.logger.info("Turning off") - self.provider.arlo.SpotlightOff(self.arlo_basestation, self.arlo_device) - self.on = False - - -class ArloFloodlight(ArloSpotlight): - - @async_print_exception_guard - async def turnOn(self) -> None: - self.logger.info("Turning on") - self.provider.arlo.FloodlightOn(self.arlo_basestation, self.arlo_device) - self.on = True - - @async_print_exception_guard - async def turnOff(self) -> None: - self.logger.info("Turning off") - self.provider.arlo.FloodlightOff(self.arlo_basestation, self.arlo_device) - self.on = False - - -class ArloNightlight(ArloSpotlight): - - def __init__(self, nativeId: str, arlo_device: dict, provider: ArloProvider, camera: ArloCamera) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_device, provider=provider, camera=camera) - - @async_print_exception_guard - async def turnOn(self) -> None: - self.logger.info("Turning on") - self.provider.arlo.NightlightOn(self.arlo_device) - self.on = True - - @async_print_exception_guard - async def turnOff(self) -> None: - self.logger.info("Turning off") - self.provider.arlo.NightlightOff(self.arlo_device) - self.on = False \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/util.py b/plugins/arlo/src/arlo_plugin/util.py deleted file mode 100644 index 3b4c0007d..000000000 --- a/plugins/arlo/src/arlo_plugin/util.py +++ /dev/null @@ -1,44 +0,0 @@ -import asyncio -import traceback - - -class BackgroundTaskMixin: - def create_task(self, coroutine) -> asyncio.Task: - task = asyncio.get_event_loop().create_task(coroutine) - self.register_task(task) - return task - - def register_task(self, task) -> None: - if not hasattr(self, "background_tasks"): - self.background_tasks = set() - - assert task is not None - - def print_exception(task): - if task.exception(): - self.logger.error(f"task exception: {task.exception()}") - - self.background_tasks.add(task) - task.add_done_callback(print_exception) - task.add_done_callback(self.background_tasks.discard) - - def cancel_pending_tasks(self) -> None: - if not hasattr(self, "background_tasks"): - return - for task in self.background_tasks: - task.cancel() - -def async_print_exception_guard(fn): - """Decorator to print an exception's stack trace before re-raising the exception.""" - async def wrapped(*args, **kwargs): - try: - return await fn(*args, **kwargs) - except Exception: - # hack to detect if the applied function is actually a method - # on a scrypted object - if len(args) > 0 and hasattr(args[0], "logger"): - getattr(args[0], "logger").exception(f"{fn.__qualname__} raised an exception") - else: - traceback.print_exc() - raise - return wrapped \ No newline at end of file diff --git a/plugins/arlo/src/arlo_plugin/vss.py b/plugins/arlo/src/arlo_plugin/vss.py deleted file mode 100644 index a6507fc7f..000000000 --- a/plugins/arlo/src/arlo_plugin/vss.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import List, TYPE_CHECKING - -from scrypted_sdk.types import Device, DeviceProvider, Setting, Settings, SettingValue, SecuritySystem, SecuritySystemMode, Readme, ScryptedInterface, ScryptedDeviceType - -from .base import ArloDeviceBase -from .siren import ArloSiren -from .util import async_print_exception_guard - -if TYPE_CHECKING: - # https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/ - from .provider import ArloProvider - from .basestation import ArloBasestation - from .camera import ArloCamera - - -class ArloSirenVirtualSecuritySystem(ArloDeviceBase, SecuritySystem, Settings, Readme, DeviceProvider): - """A virtual, emulated security system that controls when scrypted events can trip the real physical siren.""" - - SUPPORTED_MODES = [SecuritySystemMode.AwayArmed.value, SecuritySystemMode.HomeArmed.value, SecuritySystemMode.Disarmed.value] - - siren: ArloSiren = None - parent: ArloBasestation | ArloCamera = None - - def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, parent: ArloBasestation | ArloCamera) -> None: - super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider) - self.parent = parent - self.create_task(self.delayed_init()) - - @property - def mode(self) -> str: - mode = self.storage.getItem("mode") - if mode is None or mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES: - mode = SecuritySystemMode.Disarmed.value - return mode - - @mode.setter - def mode(self, mode: str) -> None: - if mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES: - raise ValueError(f"invalid mode {mode}") - self.storage.setItem("mode", mode) - self.securitySystemState = { - **self.securitySystemState, - "mode": mode, - } - self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None)) - - async def delayed_init(self) -> None: - iterations = 1 - while not self.stop_subscriptions: - if iterations > 100: - self.logger.error("Delayed init exceeded iteration limit, giving up") - return - - try: - self.securitySystemState = { - "supportedModes": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES, - "mode": self.mode, - } - return - except Exception as e: - self.logger.debug(f"Delayed init failed, will try again: {e}") - await asyncio.sleep(0.1) - iterations += 1 - - def get_applicable_interfaces(self) -> List[str]: - return [ - ScryptedInterface.SecuritySystem.value, - ScryptedInterface.DeviceProvider.value, - ScryptedInterface.Settings.value, - ScryptedInterface.Readme.value, - ] - - def get_device_type(self) -> str: - return ScryptedDeviceType.SecuritySystem.value - - def get_builtin_child_device_manifests(self) -> List[Device]: - siren = self.get_or_create_siren() - return [ - { - "info": { - "model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(), - "manufacturer": "Arlo", - "firmware": self.arlo_device.get("firmwareVersion"), - "serialNumber": self.arlo_device["deviceId"], - }, - "nativeId": siren.nativeId, - "name": f'{self.arlo_device["deviceName"]} Siren', - "interfaces": siren.get_applicable_interfaces(), - "type": siren.get_device_type(), - "providerNativeId": self.nativeId, - } - ] - - async def getSettings(self) -> List[Setting]: - return [ - { - "key": "mode", - "title": "Arm Mode", - "description": "If disarmed, the associated siren will not be physically triggered even if toggled.", - "value": self.mode, - "choices": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES, - }, - ] - - async def putSetting(self, key: str, value: SettingValue) -> None: - if key != "mode": - raise ValueError(f"invalid setting {key}") - self.mode = value - if self.mode == SecuritySystemMode.Disarmed.value: - await self.get_or_create_siren().turnOff() - - async def getReadmeMarkdown(self) -> str: - return """ -# Virtual Security System for Arlo Sirens - -This security system device is not a real physical device, but a virtual, emulated device provided by the Arlo Scrypted plugin. Its purpose is to grant security system semantics of Arm/Disarm to avoid the accidental, unwanted triggering of the real physical siren through integrations such as Homekit. - -To allow the siren to trigger, set the Arm Mode to any of the Armed options. When Disarmed, any triggers of the siren will be ignored. Switching modes will not perform any changes to Arlo cloud or your Arlo account, but rather only to this Scrypted device. - -If this virtual security system is synced to Homekit, the siren device will be merged into the same security system accessory as a switch. The siren device will not be added as a separate accessory. To access the siren as a switch without the security system, disable syncing of the virtual security system and enable syncing of the siren, then ensure that the virtual security system is armed manually in its settings in Scrypted. -""".strip() - - async def getDevice(self, nativeId: str) -> ArloDeviceBase: - if not nativeId.endswith("siren"): - return None - return self.get_or_create_siren() - - def get_or_create_siren(self) -> ArloSiren: - siren_id = f'{self.arlo_device["deviceId"]}.siren' - if not self.siren: - self.siren = ArloSiren(siren_id, self.arlo_device, self.arlo_basestation, self.provider, self) - return self.siren - - @async_print_exception_guard - async def armSecuritySystem(self, mode: SecuritySystemMode) -> None: - self.logger.info(f"Arming {mode}") - self.mode = mode - self.securitySystemState = { - **self.securitySystemState, - "mode": mode, - } - if mode == SecuritySystemMode.Disarmed.value: - await self.get_or_create_siren().turnOff() - - @async_print_exception_guard - async def disarmSecuritySystem(self) -> None: - self.logger.info(f"Disarming") - self.mode = SecuritySystemMode.Disarmed.value - self.securitySystemState = { - **self.securitySystemState, - "mode": SecuritySystemMode.Disarmed.value, - } - await self.get_or_create_siren().turnOff() diff --git a/plugins/arlo/src/main.py b/plugins/arlo/src/main.py deleted file mode 100644 index e75745cd9..000000000 --- a/plugins/arlo/src/main.py +++ /dev/null @@ -1,4 +0,0 @@ -from arlo_plugin import ArloProvider - -def create_scrypted_plugin(): - return ArloProvider() \ No newline at end of file diff --git a/plugins/arlo/src/requirements.txt b/plugins/arlo/src/requirements.txt deleted file mode 100644 index 010bea3bd..000000000 --- a/plugins/arlo/src/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -paho-mqtt==1.6.1 -aiohttp==3.8.4 -requests==2.28.2 -cachetools==5.3.0 -scrypted-arlo-go==0.5.2 -cloudscraper==1.2.71 -curl-cffi==0.5.7 -async-timeout==4.0.2 -beautifulsoup4==4.12.2 -aiortc==1.5.0 -av==9.2.0 ---extra-index-url=https://bjia56.github.io/armv7l-wheels/ ---extra-index-url=https://bjia56.github.io/scrypted-arlo-go/ ---prefer-binary \ No newline at end of file diff --git a/plugins/arlo/tsconfig.json b/plugins/arlo/tsconfig.json deleted file mode 100644 index 34a847ad8..000000000 --- a/plugins/arlo/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "ES2021", - "resolveJsonModule": true, - "moduleResolution": "Node16", - "esModuleInterop": true, - "sourceMap": true - }, - "include": [ - "src/**/*" - ] -} \ No newline at end of file