From d1ce153ca32c1de8ac4534d7659520eb2c70881d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 14 Jan 2026 19:16:19 +0100 Subject: [PATCH] Add CameraManager framework --- internal_filesystem/lib/mpos/__init__.py | 3 +- internal_filesystem/lib/mpos/board/linux.py | 10 + .../board/waveshare_esp32_s3_touch_lcd_2.py | 10 + .../lib/mpos/camera_manager.py | 197 ++++++++++++ tests/test_camera_manager.py | 298 ++++++++++++++++++ 5 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 internal_filesystem/lib/mpos/camera_manager.py create mode 100644 tests/test_camera_manager.py diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 8ef39018..6d103dd4 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -52,6 +52,7 @@ from . import net from . import content from . import time from . import sensor_manager +from . import camera_manager from . import sdcard from . import battery_voltage from . import audio @@ -89,5 +90,5 @@ __all__ = [ "get_all_widgets_with_text", # Submodules "apps", "ui", "config", "net", "content", "time", "sensor_manager", - "sdcard", "battery_voltage", "audio", "hardware", "bootloader" + "camera_manager", "sdcard", "battery_voltage", "audio", "hardware", "bootloader" ] diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 8364301d..4a7cb5db 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -122,6 +122,16 @@ import mpos.sensor_manager as SensorManager # (On Linux desktop, this will fail gracefully but set _initialized flag) SensorManager.init(None) +# === CAMERA HARDWARE === +import mpos.camera_manager as CameraManager + +# Desktop builds can simulate a camera for testing +CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="Desktop Simulated Camera", + vendor="MicroPythonOS" +)) + print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py index 047540e4..770be76e 100644 --- a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py +++ b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py @@ -127,4 +127,14 @@ import mpos.sensor_manager as SensorManager # i2c_bus was created on line 75 for touch, reuse it for IMU SensorManager.init(i2c_bus, address=0x6B, mounted_position=SensorManager.FACING_EARTH) +# === CAMERA HARDWARE === +import mpos.camera_manager as CameraManager + +# Waveshare ESP32-S3-Touch-LCD-2 has OV5640 camera +CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640", + vendor="OmniVision" +)) + print("waveshare_esp32_s3_touch_lcd_2.py finished") diff --git a/internal_filesystem/lib/mpos/camera_manager.py b/internal_filesystem/lib/mpos/camera_manager.py new file mode 100644 index 00000000..a3e580d5 --- /dev/null +++ b/internal_filesystem/lib/mpos/camera_manager.py @@ -0,0 +1,197 @@ +"""Android-inspired CameraManager for MicroPythonOS. + +Provides unified access to camera devices (back-facing, front-facing, external). +Follows module-level singleton pattern (like SensorManager, AudioFlinger). + +Example usage: + import mpos.camera_manager as CameraManager + + # In board init file: + CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640", + vendor="OmniVision" + )) + + # In app: + cam_list = CameraManager.get_cameras() + if len(cam_list) > 0: + print("we have a camera!") + +MIT License +Copyright (c) 2024 MicroPythonOS contributors +""" + +try: + import _thread + _lock = _thread.allocate_lock() +except ImportError: + _lock = None + + +# Camera lens facing constants (matching Android Camera2 API) +class CameraCharacteristics: + """Camera characteristics and constants.""" + LENS_FACING_BACK = 0 # Back-facing camera (primary) + LENS_FACING_FRONT = 1 # Front-facing camera (selfie) + LENS_FACING_EXTERNAL = 2 # External USB camera + + +class Camera: + """Camera metadata (lightweight data class, Android-inspired). + + Represents a camera device with its characteristics. + """ + + def __init__(self, lens_facing, name=None, vendor=None, version=None): + """Initialize camera metadata. + + Args: + lens_facing: Camera orientation (LENS_FACING_BACK, LENS_FACING_FRONT, etc.) + name: Human-readable camera name (e.g., "OV5640", "Front Camera") + vendor: Camera vendor/manufacturer (e.g., "OmniVision") + version: Driver version (default 1) + """ + self.lens_facing = lens_facing + self.name = name or "Camera" + self.vendor = vendor or "Unknown" + self.version = version or 1 + + def __repr__(self): + facing_names = { + CameraCharacteristics.LENS_FACING_BACK: "BACK", + CameraCharacteristics.LENS_FACING_FRONT: "FRONT", + CameraCharacteristics.LENS_FACING_EXTERNAL: "EXTERNAL" + } + facing_str = facing_names.get(self.lens_facing, f"UNKNOWN({self.lens_facing})") + return f"Camera({self.name}, facing={facing_str})" + + +# Module state +_initialized = False +_cameras = [] # List of Camera objects + + +def init(): + """Initialize CameraManager. + + Returns: + bool: True if initialized successfully + """ + global _initialized + _initialized = True + return True + + +def is_available(): + """Check if CameraManager is initialized. + + Returns: + bool: True if CameraManager is initialized + """ + return _initialized + + +def add_camera(camera): + """Register a camera device. + + Args: + camera: Camera object to register + + Returns: + bool: True if camera added successfully + """ + if not isinstance(camera, Camera): + print(f"[CameraManager] Error: add_camera() requires Camera object, got {type(camera)}") + return False + + if _lock: + _lock.acquire() + + try: + # Check if camera with same facing already exists + for existing in _cameras: + if existing.lens_facing == camera.lens_facing: + print(f"[CameraManager] Warning: Camera with facing {camera.lens_facing} already registered") + # Still add it (allow multiple cameras with same facing) + + _cameras.append(camera) + print(f"[CameraManager] Registered camera: {camera}") + return True + finally: + if _lock: + _lock.release() + + +def get_cameras(): + """Get list of all registered cameras. + + Returns: + list: List of Camera objects (copy of internal list) + """ + if _lock: + _lock.acquire() + + try: + return _cameras.copy() if _cameras else [] + finally: + if _lock: + _lock.release() + + +def get_camera_by_facing(lens_facing): + """Get first camera with specified lens facing. + + Args: + lens_facing: Camera orientation (LENS_FACING_BACK, LENS_FACING_FRONT, etc.) + + Returns: + Camera object or None if not found + """ + if _lock: + _lock.acquire() + + try: + for camera in _cameras: + if camera.lens_facing == lens_facing: + return camera + return None + finally: + if _lock: + _lock.release() + + +def has_camera(): + """Check if any camera is registered. + + Returns: + bool: True if at least one camera available + """ + if _lock: + _lock.acquire() + + try: + return len(_cameras) > 0 + finally: + if _lock: + _lock.release() + + +def get_camera_count(): + """Get number of registered cameras. + + Returns: + int: Number of cameras + """ + if _lock: + _lock.acquire() + + try: + return len(_cameras) + finally: + if _lock: + _lock.release() + + +# Initialize on module load +init() diff --git a/tests/test_camera_manager.py b/tests/test_camera_manager.py new file mode 100644 index 00000000..9a34ded3 --- /dev/null +++ b/tests/test_camera_manager.py @@ -0,0 +1,298 @@ +import unittest +import sys +import os + +import mpos.camera_manager as CameraManager + +class TestCameraClass(unittest.TestCase): + """Test Camera class functionality.""" + + def test_camera_creation_with_all_params(self): + """Test creating a camera with all parameters.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640", + vendor="OmniVision", + version=2 + ) + self.assertEqual(cam.lens_facing, CameraManager.CameraCharacteristics.LENS_FACING_BACK) + self.assertEqual(cam.name, "OV5640") + self.assertEqual(cam.vendor, "OmniVision") + self.assertEqual(cam.version, 2) + + def test_camera_creation_with_defaults(self): + """Test creating a camera with default parameters.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT + ) + self.assertEqual(cam.lens_facing, CameraManager.CameraCharacteristics.LENS_FACING_FRONT) + self.assertEqual(cam.name, "Camera") + self.assertEqual(cam.vendor, "Unknown") + self.assertEqual(cam.version, 1) + + def test_camera_repr(self): + """Test Camera __repr__ method.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="TestCam" + ) + repr_str = repr(cam) + self.assertIn("TestCam", repr_str) + self.assertIn("BACK", repr_str) + + def test_camera_repr_front(self): + """Test Camera __repr__ with front-facing camera.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + repr_str = repr(cam) + self.assertIn("FrontCam", repr_str) + self.assertIn("FRONT", repr_str) + + def test_camera_repr_external(self): + """Test Camera __repr__ with external camera.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_EXTERNAL, + name="USBCam" + ) + repr_str = repr(cam) + self.assertIn("USBCam", repr_str) + self.assertIn("EXTERNAL", repr_str) + + +class TestCameraCharacteristics(unittest.TestCase): + """Test CameraCharacteristics constants.""" + + def test_lens_facing_constants(self): + """Test that lens facing constants are defined.""" + self.assertEqual(CameraManager.CameraCharacteristics.LENS_FACING_BACK, 0) + self.assertEqual(CameraManager.CameraCharacteristics.LENS_FACING_FRONT, 1) + self.assertEqual(CameraManager.CameraCharacteristics.LENS_FACING_EXTERNAL, 2) + + def test_constants_are_unique(self): + """Test that all constants are unique.""" + constants = [ + CameraManager.CameraCharacteristics.LENS_FACING_BACK, + CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + CameraManager.CameraCharacteristics.LENS_FACING_EXTERNAL + ] + self.assertEqual(len(constants), len(set(constants))) + + +class TestCameraManagerFunctionality(unittest.TestCase): + """Test CameraManager core functionality.""" + + def setUp(self): + """Clear cameras before each test.""" + # Reset the module state + CameraManager._cameras = [] + + def tearDown(self): + """Clean up after each test.""" + CameraManager._cameras = [] + + def test_is_available(self): + """Test is_available() returns True after initialization.""" + self.assertTrue(CameraManager.is_available()) + + def test_add_camera_single(self): + """Test adding a single camera.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="TestCam" + ) + result = CameraManager.add_camera(cam) + self.assertTrue(result) + self.assertEqual(CameraManager.get_camera_count(), 1) + + def test_add_camera_multiple(self): + """Test adding multiple cameras.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam" + ) + front_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + + CameraManager.add_camera(back_cam) + CameraManager.add_camera(front_cam) + + self.assertEqual(CameraManager.get_camera_count(), 2) + + def test_add_camera_invalid_type(self): + """Test adding invalid object as camera.""" + result = CameraManager.add_camera("not a camera") + self.assertFalse(result) + self.assertEqual(CameraManager.get_camera_count(), 0) + + def test_get_cameras_empty(self): + """Test getting cameras when none registered.""" + cameras = CameraManager.get_cameras() + self.assertEqual(len(cameras), 0) + self.assertIsInstance(cameras, list) + + def test_get_cameras_returns_copy(self): + """Test that get_cameras() returns a copy, not reference.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + CameraManager.add_camera(cam) + + cameras1 = CameraManager.get_cameras() + cameras2 = CameraManager.get_cameras() + + # Should be equal but not the same object + self.assertEqual(len(cameras1), len(cameras2)) + self.assertIsNot(cameras1, cameras2) + + def test_get_cameras_multiple(self): + """Test getting multiple cameras.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam" + ) + front_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + + CameraManager.add_camera(back_cam) + CameraManager.add_camera(front_cam) + + cameras = CameraManager.get_cameras() + self.assertEqual(len(cameras), 2) + names = [c.name for c in cameras] + self.assertIn("BackCam", names) + self.assertIn("FrontCam", names) + + def test_get_camera_by_facing_back(self): + """Test getting back-facing camera.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam" + ) + CameraManager.add_camera(back_cam) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + self.assertIsNotNone(found) + self.assertEqual(found.name, "BackCam") + + def test_get_camera_by_facing_front(self): + """Test getting front-facing camera.""" + front_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + CameraManager.add_camera(front_cam) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_FRONT + ) + self.assertIsNotNone(found) + self.assertEqual(found.name, "FrontCam") + + def test_get_camera_by_facing_not_found(self): + """Test getting camera that doesn't exist.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + CameraManager.add_camera(back_cam) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_FRONT + ) + self.assertIsNone(found) + + def test_get_camera_by_facing_returns_first(self): + """Test that get_camera_by_facing returns first matching camera.""" + # Add two back-facing cameras + back_cam1 = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam1" + ) + back_cam2 = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam2" + ) + + CameraManager.add_camera(back_cam1) + CameraManager.add_camera(back_cam2) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + self.assertEqual(found.name, "BackCam1") + + def test_has_camera_empty(self): + """Test has_camera() when no cameras registered.""" + self.assertFalse(CameraManager.has_camera()) + + def test_has_camera_with_cameras(self): + """Test has_camera() when cameras registered.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + CameraManager.add_camera(cam) + self.assertTrue(CameraManager.has_camera()) + + def test_get_camera_count_empty(self): + """Test get_camera_count() when no cameras.""" + self.assertEqual(CameraManager.get_camera_count(), 0) + + def test_get_camera_count_multiple(self): + """Test get_camera_count() with multiple cameras.""" + for i in range(3): + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name=f"Camera{i}" + ) + CameraManager.add_camera(cam) + + self.assertEqual(CameraManager.get_camera_count(), 3) + + +class TestCameraManagerUsagePattern(unittest.TestCase): + """Test the usage pattern from the task description.""" + + def setUp(self): + """Clear cameras before each test.""" + CameraManager._cameras = [] + + def tearDown(self): + """Clean up after each test.""" + CameraManager._cameras = [] + + def test_task_usage_pattern(self): + """Test the exact usage pattern from the task description.""" + # Register a camera (as done in board init) + CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + )) + + # App usage pattern + cam_list = CameraManager.get_cameras() + + if len(cam_list) > 0: + has_camera = True + else: + has_camera = False + + self.assertTrue(has_camera) + + def test_task_usage_pattern_no_camera(self): + """Test usage pattern when no camera available.""" + cam_list = CameraManager.get_cameras() + + if len(cam_list) > 0: + has_camera = True + else: + has_camera = False + + self.assertFalse(has_camera) + +