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

This commit is contained in:
Koushik Dutta
2023-06-07 08:17:22 -07:00
9 changed files with 263 additions and 331 deletions

View File

@@ -1,13 +1,25 @@
# Arlo Plugin for Scrypted
The Arlo Plugin connects Scrypted to Arlo cloud, allowing you to access all of your Arlo cameras in 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 connection to their servers per account. Using a separate account allows you to use the Arlo app or website simultaneously with this plugin.
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.
## 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 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.

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ import math
import random
import time
import uuid
from urllib.parse import urlparse, parse_qs
stream_class = MQTTStream
@@ -81,8 +82,11 @@ USER_AGENTS = {
class Arlo(object):
BASE_URL = 'my.arlo.com'
AUTH_URL = 'ocapi-app.arlo.com'
BACKUP_AUTH_HOSTS = list(scrypted_arlo_go.BACKUP_AUTH_HOSTS())
TRANSID_PREFIX = 'web'
random.shuffle(BACKUP_AUTH_HOSTS)
def __init__(self, username, password):
self.username = username
self.password = password
@@ -166,13 +170,11 @@ class Arlo(object):
# in case cloudflare rejects our auth request...
logger.warning(f"Using fallback authentication host due to: {e}")
backup_hosts = list(scrypted_arlo_go.BACKUP_AUTH_HOSTS())
random.shuffle(backup_hosts)
auth_host = pick_host([
base64.b64decode(h.encode("utf-8")).decode("utf-8")
for h in backup_hosts
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")
@@ -200,10 +202,15 @@ class Arlo(object):
raw=True
)
factor_id = next(
i for i in factors_body['data']['items']
if (i['factorType'] == 'EMAIL' or i['factorType'] == 'SMS')
and i['factorRole'] == "PRIMARY"
)['factorId']
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(
@@ -227,6 +234,9 @@ class Arlo(object):
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()
# Update Authorization code with new code
@@ -282,14 +292,16 @@ class Arlo(object):
cameras[camera['deviceId']] = camera
# filter out cameras without basestation, where they are their own basestations
# for now, keep doorbells and sirens in the list so they get pings
# 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
proper_basestations = {}
for basestation in basestations.values():
if basestation['deviceId'] == basestation.get('parentId') and basestation['deviceType'] not in ['doorbell', 'siren']:
if basestation['deviceId'] == basestation.get('parentId') and \
basestation['deviceType'] not in ['doorbell', 'siren', 'arloq', 'arloqs']:
continue
proper_basestations[basestation['deviceId']] = basestation
logger.info(f"Will send heartbeat to the following basestations: {list(proper_basestations.keys())}")
logger.info(f"Will send heartbeat to the following devices: {list(proper_basestations.keys())}")
# start heartbeat loop with only basestations
asyncio.get_event_loop().create_task(heartbeat(self, list(proper_basestations.values())))
@@ -629,7 +641,7 @@ class Arlo(object):
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.request.get(f'https://{self.BASE_URL}/hmsweb/v2/users/devices')
devices = self._getDevicesImpl()
if device_type:
devices = [ device for device in devices if device.get('deviceType') in device_type]
@@ -641,20 +653,31 @@ class Arlo(object):
return devices
async def StartStream(self, basestation, camera):
@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
async def StartStream(self, basestation, camera, mode="rtsp"):
"""
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://<url>' -acodec copy -vcodec copy test.mp4
The request to /users/devices/startStream returns: { url:rtsp://<url>:443/vzmodulelive?egressToken=b<xx>&userAgent=iOS&cameraId=<camid>}
If mode is set to "dash", returns the url to the mpd file for DASH streaming.
"""
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={
@@ -670,14 +693,17 @@ class Arlo(object):
"cameraId": camera.get('deviceId')
}
},
headers={"xcloudId":camera.get('xCloudId')}
headers={"xcloudId":camera.get('xCloudId'), 'User-Agent': ua}
)
def callback(self, event):
#return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://")
properties = event.get("properties", {})
if properties.get("activityState") == "userStreamActive":
return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://")
if mode == "rtsp":
return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://")
else:
return nl.stream_url_dict['url'].replace(":80", "")
return None
return await self.TriggerAndHandleEvent(
@@ -688,6 +714,27 @@ class Arlo(object):
callback,
)
def GetMPDHeaders(self, url: str) -> dict:
parsed = urlparse(url)
query = parse_qs(parsed.query)
headers = {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Connection": "keep-alive",
"DNT": "1",
"Egress-Token": query['egressToken'][0],
"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 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)

View File

@@ -2,8 +2,7 @@ import ssl
from socket import setdefaulttimeout
import requests
from requests_toolbelt.adapters import host_header_ssl
from cryptography import x509
from cryptography.x509.oid import ExtensionOID
import scrypted_arlo_go
from .logging import logger
@@ -18,12 +17,10 @@ def pick_host(hosts, hostname_to_match, endpoint_to_test):
for host in hosts:
try:
c = ssl.get_server_certificate((host, 443))
c = x509.load_pem_x509_certificate(c.encode("utf-8"))
if hostname_to_match in c.subject.rfc4514_string() or \
hostname_to_match in c.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName):
r = session.post(f"https://{host}{endpoint_to_test}", headers={"Host": hostname_to_match})
r.raise_for_status()
return host
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!")

View File

@@ -5,16 +5,16 @@ import aiohttp
from async_timeout import timeout as async_timeout
from datetime import datetime, timedelta
import json
import threading
import time
from typing import List, TYPE_CHECKING
import scrypted_arlo_go
import scrypted_sdk
from scrypted_sdk.types import Setting, Settings, SettingValue, Device, Camera, VideoCamera, VideoClips, VideoClip, VideoClipOptions, MotionSensor, AudioSensor, Battery, Charger, ChargeState, DeviceProvider, MediaObject, ResponsePictureOptions, ResponseMediaStreamOptions, ScryptedMimeTypes, ScryptedInterface, ScryptedDeviceType
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 .debug import EXPERIMENTAL
from .arlo.arlo_async import USER_AGENTS
from .experimental import EXPERIMENTAL
from .base import ArloDeviceBase
from .spotlight import ArloSpotlight, ArloFloodlight
from .vss import ArloSirenVirtualSecuritySystem
@@ -76,6 +76,7 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
timeout: int = 30
intercom_session = None
goSM = None
light: ArloSpotlight = None
vss: ArloSirenVirtualSecuritySystem = None
picture_lock: asyncio.Lock = None
@@ -152,14 +153,8 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
ScryptedInterface.Settings.value,
])
if EXPERIMENTAL:
if self.two_way_audio:
results.discard(ScryptedInterface.RTCSignalingChannel.value)
results.add(ScryptedInterface.Intercom.value)
if self.webrtc_emulation:
results.add(ScryptedInterface.RTCSignalingChannel.value)
results.discard(ScryptedInterface.Intercom.value)
if EXPERIMENTAL or not self.uses_sip_push_to_talk:
results.add(ScryptedInterface.Intercom.value)
if self.has_battery:
results.add(ScryptedInterface.Battery.value)
@@ -174,11 +169,6 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
if self.has_cloud_recording:
results.add(ScryptedInterface.VideoClips.value)
if EXPERIMENTAL:
if not self._can_push_to_talk():
results.discard(ScryptedInterface.RTCSignalingChannel.value)
results.discard(ScryptedInterface.Intercom.value)
return list(results)
def get_device_type(self) -> str:
@@ -220,23 +210,6 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
] + vss.get_builtin_child_device_manifests())
return results
@property
def webrtc_emulation(self) -> bool:
if self.storage:
return True if self.storage.getItem("webrtc_emulation") else False
else:
return False
@property
def two_way_audio(self) -> bool:
if self.storage:
val = self.storage.getItem("two_way_audio")
if val is None:
val = True
return val
else:
return True
@property
def wired_to_power(self) -> bool:
if self.storage:
@@ -283,6 +256,10 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
def has_battery(self) -> bool:
return not any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITHOUT_BATTERY])
@property
def uses_sip_push_to_talk(self) -> bool:
return self.arlo_device["deviceId"] == self.arlo_device["parentId"]
async def getSettings(self) -> List[Setting]:
result = []
if self.has_battery:
@@ -323,26 +300,6 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
"type": "number",
}
)
if self._can_push_to_talk() and EXPERIMENTAL:
result.extend([
{
"group": "General",
"key": "two_way_audio",
"title": "(Experimental) Enable native two-way audio",
"value": self.two_way_audio,
"description": "Enables two-way audio for this device. Not yet completely functional on all audio senders.",
"type": "boolean",
},
{
"group": "General",
"key": "webrtc_emulation",
"title": "(Highly Experimental) Emulate WebRTC Camera",
"value": self.webrtc_emulation,
"description": "Configures the plugin to offer this device as a WebRTC camera, merging video/audio stream with two-way audio. "
"If enabled, takes precedence over native two-way audio. May use increased system resources.",
"type": "boolean",
},
])
return result
@async_print_exception_guard
@@ -351,7 +308,7 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
return
if key in ["webrtc_emulation", "two_way_audio", "wired_to_power"]:
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"]:
@@ -408,8 +365,8 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
return await scrypted_sdk.mediaManager.createMediaObject(self.last_picture, "image/jpeg")
async def getVideoStreamOptions(self) -> List[ResponseMediaStreamOptions]:
return [
async def getVideoStreamOptions(self, id: str = None) -> List[ResponseMediaStreamOptions]:
options = [
{
"id": 'default',
"name": 'Cloud RTSP',
@@ -423,72 +380,138 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
"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,
}
]
async def _getVideoStreamURL(self) -> str:
self.logger.info("Requesting stream")
rtsp_url = await asyncio.wait_for(self.provider.arlo.StartStream(self.arlo_basestation, self.arlo_device), timeout=self.timeout)
self.logger.debug(f"Got stream URL at {rtsp_url}")
return rtsp_url
if id is None:
return options
async def getVideoStream(self, options: dict = None) -> MediaObject:
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), 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 = None) -> MediaObject:
self.logger.debug("Entered getVideoStream")
rtsp_url = await self._getVideoStreamURL()
mso = (await self.getVideoStreamOptions())[0]
mso = await self.getVideoStreamOptions(id=options["id"])
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': rtsp_url,
'container': 'rtsp',
'url': url,
'container': container,
'mediaStreamOptions': mso,
'inputArguments': [
'-f', 'rtsp',
'-i', rtsp_url,
'-f', container,
*additional_ffmpeg_args,
'-i', url,
]
}
return await scrypted_sdk.mediaManager.createFFmpegMediaObject(ffmpeg_input)
@async_print_exception_guard
async def startRTCSignalingSession(self, scrypted_session):
plugin_session = ArloCameraRTCSignalingSession(self)
await plugin_session.initialize()
scrypted_setup = {
"type": "offer",
"audio": {
"direction": "sendrecv" if self._can_push_to_talk() else "recvonly",
},
"video": {
"direction": "recvonly",
}
}
plugin_setup = {}
scrypted_offer = await scrypted_session.createLocalDescription("offer", scrypted_setup, sendIceCandidate=plugin_session.addIceCandidate)
self.logger.info(f"Scrypted offer sdp:\n{scrypted_offer['sdp']}")
await plugin_session.setRemoteDescription(scrypted_offer, plugin_setup)
plugin_answer = await plugin_session.createLocalDescription("answer", plugin_setup, scrypted_session.sendIceCandidate)
self.logger.info(f"Scrypted answer sdp:\n{plugin_answer['sdp']}")
await scrypted_session.setRemoteDescription(plugin_answer, scrypted_setup)
return ArloCameraRTCSessionControl(plugin_session)
async def startIntercom(self, media) -> None:
self.logger.info("Starting intercom")
self.intercom_session = ArloCameraRTCSignalingSession(self)
await self.intercom_session.initialize_push_to_talk(media)
if self.uses_sip_push_to_talk:
sip_info = self.provider.arlo.GetSIPInfo()
sip_call_info = sip_info["sipCallInfo"]
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.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.goSM = scrypted_arlo_go.NewSIPWebRTCManager("Arlo SIP "+self.nativeId, 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.goSM.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"],
"-vn",
"-acodec", "libopus",
"-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("Arlo Subprocess "+self.logger_name, ffmpeg_path, *ffmpeg_args)
self.intercom_ffmpeg_subprocess.start()
self.goSM.Start()
else:
# we need to do signaling through arlo cloud apis
self.intercom_session = ArloCameraIntercomSession(self)
await self.intercom_session.initialize_push_to_talk(media)
self.logger.info("Intercom ready")
@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
def _can_push_to_talk(self) -> bool:
# Right now, only implement push to talk for basestation cameras
return self.arlo_device["deviceId"] != self.arlo_device["parentId"]
if self.goSM is not None:
self.goSM.Close()
self.goSM = None
async def getVideoClip(self, videoId: str) -> MediaObject:
self.logger.info(f"Getting video clip {videoId}")
@@ -580,7 +603,7 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
return self.vss
class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
class ArloCameraIntercomSession(BackgroundTaskMixin):
def __init__(self, camera):
super().__init__()
self.camera = camera
@@ -589,10 +612,8 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
self.arlo_device = camera.arlo_device
self.arlo_basestation = camera.arlo_basestation
self.ffmpeg_subprocess = None
self.intercom_ffmpeg_subprocess = None
self.scrypted_pc = None
self.arlo_pc = None
self.arlo_sdp_answered = False
@@ -639,24 +660,26 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
self.provider.arlo.SubscribeToCandidateAnswers(self.arlo_basestation, self.arlo_device, callback)
)
async def initialize(self):
self.logger.info("Initializing video stream for RTC")
rtsp_url = await self.camera._getVideoStreamURL()
@async_print_exception_guard
async def initialize_push_to_talk(self, media):
self.logger.info("Initializing push to talk")
cfg = scrypted_arlo_go.WebRTCConfiguration(
ICEServers=scrypted_arlo_go.Slice_webrtc_ICEServer([
scrypted_arlo_go.NewWebRTCICEServer(
scrypted_arlo_go.go.Slice_string(["turn:turn0.clockworkmod.com", "turn:n0.clockworkmod.com", "turn:n1.clockworkmod.com"]),
"foo",
"bar"
)
])
)
cfg = scrypted_arlo_go.WebRTCConfiguration()
self.scrypted_pc = scrypted_arlo_go.NewWebRTCManager("Arlo "+self.camera.logger_name, cfg)
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]}")
audio_port = self.scrypted_pc.InitializeAudioRTPListener(scrypted_arlo_go.WebRTCMimeTypeOpus)
video_port = self.scrypted_pc.InitializeVideoRTPListener(scrypted_arlo_go.WebRTCMimeTypeH264)
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("Arlo WebRTC "+self.camera.logger_name, 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 = [
@@ -665,200 +688,48 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
"-loglevel", "error",
"-analyzeduration", "0",
"-fflags", "-nobuffer",
"-max_probe_packets", "2",
"-vcodec", "h264",
"-acodec", "aac",
"-i", rtsp_url,
"-an",
"-vcodec", "copy",
"-f", "rtp",
"-flush_packets", "1",
f"rtp://localhost:{video_port}",
"-probesize", "500000",
*ffmpeg_params["inputArguments"],
"-vn",
"-acodec", "libopus",
"-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 {ffmpeg_args}")
self.logger.debug(f"Starting ffmpeg at {ffmpeg_path} with '{' '.join(ffmpeg_args)}'")
self.ffmpeg_subprocess = HeartbeatChildProcess("Arlo "+self.camera.logger_name, ffmpeg_path, *ffmpeg_args)
self.ffmpeg_subprocess.start()
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("Arlo Subprocess "+self.camera.logger_name, ffmpeg_path, *ffmpeg_args)
self.intercom_ffmpeg_subprocess.start()
if self.camera._can_push_to_talk():
self.create_task(self.initialize_push_to_talk())
self.sdp_answered = False
async def initialize_push_to_talk(self, media=None):
try:
self.logger.info("Initializing push to talk")
offer = self.arlo_pc.CreateOffer()
offer_sdp = scrypted_arlo_go.WebRTCSessionDescriptionSDP(offer)
self.logger.info(f"Arlo offer sdp:\n{offer_sdp}")
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]}")
self.arlo_pc.SetLocalDescription(offer)
cfg = scrypted_arlo_go.WebRTCConfiguration(
ICEServers=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("Arlo "+self.camera.logger_name, cfg)
self.provider.arlo.NotifyPushToTalkSDP(
self.arlo_basestation, self.arlo_device,
session_id, offer_sdp
)
if media is not None:
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"],
"-vn",
"-acodec", "libopus",
"-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 {ffmpeg_args}")
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("Arlo "+self.camera.logger_name, ffmpeg_path, *ffmpeg_args)
self.intercom_ffmpeg_subprocess.start()
else:
self.logger.debug("Starting audio track forwarder")
self.scrypted_pc.ForwardAudioTo(self.arlo_pc)
self.logger.debug("Started audio track forwarder")
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(
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, offer_sdp
session_id, candidate,
)
def forward_candidates():
try:
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,
)
except Exception as e:
self.logger.error(e)
t = threading.Thread(target=forward_candidates)
t.start()
except Exception as e:
self.logger.error(e)
async def createLocalDescription(self, type, setup, sendIceCandidate=None):
if type == "offer":
raise Exception("can only create answers in ArloCameraRTCSignalingSession.createLocalDescription")
answer = self.scrypted_pc.CreateAnswer()
answer_sdp = scrypted_arlo_go.WebRTCSessionDescriptionSDP(answer)
self.scrypted_pc.SetLocalDescription(answer)
if sendIceCandidate is not None:
loop = asyncio.get_event_loop()
def forward_candidates():
try:
candidates = self.scrypted_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 scrypted: {candidate}")
loop.call_soon_threadsafe(
self.create_task,
sendIceCandidate({
"candidate": candidate,
"sdpMid": "0",
"sdpMLineIndex": 0,
})
)
except Exception as e:
self.logger.error(e)
t = threading.Thread(target=forward_candidates)
t.start()
return {
"sdp": answer_sdp,
"type": "answer"
}
async def setRemoteDescription(self, description, setup):
if description["type"] != "offer":
raise Exception("can only accept offers in ArloCameraRTCSignalingSession.createLocalDescription")
sdp = scrypted_arlo_go.WebRTCSessionDescription(scrypted_arlo_go.NewWebRTCSDPType("offer"), description["sdp"])
self.scrypted_pc.SetRemoteDescription(sdp)
async def addIceCandidate(self, candidate):
candidate = scrypted_arlo_go.WebRTCICECandidateInit(candidate["candidate"], "0", 0)
self.scrypted_pc.AddICECandidate(candidate)
async def getOptions(self):
pass
async def unmute_relay(self):
return
await self.arlo_pc.unmute_relay(self.arlo_relay_track)
async def mute_relay(self):
return
await self.arlo_pc.mute_relay(self.arlo_relay_track)
async def shutdown(self):
if self.ffmpeg_subprocess is not None:
self.ffmpeg_subprocess.stop()
self.ffmpeg_subprocess = None
if self.intercom_ffmpeg_subprocess is not None:
self.intercom_ffmpeg_subprocess.stop()
self.intercom_ffmpeg_subprocess = None
if self.scrypted_pc is not None:
self.scrypted_pc.Close()
self.scrypted_pc = None
if self.arlo_pc is not None:
self.arlo_pc.Close()
self.arlo_pc = None
class ArloCameraRTCSessionControl:
def __init__(self, arlo_session):
self.arlo_session = arlo_session
self.logger = arlo_session.logger
async def setPlayback(self, options):
self.logger.debug(f"setPlayback options {options}")
audio = options.get("audio")
if audio is None:
return
if audio:
await self.arlo_session.unmute_relay()
else:
await self.arlo_session.mute_relay()
async def endSession(self):
self.logger.info("Ending RTC session")
await self.arlo_session.shutdown()
self.arlo_pc = None

View File

@@ -87,6 +87,9 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
@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"
@@ -149,13 +152,15 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
def arlo(self) -> Arlo:
if self._arlo is not None:
if self._arlo_mfa_complete_auth is not None:
if self._arlo_mfa_code == "":
if not self._arlo_mfa_code:
return None
self.logger.info("Completing Arlo MFA...")
self._arlo_mfa_complete_auth(self._arlo_mfa_code)
self._arlo_mfa_complete_auth = None
self._arlo_mfa_code = None
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())))
@@ -175,7 +180,6 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
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:
@@ -185,6 +189,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
except Exception as e:
traceback.print_exc()
self._arlo = None
self._arlo_mfa_complete_auth = None
self._arlo_mfa_code = None
return None
@@ -455,9 +460,9 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
"group": "General",
"key": "arlo_transport",
"title": "Underlying Transport Protocol",
"description": "Select the underlying transport protocol used to connect to Arlo Cloud.",
"description": "Arlo Cloud currently only supports the SSE protocol.",
"value": self.arlo_transport,
"choices": self.arlo_transport_choices,
"readonly": True,
},
{
"group": "General",
@@ -627,7 +632,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
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']}): {scrypted_interfaces}")
self.logger.debug(f"Interfaces for {nativeId} ({camera['modelId']} parent {camera['parentId']}): {scrypted_interfaces}")
if camera["deviceId"] == camera["parentId"]:
provider_to_device_map.setdefault(None, []).append(manifest)
@@ -647,6 +652,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceL
if len(cameras) != len(camera_devices):
self.logger.info(f"Discovered {len(cameras)} cameras, but only {len(camera_devices)} are usable")
self.logger.info(f"Are all cameras shared with admin permissions?")
else:
self.logger.info(f"Discovered {len(cameras)} cameras")

View File

@@ -3,9 +3,8 @@ sseclient==0.0.22
aiohttp==3.8.4
requests==2.28.2
cachetools==5.3.0
scrypted-arlo-go==0.0.2
scrypted-arlo-go==0.1.3
cloudscraper==1.2.71
cryptography==38.0.4
async-timeout==4.0.2
--extra-index-url=https://www.piwheels.org/simple/
--extra-index-url=https://bjia56.github.io/scrypted-arlo-go/