diff --git a/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py b/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py index e418bba..855a2f4 100644 --- a/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py +++ b/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py @@ -25,7 +25,8 @@ class GPhoto2Backend(CameraBackend): _streaming_process: subprocess.Popen | None = None # Track active streaming sessions per camera port - _active_streams: dict[str, str] = {} # port -> udp_port + # port -> {"udp_port": str, "launch_port": str} + _active_streams: dict[str, dict[str, str]] = {} def get_backend_type(self) -> BackendType: return BackendType.GPHOTO2 @@ -85,7 +86,7 @@ def _release_usb_device(port: str) -> None: except (ProcessLookupError, FileNotFoundError, PermissionError): pass if pids: - time.sleep(1) + time.sleep(3) except Exception: pass @@ -241,6 +242,11 @@ def _refresh_port(cls, camera: CameraInfo) -> str: if name and name in camera.name: if port != old_port: print(f"[DEBUG] Port changed: {old_port} -> {port}") + # Update _active_streams key if camera was streaming + if old_port in cls._active_streams: + stream_info = cls._active_streams.pop(old_port) + cls._active_streams[port] = stream_info + print(f"[DEBUG] Updated _active_streams: {old_port} -> {port}") camera.extra["port"] = port camera.device_path = port camera.id = f"gphoto2:{port}" @@ -353,9 +359,21 @@ def _refresh_port(cls, camera: CameraInfo) -> str: def get_controls(self, camera: CameraInfo) -> list[CameraControl]: controls: list[CameraControl] = [] - port = camera.extra.get("port", camera.device_path) + + # Refresh USB port first (device number changes after GVFS kill) + port = self._refresh_port(camera) print(f"[DEBUG] get_controls: port={port}") + # Check if the USB device actually exists + try: + bus, dev = port.replace("usb:", "").split(",") + usb_path = f"/dev/bus/usb/{bus}/{dev}" + if not os.path.exists(usb_path): + print(f"[DEBUG] get_controls: {usb_path} does not exist, camera disconnected?") + return controls + except (ValueError, OSError): + pass + # Ensure GVFS is dead and USB device is free self._kill_gvfs() self._release_usb_device(port) @@ -630,10 +648,10 @@ def start_streaming(self, camera: CameraInfo) -> bool: if line.startswith("SUCCESS:"): dev = line.split("SUCCESS:")[1].strip() log.info("GPhoto2 streaming started on %s", dev) - self._active_streams[port] = udp_port + self._active_streams[port] = {"udp_port": udp_port, "launch_port": port} return True log.info("GPhoto2 script exited 0 (no explicit SUCCESS)") - self._active_streams[port] = udp_port + self._active_streams[port] = {"udp_port": udp_port, "launch_port": port} return True log.error("GPhoto2 script failed (code %d): %s", @@ -652,13 +670,21 @@ def stop_streaming(self, camera: CameraInfo | None = None) -> None: if camera: port = camera.extra.get("port", camera.device_path) udp_port = str(camera.extra.get("udp_port", 5000)) - self._active_streams.pop(port, None) + stream_info = self._active_streams.pop(port, None) + # Use the port the process was actually launched with + launch_port = stream_info["launch_port"] if stream_info else port # Graceful SIGTERM first — gives gphoto2 time to close PTP session subprocess.run( - ["pkill", "-f", f"gphoto2.*--port {port}"], + ["pkill", "-f", f"gphoto2.*--port {launch_port}"], capture_output=True, ) + # Also try current port if different + if launch_port != port: + subprocess.run( + ["pkill", "-f", f"gphoto2.*--port {port}"], + capture_output=True, + ) subprocess.run( ["pkill", "-f", f"ffmpeg.*udp://127.0.0.1:{udp_port}"], capture_output=True, @@ -666,9 +692,14 @@ def stop_streaming(self, camera: CameraInfo | None = None) -> None: time.sleep(2) # Force-kill any survivors subprocess.run( - ["pkill", "-9", "-f", f"gphoto2.*--port {port}"], + ["pkill", "-9", "-f", f"gphoto2.*--port {launch_port}"], capture_output=True, ) + if launch_port != port: + subprocess.run( + ["pkill", "-9", "-f", f"gphoto2.*--port {port}"], + capture_output=True, + ) subprocess.run( ["pkill", "-9", "-f", f"ffmpeg.*udp://127.0.0.1:{udp_port}"], capture_output=True, @@ -701,13 +732,22 @@ def is_camera_streaming(self, camera: CameraInfo) -> bool: port = camera.extra.get("port", camera.device_path) if port not in self._active_streams: return False - # Verify the process is actually alive - udp_port = self._active_streams[port] + # Verify the process is actually alive using the launch port + stream_info = self._active_streams[port] + launch_port = stream_info.get("launch_port", port) result = subprocess.run( - ["pgrep", "-f", f"gphoto2.*--port {port}"], + ["pgrep", "-f", f"gphoto2.*--port {launch_port}"], capture_output=True, ) if result.returncode != 0: + # Also try current port (in case it matches) + if launch_port != port: + result = subprocess.run( + ["pgrep", "-f", f"gphoto2.*--port {port}"], + capture_output=True, + ) + if result.returncode == 0: + return True # Process died — clean up self._active_streams.pop(port, None) return False diff --git a/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh b/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh index d24e123..a20fc43 100755 --- a/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh +++ b/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh @@ -79,18 +79,12 @@ if ! timeout 10 gphoto2 --auto-detect 2>&1 | grep -q "$USB_PORT"; then # Try to find camera by name at a different port NEW_PORT=$(timeout 10 gphoto2 --auto-detect 2>/dev/null | grep -i "$CAM_NAME" | grep -oP 'usb:\S+' | head -1) if [ -n "$NEW_PORT" ]; then - echo "INFO: Camera found at new port: $NEW_PORT" + echo "INFO: Camera '$CAM_NAME' found at new port: $NEW_PORT" USB_PORT="$NEW_PORT" else - # Last resort: just pick any Canon/DSLR camera - NEW_PORT=$(timeout 10 gphoto2 --auto-detect 2>/dev/null | grep -v '^Model\|^---' | grep 'usb:' | grep -oP 'usb:\S+' | head -1) - if [ -n "$NEW_PORT" ]; then - echo "INFO: Using first available camera at port: $NEW_PORT" - USB_PORT="$NEW_PORT" - else - echo "ERROR: No camera detected at any port." - exit 1 - fi + # Do NOT pick a random camera — that would stream the wrong one + echo "ERROR: Camera '$CAM_NAME' not detected at any port." + exit 1 fi fi diff --git a/usr/share/biglinux/bigcam/ui/window.py b/usr/share/biglinux/bigcam/ui/window.py index 0fd138a..98d14d6 100644 --- a/usr/share/biglinux/bigcam/ui/window.py +++ b/usr/share/biglinux/bigcam/ui/window.py @@ -278,6 +278,10 @@ def _on_camera_selected(self, _selector: CameraSelector, camera: CameraInfo) -> and backend.is_camera_streaming(camera)) cached_controls = self._controls_cache.get(camera.id) + print(f"[DEBUG] already_streaming={already_streaming}, cached_controls={cached_controls is not None}, camera.id={camera.id}") + if hasattr(backend, "_active_streams"): + print(f"[DEBUG] _active_streams={dict(backend._active_streams)}") + if already_streaming and cached_controls is not None: # Hot-swap: camera already streaming, just switch the GStreamer pipeline print(f"[DEBUG] Hot-swap to {camera.name} (already streaming)") @@ -340,9 +344,21 @@ def on_done(result: tuple[bool, list]) -> None: run_async(do_controls_then_stream, on_success=on_done) else: # V4L2, libcamera, PipeWire: load controls async + start stream + # Stop only GStreamer pipeline on UI thread (instant) + old_camera = self._stream_engine._current_camera + old_backend_obj = None + if old_camera and old_camera.backend != camera.backend: + old_backend_obj = self._camera_manager.get_backend(old_camera.backend) + self._stream_engine.stop(stop_backend=False) + + # Start the V4L2 camera immediately self._controls_page.set_camera(camera) self._stream_engine.play(camera) + # Stop old backend (gphoto2) in background — has time.sleep() calls + if old_backend_obj and hasattr(old_backend_obj, "stop_streaming"): + run_async(lambda: old_backend_obj.stop_streaming(old_camera)) + def _on_retry(self, _preview: PreviewArea) -> None: """Re-attempt camera connection when user clicks Try Again.""" if self._active_camera: