Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

arlo: eco mode for snapshot throttling + disable experimental features #816

Merged
merged 3 commits into from
May 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/arlo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/arlo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@scrypted/arlo",
"version": "0.7.20",
"version": "0.7.21",
"description": "Arlo Plugin for Scrypted",
"keywords": [
"scrypted",
Expand Down
121 changes: 104 additions & 17 deletions plugins/arlo/src/arlo_plugin/camera.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import aiohttp
from async_timeout import timeout as async_timeout
from datetime import datetime, timedelta
import json
import threading
Expand All @@ -10,8 +12,9 @@
import scrypted_arlo_go

import scrypted_sdk
from scrypted_sdk.types import Setting, Settings, 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, VideoClips, VideoClip, VideoClipOptions, MotionSensor, AudioSensor, Battery, Charger, ChargeState, DeviceProvider, MediaObject, ResponsePictureOptions, ResponseMediaStreamOptions, ScryptedMimeTypes, ScryptedInterface, ScryptedDeviceType

from .debug import EXPERIMENTAL
from .base import ArloDeviceBase
from .spotlight import ArloSpotlight, ArloFloodlight
from .vss import ArloSirenVirtualSecuritySystem
Expand Down Expand Up @@ -75,9 +78,16 @@ class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider,
intercom_session = None
light: ArloSpotlight = None
vss: ArloSirenVirtualSecuritySystem = None
picture_lock: asyncio.Lock = None

# eco mode bookkeeping
last_picture: bytes = None
last_picture_time: datetime = datetime(1970, 1, 1)

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_motion_subscription()
self.start_audio_subscription()
self.start_battery_subscription()
Expand Down Expand Up @@ -142,13 +152,14 @@ def get_applicable_interfaces(self) -> List[str]:
ScryptedInterface.Settings.value,
])

if self.two_way_audio:
results.discard(ScryptedInterface.RTCSignalingChannel.value)
results.add(ScryptedInterface.Intercom.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 self.webrtc_emulation:
results.add(ScryptedInterface.RTCSignalingChannel.value)
results.discard(ScryptedInterface.Intercom.value)

if self.has_battery:
results.add(ScryptedInterface.Battery.value)
Expand All @@ -163,9 +174,10 @@ def get_applicable_interfaces(self) -> List[str]:
if self.has_cloud_recording:
results.add(ScryptedInterface.VideoClips.value)

if not self._can_push_to_talk():
results.discard(ScryptedInterface.RTCSignalingChannel.value)
results.discard(ScryptedInterface.Intercom.value)
if EXPERIMENTAL:
if not self._can_push_to_talk():
results.discard(ScryptedInterface.RTCSignalingChannel.value)
results.discard(ScryptedInterface.Intercom.value)

return list(results)

Expand Down Expand Up @@ -232,6 +244,21 @@ def wired_to_power(self) -> bool:
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 snapshot_throttle_interval(self) -> bool:
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)
Expand Down Expand Up @@ -261,6 +288,7 @@ async def getSettings(self) -> List[Setting]:
if self.has_battery:
result.append(
{
"group": "General",
"key": "wired_to_power",
"title": "Plugged In to External Power",
"value": self.wired_to_power,
Expand All @@ -270,16 +298,43 @@ async def getSettings(self) -> List[Setting]:
"type": "boolean",
},
)
if self._can_push_to_talk():
result.append(
{
"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",
}
)
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",
}
)
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,
Expand All @@ -291,10 +346,28 @@ async def getSettings(self) -> List[Setting]:
return result

@async_print_exception_guard
async def putSetting(self, key, value) -> None:
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 ["webrtc_emulation", "two_way_audio", "wired_to_power"]:
self.storage.setItem(key, value == "true" or value == True)
await self.provider.discover_devices()
elif key in ["eco_mode"]:
self.storage.setItem(key, value == "true" or value == True)
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 []
Expand All @@ -313,13 +386,27 @@ async def takePicture(self, options: dict = None) -> MediaObject:
self.logger.warning(f"Could not fetch from prebuffer due to: {e}")
self.logger.warning("Will try to fetch snapshot from Arlo cloud")

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 for at {pic_url}")
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 for at {pic_url}")

if pic_url is None:
raise Exception("Error taking snapshot")

if pic_url is None:
raise Exception("Error taking snapshot")
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(str.encode(pic_url), ScryptedMimeTypes.Url.value)
return await scrypted_sdk.mediaManager.createMediaObject(self.last_picture, "image/jpeg")

async def getVideoStreamOptions(self) -> List[ResponseMediaStreamOptions]:
return [
Expand Down
1 change: 1 addition & 0 deletions plugins/arlo/src/arlo_plugin/debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXPERIMENTAL = False
2 changes: 2 additions & 0 deletions plugins/arlo/src/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
paho-mqtt==1.6.1
sseclient==0.0.22
aiohttp==3.8.4
requests==2.28.2
cachetools==5.3.0
scrypted-arlo-go==0.0.2
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/
--prefer-binary