Skip to content

Commit

Permalink
Merge pull request #19 from YpNo/feat/supporting_last_image_as_idle
Browse files Browse the repository at this point in the history
feat(#16): Supporting last image as idle

Added the following options:
IMAP_DELETE_AFTER: Deletes mail after extracting code for 2fa (default: False)
LAST_IMAGE_IDLE: Set last frame as idle image for the camera (default: False)
DEFAULT_RESOLUTION: Default resolution for the idle video (default: (1280, 768))
  • Loading branch information
kaffetorsk authored Oct 22, 2024
2 parents 8f14dea + 4c9aa38 commit 9b23288
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 8 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__pycache__
venv
.env
.DS_Store
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ RUN python3.11 -m ensurepip
# Unbuffered logging
ENV PYTHONUNBUFFERED=TRUE

COPY idle.mp4 requirements.txt ./
COPY idle.mp4 eye.png requirements.txt ./

RUN pip3.11 install -U --upgrade-strategy eager -r requirements.txt

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ PYAARLO_STREAM_TIMEOUT: Pyaarlo backend event stream timeout (in seconds) (defau
PYAARLO_STORAGE_DIR: Pyaarlo storage_dir. Define it if you want to change the Pyaarlo storage directory. (default determined by pyaarlo)
PYAARLO_ECDH_CURVE: Allowing you defining the ECDH CURVE to use during pyaarlo authentication
IMAP_GRAB_ALL: Grabs all mails in the inbox, to avoid slow indexing problems (default: False)
IMAP_DELETE_AFTER: Deletes mail after extracting code for 2fa (default: False)
LAST_IMAGE_IDLE: Set last frame as idle image for the camera (default: False)
DEFAULT_RESOLUTION: Default resolution for the idle video (default: (1280, 768))
```
### Running
```
Expand Down
122 changes: 119 additions & 3 deletions camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import asyncio
import shlex
import os
import re
from device import Device
from decouple import config
from utils import download_file

DEBUG = config('DEBUG', default=False, cast=bool)

Expand All @@ -29,10 +31,12 @@ class Camera(Device):
STATES = ['idle', 'streaming']

def __init__(self, arlo_camera, ffmpeg_out,
motion_timeout, status_interval):
motion_timeout, status_interval, last_image_idle,
default_resolution):
super().__init__(arlo_camera, status_interval)
self.ffmpeg_out = shlex.split(ffmpeg_out.format(name=self.name))
self.timeout = motion_timeout
self.last_image_idle = last_image_idle
self._timeout_task = None
self.motion = False
self._state = None
Expand All @@ -42,6 +46,10 @@ def __init__(self, arlo_camera, ffmpeg_out,
self.proxy_reader, self.proxy_writer = os.pipe()
self._pictures = asyncio.Queue()
self._listen_pictures = False
self._default_resolution = default_resolution
self.resolution = None
self.idle_video = None
self.event_loop = asyncio.get_running_loop()
logging.info(f"Camera added: {self.name}")

async def run(self):
Expand All @@ -50,8 +58,18 @@ async def run(self):
Creates event channel between pyaarlo callbacks and async generator.
Listens for and passes events to handler.
"""
while self._arlo.is_unavailable:
while (
self._arlo.is_unavailable
or (self._arlo.has_batteries and self._arlo.battery_level == 0)
or not self._arlo.is_on
):
await asyncio.sleep(5)
logging.info(f"{self.name} availaible, starting stream")

# Start stream to get resolution
await self._start_stream()
self.stop_stream()

await self.set_state('idle')
asyncio.create_task(self._start_proxy_stream())
await super().run()
Expand Down Expand Up @@ -153,10 +171,36 @@ async def _start_idle_stream(self):
"""
Start idle picture, writing to the proxy stream
"""
default_image_path = "eye.png"

# Create video from last image if configured
if self.last_image_idle:
image_path = f"/tmp/{self.name}.jpg"
last_image = await download_file(
self._arlo.last_image, image_path
)
# Using last camera's thumbnail as idle stream if exists
if last_image:
logging.debug(
f"Last image found for {self.name}, setting as idle"
)
self.idle_video = await self._create_idle_video(image_path)

# Either not configured for last_image_idle, or it failed to create
# create idle video from default image to cameras resolution
if not self.idle_video:
self.idle_video = await self._create_idle_video(default_image_path)

# Still no idle_video present, revert to default video
if not self.idle_video:
self.idle_video = "idle.mp4"

logging.debug(f"{self.name}: idle video set to {self.idle_video}")

exit_code = 1
while exit_code > 0:
self.stream = await asyncio.create_subprocess_exec(
*['ffmpeg', '-re', '-stream_loop', '-1', '-i', 'idle.mp4',
*['ffmpeg', '-re', '-stream_loop', '-1', '-i', self.idle_video,
'-c:v', 'copy',
'-c:a', 'libmp3lame', '-ar', '44100', '-b:a', '8k',
'-bsf', 'dump_extra', '-f', 'mpegts', 'pipe:'],
Expand Down Expand Up @@ -203,6 +247,22 @@ async def _start_stream(self):
self._log_stderr(self.stream, 'live_stream')
)

if not self.resolution:
resolution = await self._get_resolution(stream)
if resolution:
logging.debug(
f"{self.name}: resolution found: {resolution}"
)
self.resolution = resolution
else:
logging.warning(
f"{self.name}: failed to find resolution, setting "
f"default: {self._default_resolution}"
)
self.resolution = self._default_resolution
else:
logging.debug(f"{self.name}: No stream available.")

async def _stream_timeout(self):
await asyncio.sleep(self.timeout)
await self.set_state('idle')
Expand Down Expand Up @@ -263,6 +323,62 @@ async def mqtt_control(self, payload):
await self.event_loop.run_in_executor(
None, self._arlo.request_snapshot)

async def _create_idle_video(self, image_path):
"""
Creates video from still image, with the cameras resolution.
Reverts to default on failure.
"""
output_path = f"{self.name}-idle.mp4"

convert = await asyncio.create_subprocess_exec(
*['ffmpeg', '-loop', '1', '-i', image_path,
'-f', 'lavfi', '-i', 'anullsrc=r=16000:cl=mono',
'-c:v', 'libx264', '-c:a', 'mp2', '-t', '5',
'-pix_fmt', 'yuv420p', '-r', '24', '-g', '24', '-vf',
f"scale={self.resolution[0]}:{self.resolution[1]}",
'-f', 'mpegts', '-y', output_path],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE
)

if DEBUG:
asyncio.create_task(
self._log_stderr(convert, 'create_idle')
)

exit_code = await convert.wait()
if exit_code > 0:
logging.warning(
f"{self.name}: failed to create idle video from {image_path}"
)
output_path = None

return output_path

async def _get_resolution(self, stream):
probe = await asyncio.create_subprocess_exec(
*['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', stream
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
stdout, _ = await probe.communicate()

result = False

if probe.returncode == 0:
try:
pattern = r"(\d{3,4}),(\d{3,4})"
match = re.search(pattern, stdout.decode())
if match:
result = (match.group(1), match.group(2))
except UnicodeDecodeError:
pass

return result

async def _log_stderr(self, stream, label):
"""
Continuously read from stderr and log the output.
Expand Down
1 change: 0 additions & 1 deletion device.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ async def run(self):
Creates event channel between pyaarlo callbacks and async generator.
Listens for and passes events to handler.
"""
self.event_loop = asyncio.get_running_loop()
event_get, event_put = self.create_sync_async_channel()
self._arlo.add_attr_callback('*', event_put)
asyncio.create_task(self._periodic_status_trigger())
Expand Down
11 changes: 8 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
IMAP_HOST = config('IMAP_HOST')
IMAP_USER = config('IMAP_USER')
IMAP_PASS = config('IMAP_PASS')
IMAP_GRAB_ALL = config('IMAP_GRAB_ALL', default=False)
IMAP_GRAB_ALL = config('IMAP_GRAB_ALL', default=False, cast=bool)
IMAP_DELETE_AFTER = config('IMAP_DELETE_AFTER', default=False, cast=bool)
MQTT_BROKER = config('MQTT_BROKER', default=None)
FFMPEG_OUT = config('FFMPEG_OUT')
DEFAULT_RESOLUTION = config('DEFAULT_RESOLUTION', default=(1280, 768))
MOTION_TIMEOUT = config('MOTION_TIMEOUT', default=60, cast=int)
STATUS_INTERVAL = config('STATUS_INTERVAL', default=120, cast=int)
LAST_IMAGE_IDLE = config('LAST_IMAGE_IDLE', default=False, cast=bool)
DEBUG = config('DEBUG', default=False, cast=bool)
PYAARLO_BACKEND = config('PYAARLO_BACKEND', default=None)
PYAARLO_REFRESH_DEVICES = config('PYAARLO_REFRESH_DEVICES', default=0, cast=int)
Expand All @@ -43,7 +46,8 @@ async def main():
'tfa_host': IMAP_HOST,
'tfa_username': IMAP_USER,
'tfa_password': IMAP_PASS,
'tfa_grab_all': IMAP_GRAB_ALL
'tfa_grab_all': IMAP_GRAB_ALL,
'tfa_delete_after': IMAP_DELETE_AFTER
}

if PYAARLO_REFRESH_DEVICES:
Expand All @@ -68,7 +72,8 @@ async def main():

# Initialize cameras
cameras = [Camera(
c, FFMPEG_OUT, MOTION_TIMEOUT, STATUS_INTERVAL
c, FFMPEG_OUT, MOTION_TIMEOUT, STATUS_INTERVAL, LAST_IMAGE_IDLE,
DEFAULT_RESOLUTION
) for c in arlo.cameras]

# Start both
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ git+https://github.com/kaffetorsk/pyaarlo@grab_all
python-decouple
aiomqtt==1.2.1
aiostream
aiohttp
17 changes: 17 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import aiohttp


async def download_file(url, dest_filename):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
# Read file data
with open(dest_filename, 'wb') as f:
while True:
chunk = await response.content.read(1024)
if not chunk:
break
f.write(chunk)
return True
else:
return False

0 comments on commit 9b23288

Please sign in to comment.