Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -652,23 +670,36 @@ 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,
)
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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 4 additions & 10 deletions usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions usr/share/biglinux/bigcam/ui/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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:
Expand Down