Merge branch 'main' of github.com:koush/scrypted

This commit is contained in:
Koushik Dutta
2023-07-04 09:07:09 -07:00
8 changed files with 139 additions and 50 deletions

View File

@@ -16,7 +16,7 @@ If you experience any trouble logging in, clear the username and password boxes,
* 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`.
* 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.

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/arlo",
"version": "0.8.0",
"version": "0.8.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/arlo",
"version": "0.8.0",
"version": "0.8.4",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/arlo",
"version": "0.8.0",
"version": "0.8.4",
"description": "Arlo Plugin for Scrypted",
"keywords": [
"scrypted",

View File

@@ -92,7 +92,7 @@ MEDIA_USER_AGENTS = {
class Arlo(object):
BASE_URL = 'my.arlo.com'
AUTH_URL = 'ocapi-app.arlo.com'
BACKUP_AUTH_HOSTS = ["NTIuMjEyLjIwNS4xNDU="] # list(scrypted_arlo_go.BACKUP_AUTH_HOSTS())
BACKUP_AUTH_HOSTS = ['NTIuMjEwLjMuMTIx', 'MzQuMjU1LjkyLjIxMg==', 'MzQuMjUxLjE3Ny45MA==', 'NTQuMjQ2LjE3MS4x']
TRANSID_PREFIX = 'web'
random.shuffle(BACKUP_AUTH_HOSTS)
@@ -101,7 +101,7 @@ class Arlo(object):
self.username = username
self.password = password
self.event_stream = None
self.request = Request()
self.request = None
def to_timestamp(self, dt):
if sys.version[0] == '2':
@@ -150,6 +150,7 @@ class Arlo(object):
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'
@@ -170,7 +171,7 @@ class Arlo(object):
'Host': self.AUTH_URL,
}
self.request = Request(mode="cloudscraper")
self.request = Request()
try:
auth_host = self.AUTH_URL
self.request.options(f'https://{auth_host}/api/auth', headers=headers)
@@ -246,7 +247,7 @@ class Arlo(object):
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()
self.request = Request(mode="cloudscraper")
# Update Authorization code with new code
headers = {
@@ -311,6 +312,9 @@ class Arlo(object):
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())}")

View File

@@ -14,6 +14,7 @@
# limitations under the License.
##
from functools import partialmethod
import requests
from requests.exceptions import HTTPError
from requests_toolbelt.adapters import host_header_ssl
@@ -21,6 +22,20 @@ import cloudscraper
import time
import uuid
from .logging import logger
try:
from curl_cffi import requests as curl_cffi_requests
HAS_CURL_CFFI = True
# upstream curl_cffi doesn't have OPTIONS support, so this is
# a bit of a hack to add it
class CurlCffiSession(curl_cffi_requests.Session):
options = partialmethod(curl_cffi_requests.Session.request, "OPTIONS")
except:
HAS_CURL_CFFI = False
#from requests_toolbelt.utils import dump
#def print_raw_http(response):
# data = dump.dump_all(response, request_prefix=b'', response_prefix=b'')
@@ -29,14 +44,20 @@ import uuid
class Request(object):
"""HTTP helper class"""
def __init__(self, timeout=5, mode="cloudscraper"):
if mode == "cloudscraper":
def __init__(self, timeout=5, mode="curl" if HAS_CURL_CFFI else "cloudscraper"):
if mode == "curl":
logger.debug("HTTP helper using curl_cffi")
self.session = CurlCffiSession(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

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk import ScryptedDeviceBase
from scrypted_sdk.types import Device, DeviceProvider, ScryptedInterface, ScryptedDeviceType
from scrypted_sdk.types import Device, DeviceProvider, Setting, SettingValue, Settings, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .vss import ArloSirenVirtualSecuritySystem
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from .provider import ArloProvider
class ArloBasestation(ArloDeviceBase, DeviceProvider):
class ArloBasestation(ArloDeviceBase, DeviceProvider, Settings):
MODELS_WITH_SIRENS = [
"vmb4000",
"vmb4500"
@@ -29,7 +29,10 @@ class ArloBasestation(ArloDeviceBase, DeviceProvider):
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]
return [
ScryptedInterface.DeviceProvider.value,
ScryptedInterface.Settings.value,
]
def get_device_type(self) -> str:
return ScryptedDeviceType.DeviceProvider.value
@@ -68,4 +71,20 @@ class ArloBasestation(ArloDeviceBase, DeviceProvider):
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
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)

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
import json
import socket
import time
import threading
from typing import List, TYPE_CHECKING
import scrypted_arlo_go
@@ -36,10 +37,10 @@ class ArloCameraIntercomSession(BackgroundTaskMixin):
self.arlo_basestation = camera.arlo_basestation
async def initialize_push_to_talk(self, media: MediaObject) -> None:
raise Exception("not implemented")
raise NotImplementedError("not implemented")
async def shutdown(self) -> None:
raise Exception("not implemented")
raise NotImplementedError("not implemented")
class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider, VideoClips, MotionSensor, AudioSensor, Battery, Charger):
@@ -111,8 +112,9 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
last_picture_time: datetime = datetime(1970, 1, 1)
# socket logger
logger_server = None
logger_server_port = 0
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)
@@ -126,7 +128,11 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
def __del__(self) -> None:
super().__del__()
self.logger_server.close()
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()
@@ -150,24 +156,40 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
@async_print_exception_guard
async def create_tcp_logger_server(self) -> None:
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")
self.logger_loop = asyncio.new_event_loop()
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]
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)
self.logger.info(f"Started logging server at localhost:{self.logger_server_port}")
def start_error_subscription(self) -> None:
def callback(code, message):
@@ -291,7 +313,7 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
return False
@property
def snapshot_throttle_interval(self) -> bool:
def snapshot_throttle_interval(self) -> int:
interval = self.storage.getItem("snapshot_throttle_interval")
if interval is None:
interval = 60
@@ -536,7 +558,7 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
self.intercom_session = ArloCameraWebRTCIntercomSession(self)
await self.intercom_session.initialize_push_to_talk(media)
self.logger.info("Intercom ready")
self.logger.info("Intercom initialized")
@async_print_exception_guard
async def stopIntercom(self) -> None:
@@ -746,17 +768,31 @@ class ArloCameraWebRTCIntercomSession(ArloCameraIntercomSession):
session_id, offer_sdp
)
candidates = self.arlo_pc.WaitAndGetICECandidates()
self.logger.debug(f"Gathered {len(candidates)} candidates")
for candidate in candidates:
candidate = scrypted_arlo_go.WebRTCICECandidateInit(
scrypted_arlo_go.WebRTCICECandidate(handle=candidate).ToJSON()
).Candidate
self.logger.debug(f"Sending candidate to Arlo: {candidate}")
self.provider.arlo.NotifyPushToTalkCandidate(
self.arlo_basestation, self.arlo_device,
session_id, candidate,
)
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:
@@ -840,7 +876,15 @@ class ArloCameraSIPIntercomSession(ArloCameraIntercomSession):
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("FFmpeg", self.camera.logger_server_port, ffmpeg_path, *ffmpeg_args)
self.intercom_ffmpeg_subprocess.start()
self.arlo_sip.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:

View File

@@ -3,8 +3,9 @@ sseclient==0.0.22
aiohttp==3.8.4
requests==2.28.2
cachetools==5.3.0
scrypted-arlo-go==0.3.0
scrypted-arlo-go==0.4.0
cloudscraper==1.2.71
curl-cffi==0.5.6; platform_machine != 'armv7l'
async-timeout==4.0.2
--extra-index-url=https://www.piwheels.org/simple/
--extra-index-url=https://bjia56.github.io/scrypted-arlo-go/