Skip to content

Commit 502d6c6

Browse files
committed
Replace rpicam-still/rpicam-vid with picamera2 daemon, add WebSocket live stream
Camera daemon (single-process, eliminates capture vs stream contention): - picamera2_daemon.py: standalone daemon owning Picamera2 instance - Continuous grab loop in daemon thread - Shared memory for JPEG stream (dynamically sized from stream resolution) - Unix socket IPC for controls, capture, metadata, sensor info - OSD overlay via original indi-allsky overlay modules (orb, cardinals, moon texture, Pillow text with # color/xy/anchor/size directives) - Stream resolution/quality/OSD state persisted to config DB - picamera2_client.py: zero-dependency client for capture worker and Flask - Resource tracker unregister to prevent shm deletion on client exit - libcamera.py: setCcdExposure uses daemon client instead of rpicam-still - Async capture via background thread, sync via blocking wait - Metadata from daemon instead of JSON file parsing - findCcd queries daemon for live sensor info - No colorspace conversions (BGR throughout) - stream_overlay.py: imports real overlay modules (orb, cardinals, moon), translates config from DB, renders with Pillow (TrueType fonts, Unicode) WebSocket live stream: - Flask WebSocket endpoint reads directly from daemon shared memory - Binary frame protocol: [4B JSON len][metadata JSON][JPEG data] - Metadata: exposure, gain, lux, colour_temp, sensor_temp, WB gains - Sends latest frame immediately on connect (no stall during long exposures) - Metadata RPC cached at 1s intervals (not per frame) - AllskyPlayer JS: canvas-based WS player with MJPEG fallback, OSD bar - Camera controls: unified sliders (gain, exposure, brightness, contrast, target ADU, JPEG quality), resolution selector, OSD toggle - All controls send to daemon via stream update API Flask/gunicorn: - wsgi.py: root redirect middleware for direct SSL without reverse proxy - captureapi_views.py: MJPEGStreamManager reads from daemon shared memory, _stop_allsky/_start_allsky removed (no camera contention), SensorInfoMethodView queries daemon, stream update sends to daemon - flask-sock added to requirements for WebSocket support Infrastructure: - picamera2-daemon.service: systemd unit (starts before indi-allsky) - activate-indi-allsky.sh / activate-allsky-ng.sh: switching scripts - Runs on HTTPS/443 via gunicorn SSL — no Apache/nginx needed
1 parent b611971 commit 502d6c6

15 files changed

Lines changed: 3501 additions & 350 deletions

indi_allsky/camera/libcamera.py

Lines changed: 196 additions & 341 deletions
Large diffs are not rendered by default.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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 set_controls(self, **kwargs) -> dict:
77+
return self._send({"cmd": "set_controls", **kwargs})
78+
79+
def capture_still(self, exposure: float = None, gain: float = None,
80+
timeout: float = 120) -> dict:
81+
cmd: dict[str, Any] = {"cmd": "capture_still", "timeout": timeout}
82+
if exposure is not None:
83+
cmd["exposure"] = exposure
84+
if gain is not None:
85+
cmd["gain"] = gain
86+
return self._send(cmd)
87+
88+
def capture_dng(self, path: str, timeout: float = 120) -> dict:
89+
return self._send({"cmd": "capture_dng", "path": path, "timeout": timeout})
90+
91+
def set_binning(self, level: int, width: int, height: int) -> dict:
92+
return self._send({"cmd": "set_binning", "level": level,
93+
"width": width, "height": height})
94+
95+
def set_stream(self, width: int = None, height: int = None,
96+
quality: int = None, osd: bool = None) -> dict:
97+
cmd: dict[str, Any] = {"cmd": "set_stream"}
98+
if width is not None:
99+
cmd["width"] = width
100+
if height is not None:
101+
cmd["height"] = height
102+
if quality is not None:
103+
cmd["quality"] = quality
104+
if osd is not None:
105+
cmd["osd"] = osd
106+
return self._send(cmd)
107+
108+
# ------------------------------------------------------------------
109+
# Shared memory frame reader
110+
# ------------------------------------------------------------------
111+
112+
def _open_shm(self) -> None:
113+
if self._shm is None:
114+
self._shm = shared_memory.SharedMemory(name=SHM_NAME)
115+
# Prevent Python's resource tracker from unlinking shm we don't own.
116+
# The daemon creates and owns the shm; clients are read-only.
117+
try:
118+
from multiprocessing import resource_tracker
119+
resource_tracker.unregister(
120+
"/" + SHM_NAME, "shared_memory",
121+
)
122+
except Exception:
123+
pass
124+
# Derive JPEG max and path offset from actual shm size
125+
self._shm_jpeg_max = self._shm.size - SHM_HEADER - SHM_PATH_SIZE
126+
self._shm_path_offset = SHM_HEADER + self._shm_jpeg_max
127+
128+
def get_stream_jpeg(self) -> Optional[tuple[bytes, int]]:
129+
"""Read the latest JPEG frame from shared memory.
130+
131+
Returns (jpeg_bytes, sequence_counter) or None.
132+
"""
133+
try:
134+
self._open_shm()
135+
except FileNotFoundError:
136+
return None
137+
138+
buf = self._shm.buf
139+
seq, w, h, ch, jpeg_len = struct.unpack_from("<QIIiI", buf, 0)
140+
if jpeg_len <= 0 or jpeg_len > self._shm_jpeg_max:
141+
return None
142+
jpeg = bytes(buf[SHM_HEADER:SHM_HEADER + jpeg_len])
143+
return jpeg, seq
144+
145+
def get_frame_path(self) -> Optional[str]:
146+
"""Read the latest full-res frame path from shared memory."""
147+
try:
148+
self._open_shm()
149+
except FileNotFoundError:
150+
return None
151+
152+
buf = self._shm.buf
153+
raw = bytes(buf[self._shm_path_offset:self._shm_path_offset + SHM_PATH_SIZE])
154+
path = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
155+
return path if path else None

0 commit comments

Comments
 (0)