diff --git a/pyroengine/core.py b/pyroengine/core.py index 182d241..7c2363a 100644 --- a/pyroengine/core.py +++ b/pyroengine/core.py @@ -58,13 +58,16 @@ def is_day_time(cache, frame, strategy, delta=0): return is_day -async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue) -> None: +async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue) -> bool: """ - Captures an image from the camera and puts it into a queue. + Captures an image from the camera and puts it into a queue. Returns whether it is daytime for this camera. Args: camera (ReolinkCamera): The camera instance. image_queue (asyncio.Queue): The queue to put the captured image. + + Returns: + bool: True if it is daytime according to this camera, False otherwise. """ cam_id = camera.ip_address try: @@ -75,16 +78,21 @@ async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue # Move camera to the next pose to avoid waiting next_pos_id = camera.cam_poses[(idx + 1) % len(camera.cam_poses)] camera.move_camera("ToPos", idx=int(next_pos_id), speed=50) - if frame is not None and is_day_time(None, frame, "ir"): + if frame is not None: await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control + if not is_day_time(None, frame, "ir"): + return False else: frame = camera.capture() if frame is not None: await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control + if not is_day_time(None, frame, "ir"): + return False except Exception as e: logging.exception(f"Error during image capture from camera {cam_id}: {e}") + return True class SystemController: @@ -106,17 +114,21 @@ def __init__(self, engine: Engine, cameras: List[ReolinkCamera]) -> None: """ self.engine = engine self.cameras = cameras - self.day_time = True + self.is_day = True - async def capture_images(self, image_queue: asyncio.Queue) -> None: + async def capture_images(self, image_queue: asyncio.Queue) -> bool: """ Captures images from all cameras using asyncio. Args: image_queue (asyncio.Queue): The queue to put the captured images. + + Returns: + bool: True if it is daytime according to all cameras, False otherwise. """ tasks = [capture_camera_image(camera, image_queue) for camera in self.cameras] - await asyncio.gather(*tasks) + day_times = await asyncio.gather(*tasks) + return all(day_times) async def analyze_stream(self, image_queue: asyncio.Queue) -> None: """ @@ -137,41 +149,72 @@ async def analyze_stream(self, image_queue: asyncio.Queue) -> None: finally: image_queue.task_done() # Mark the task as done - async def run(self, period: int = 30, send_alerts: bool = True) -> None: + async def night_mode(self) -> bool: + """ + Checks if it is nighttime for any camera. + + Returns: + bool: True if it is daytime for all cameras, False otherwise. + """ + for camera in self.cameras: + cam_id = camera.ip_address + try: + if camera.cam_type == "ptz": + for idx, pose_id in enumerate(camera.cam_poses): + cam_id = f"{camera.ip_address}_{pose_id}" + frame = camera.capture() + # Move camera to the next pose to avoid waiting + next_pos_id = camera.cam_poses[(idx + 1) % len(camera.cam_poses)] + camera.move_camera("ToPos", idx=int(next_pos_id), speed=50) + if not is_day_time(None, frame, "ir"): + return False + else: + frame = camera.capture() + if not is_day_time(None, frame, "ir"): + return False + except Exception as e: + logging.exception(f"Error during image capture from camera {cam_id}: {e}") + return True + + async def run(self, period: int = 30, send_alerts: bool = True) -> bool: """ Captures and analyzes all camera streams, then processes alerts. Args: period (int): The time period between captures in seconds. - send_alerts (bool): Boolean to activate / deactivate alert sending + send_alerts (bool): Boolean to activate / deactivate alert sending. + + Returns: + bool: True if it is daytime according to all cameras, False otherwise. """ try: + image_queue: asyncio.Queue[Any] = asyncio.Queue() - if self.day_time: - image_queue: asyncio.Queue[Any] = asyncio.Queue() - - # Start the image processor task - processor_task = asyncio.create_task(self.analyze_stream(image_queue)) + # Start the image processor task + processor_task = asyncio.create_task(self.analyze_stream(image_queue)) - # Capture images concurrently - await self.capture_images(image_queue) + # Capture images concurrently + self.is_day = await self.capture_images(image_queue) - # Wait for the image processor to finish processing - await image_queue.join() # Ensure all tasks are marked as done + # Wait for the image processor to finish processing + await image_queue.join() # Ensure all tasks are marked as done - # Signal the image processor to stop processing - await image_queue.put(None) - await processor_task # Ensure the processor task completes + # Signal the image processor to stop processing + await image_queue.put(None) + await processor_task # Ensure the processor task completes - # Process alerts + # Process alerts + if send_alerts: try: - if send_alerts: - self.engine._process_alerts() + self.engine._process_alerts() except Exception as e: logging.error(f"Error processing alerts: {e}") + return self.is_day + except Exception as e: logging.warning(f"Analyze stream error: {e}") + return True async def main_loop(self, period: int, send_alerts: bool = True) -> None: """ @@ -179,16 +222,21 @@ async def main_loop(self, period: int, send_alerts: bool = True) -> None: Args: period (int): The time period between captures in seconds. - send_alerts (bool): Boolean to activate / deactivate alert sending + send_alerts (bool): Boolean to activate / deactivate alert sending. """ while True: start_ts = time.time() await self.run(period, send_alerts) - # Sleep only once all images are processed - loop_time = time.time() - start_ts - sleep_time = max(period - (loop_time), 0) - logging.info(f"Loop run under {loop_time:.2f} seconds, sleeping for {sleep_time:.2f}") - await asyncio.sleep(sleep_time) + if not self.is_day: + while not await self.night_mode(): + logging.info("Nighttime detected by at least one camera, sleeping for 1 hour.") + await asyncio.sleep(3600) # Sleep for 1 hour + else: + # Sleep only once all images are processed + loop_time = time.time() - start_ts + sleep_time = max(period - (loop_time), 0) + logging.info(f"Loop run under {loop_time:.2f} seconds, sleeping for {sleep_time:.2f}") + await asyncio.sleep(sleep_time) def __repr__(self) -> str: """ diff --git a/tests/test_core.py b/tests/test_core.py index ef1c4f2..42a21f9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -34,6 +34,16 @@ def mock_cameras_ptz(mock_wildfire_image): return [camera] +@pytest.fixture +def mock_cameras_ptz_night(): + camera = MagicMock() + camera.capture.return_value = Image.new("RGB", (100, 100), (255, 255, 255)) # Mock captured image + camera.cam_type = "ptz" + camera.cam_poses = [1, 2] + camera.ip_address = "192.168.1.1" + return [camera] + + @pytest.fixture def system_controller(mock_engine, mock_cameras): return SystemController(engine=mock_engine, cameras=mock_cameras) @@ -44,6 +54,21 @@ def system_controller_ptz(mock_engine, mock_cameras_ptz): return SystemController(engine=mock_engine, cameras=mock_cameras_ptz) +@pytest.fixture +def system_controller_ptz_night(mock_engine, mock_cameras_ptz_night): + return SystemController(engine=mock_engine, cameras=mock_cameras_ptz_night) + + +@pytest.mark.asyncio +async def test_night_mode(system_controller): + assert await system_controller.night_mode() + + +@pytest.mark.asyncio +async def test_night_mode_ptz(system_controller_ptz_night): + assert not await system_controller_ptz_night.night_mode() + + def test_is_day_time_ir_strategy(mock_wildfire_image): # Use day image assert is_day_time(None, mock_wildfire_image, "ir")