diff --git a/.gitignore b/.gitignore index 7384a17..5cdf7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ venv .env +.DS_Store diff --git a/Dockerfile b/Dockerfile index c061c80..7eee3b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 80d968c..8aaac89 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/camera.py b/camera.py index 68ec026..670991f 100644 --- a/camera.py +++ b/camera.py @@ -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) @@ -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 @@ -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): @@ -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() @@ -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:'], @@ -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') @@ -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. diff --git a/device.py b/device.py index 50e19fd..7dcff64 100644 --- a/device.py +++ b/device.py @@ -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()) diff --git a/main.py b/main.py index 1fc739d..22142e9 100644 --- a/main.py +++ b/main.py @@ -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) @@ -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: @@ -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 diff --git a/requirements.txt b/requirements.txt index b699ada..a4d3d5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ git+https://github.com/kaffetorsk/pyaarlo@grab_all python-decouple aiomqtt==1.2.1 aiostream +aiohttp diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..3bad8b3 --- /dev/null +++ b/utils.py @@ -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