Skip to content

Commit b54bc22

Browse files
committed
Replace rpicam-still/rpicam-vid with picamera2 daemon, add WebSocket live stream
Single-process picamera2 daemon owns the camera and serves frames via shared memory + Unix socket IPC. Three camera backends: picamera2 (preferred), python3-libcamera (raw bindings), libcamera-still (subprocess fallback). Camera backends (camera_backend.py): - Picamera2Backend, LibcameraBackend, LibcameraStillBackend with auto-detection - LibcameraBackend: control lookup via getattr(libcamera.controls, name) - LibcameraStillBackend: capture lock, timeout wrapper, stale process cleanup, sticky controls, fixed temp filenames, --awb off support - requested_exposure/gain injected into metadata from pending controls Daemon (picamera2_daemon.py): - Backend-agnostic grab loop, set_backend hot-switch via IPC - Watchdog thread: auto-restarts backend if grab loop stalls for 120s - _last_applied tracks controls across frames for OSD synchronization - _capture_time stamped for frame staleness detection Stream and display: - WebSocket preferred (works with gthread via flask-sock/simple-websocket) - /api/stream/frame.jpg: single-frame polling with requestAnimationFrame pacing - /api/stream/metadata: reads directly from daemon socket (no stale cache) - allsky_player.js: _pollInFlight guard, frame_age warning display - OSD shows requested exposure/gain rounded to 2 decimals Dashboard camera controls (capture_buttons.js): - Driver selector (picamera2/libcamera/libcamera-still) - Auto-exposure toggle (TARGET_ADU_DISABLE) with config reload trigger - Exposure slider loads from config DB (CCD_EXPOSURE_MAX), not live metadata - Gain syncs from live metadata, exposure stays at user-set value - Config saves via UPDATE (upsert), not INSERT, to prevent row proliferation Config and capture: - LIBCAMERA.BACKEND field, TARGET_ADU_DISABLE flag - image.py: skip calculate_exposure when TARGET_ADU_DISABLE is set - Slider updates CCD_EXPOSURE_MAX + all mode gains simultaneously - Config level preserved on save, capture worker reload queued on changes - PYTHONDONTWRITEBYTECODE=1 in systemd services
1 parent a01df02 commit b54bc22

22 files changed

Lines changed: 4770 additions & 353 deletions

indi_allsky/camera/camera_backend.py

Lines changed: 672 additions & 0 deletions
Large diffs are not rendered by default.

indi_allsky/camera/libcamera.py

Lines changed: 196 additions & 341 deletions
Large diffs are not rendered by default.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Standalone picamera2 daemon client.
2+
3+
Zero dependencies on indi_allsky — safe to import from any context
4+
(daemon, capture worker, Flask, standalone test scripts).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import socket
11+
import struct
12+
from multiprocessing import shared_memory
13+
from typing import Any, Optional
14+
15+
SOCK_PATH = "/run/indi-allsky/picamera2.sock"
16+
SHM_NAME = "indi_allsky_frame"
17+
SHM_HEADER = 24
18+
SHM_PATH_SIZE = 512
19+
# JPEG max and path offset are derived from actual shm size at runtime
20+
21+
22+
class Picamera2Client:
23+
"""Connect to the picamera2 daemon via Unix socket + shared memory."""
24+
25+
def __init__(self, sock_path: str = SOCK_PATH) -> None:
26+
self._sock_path = sock_path
27+
self._sock: Optional[socket.socket] = None
28+
self._shm: Optional[shared_memory.SharedMemory] = None
29+
30+
def connect(self) -> None:
31+
if self._sock is not None:
32+
return
33+
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
34+
self._sock.connect(self._sock_path)
35+
self._sock.settimeout(120.0)
36+
37+
def close(self) -> None:
38+
if self._sock is not None:
39+
try:
40+
self._sock.close()
41+
except Exception:
42+
pass
43+
self._sock = None
44+
if self._shm is not None:
45+
try:
46+
self._shm.close()
47+
except Exception:
48+
pass
49+
self._shm = None
50+
51+
def _send(self, cmd: dict) -> dict:
52+
self.connect()
53+
msg = json.dumps(cmd).encode() + b"\n"
54+
self._sock.sendall(msg)
55+
buf = b""
56+
while b"\n" not in buf:
57+
data = self._sock.recv(4096)
58+
if not data:
59+
raise ConnectionError("Daemon closed connection")
60+
buf += data
61+
line = buf.split(b"\n", 1)[0]
62+
return json.loads(line)
63+
64+
def ping(self) -> dict:
65+
return self._send({"cmd": "ping"})
66+
67+
def get_sensor_info(self) -> dict:
68+
return self._send({"cmd": "get_sensor_info"})
69+
70+
def get_metadata(self) -> dict:
71+
return self._send({"cmd": "get_metadata"})
72+
73+
def get_modes(self) -> dict:
74+
return self._send({"cmd": "get_modes"})
75+
76+
def get_backend(self) -> dict:
77+
return self._send({"cmd": "get_backend"})
78+
79+
def set_controls(self, **kwargs) -> dict:
80+
return self._send({"cmd": "set_controls", **kwargs})
81+
82+
def capture_still(self, exposure: float = None, gain: float = None,
83+
timeout: float = 120) -> dict:
84+
cmd: dict[str, Any] = {"cmd": "capture_still", "timeout": timeout}
85+
if exposure is not None:
86+
cmd["exposure"] = exposure
87+
if gain is not None:
88+
cmd["gain"] = gain
89+
return self._send(cmd)
90+
91+
def capture_dng(self, path: str, timeout: float = 120) -> dict:
92+
return self._send({"cmd": "capture_dng", "path": path, "timeout": timeout})
93+
94+
def set_binning(self, level: int, width: int, height: int) -> dict:
95+
return self._send({"cmd": "set_binning", "level": level,
96+
"width": width, "height": height})
97+
98+
def set_stream(self, width: int = None, height: int = None,
99+
quality: int = None, osd: bool = None) -> dict:
100+
cmd: dict[str, Any] = {"cmd": "set_stream"}
101+
if width is not None:
102+
cmd["width"] = width
103+
if height is not None:
104+
cmd["height"] = height
105+
if quality is not None:
106+
cmd["quality"] = quality
107+
if osd is not None:
108+
cmd["osd"] = osd
109+
return self._send(cmd)
110+
111+
# ------------------------------------------------------------------
112+
# Shared memory frame reader
113+
# ------------------------------------------------------------------
114+
115+
def _open_shm(self) -> None:
116+
if self._shm is None:
117+
self._shm = shared_memory.SharedMemory(name=SHM_NAME)
118+
# Prevent Python's resource tracker from unlinking shm we don't own.
119+
# The daemon creates and owns the shm; clients are read-only.
120+
try:
121+
from multiprocessing import resource_tracker
122+
resource_tracker.unregister(
123+
"/" + SHM_NAME, "shared_memory",
124+
)
125+
except Exception:
126+
pass
127+
# Derive JPEG max and path offset from actual shm size
128+
self._shm_jpeg_max = self._shm.size - SHM_HEADER - SHM_PATH_SIZE
129+
self._shm_path_offset = SHM_HEADER + self._shm_jpeg_max
130+
131+
def get_stream_jpeg(self) -> Optional[tuple[bytes, int]]:
132+
"""Read the latest JPEG frame from shared memory.
133+
134+
Returns (jpeg_bytes, sequence_counter) or None.
135+
"""
136+
try:
137+
self._open_shm()
138+
except FileNotFoundError:
139+
return None
140+
141+
buf = self._shm.buf
142+
seq, w, h, ch, jpeg_len = struct.unpack_from("<QIIiI", buf, 0)
143+
if jpeg_len <= 0 or jpeg_len > self._shm_jpeg_max:
144+
return None
145+
jpeg = bytes(buf[SHM_HEADER:SHM_HEADER + jpeg_len])
146+
return jpeg, seq
147+
148+
def get_frame_path(self) -> Optional[str]:
149+
"""Read the latest full-res frame path from shared memory."""
150+
try:
151+
self._open_shm()
152+
except FileNotFoundError:
153+
return None
154+
155+
buf = self._shm.buf
156+
raw = bytes(buf[self._shm_path_offset:self._shm_path_offset + SHM_PATH_SIZE])
157+
path = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
158+
return path if path else None

0 commit comments

Comments
 (0)