Comprehensive documentation for accessing the DWARF camera streams:
- RTSP URLs and channels (ch0=tele, ch1=wide)
- The critical prerequisite: camera must be opened via WebSocket first
- ffmpeg commands for frame capture, timelapse, and video recording
- mpv and VLC usage with TCP transport
- Python/OpenCV integration example
- Troubleshooting common issues (black image, connection refused, VLC delay)
- Comparison of RTSP vs MJPEG modes across device models
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
347 lines
8.4 KiB
Go
347 lines
8.4 KiB
Go
// Package daemon implements a persistent telescope service that keeps the
|
|
// WebSocket connection open, manages cameras, grabs RTSP frames, and runs the
|
|
// visual odometry tracker — exposing everything via a local HTTP API so that
|
|
// short-lived CLI invocations can control a long-lived session.
|
|
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
_ "image/jpeg"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/antitbone/dwarfctl/internal/api"
|
|
"github.com/antitbone/dwarfctl/internal/odometry"
|
|
)
|
|
|
|
// DefaultAddr is the default HTTP listen address for the daemon.
|
|
const DefaultAddr = "127.0.0.1:7777"
|
|
|
|
// Config configures the daemon.
|
|
type Config struct {
|
|
IP string
|
|
Addr string
|
|
FoVH float64
|
|
FoVV float64
|
|
GridSize int
|
|
Camera string // "wide" or "tele"
|
|
Interval time.Duration // odometry frame interval
|
|
Debug bool
|
|
}
|
|
|
|
// Server is the persistent telescope daemon.
|
|
type Server struct {
|
|
cfg Config
|
|
scope *api.Telescope
|
|
tracker *odometry.Tracker
|
|
|
|
mu sync.Mutex
|
|
camera api.Camera
|
|
camOpen bool
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// New creates a daemon server.
|
|
func New(cfg Config) *Server {
|
|
if cfg.Addr == "" {
|
|
cfg.Addr = DefaultAddr
|
|
}
|
|
if cfg.FoVH <= 0 {
|
|
cfg.FoVH = odometry.DefaultFoVH
|
|
}
|
|
if cfg.FoVV <= 0 {
|
|
cfg.FoVV = odometry.DefaultFoVV
|
|
}
|
|
if cfg.GridSize <= 0 {
|
|
cfg.GridSize = odometry.DefaultSize
|
|
}
|
|
if cfg.Camera == "" {
|
|
cfg.Camera = "wide"
|
|
}
|
|
if cfg.Interval <= 0 {
|
|
cfg.Interval = 5 * time.Second
|
|
}
|
|
return &Server{
|
|
cfg: cfg,
|
|
stopCh: make(chan struct{}),
|
|
tracker: odometry.NewTracker(cfg.FoVH, cfg.FoVV, cfg.GridSize),
|
|
}
|
|
}
|
|
|
|
// Run starts the daemon: connects to telescope, opens camera, starts odometry,
|
|
// and serves HTTP until SIGTERM/SIGINT is received (systemd-compatible).
|
|
func (s *Server) Run() error {
|
|
// 1. Connect to telescope.
|
|
s.scope = api.New("dwarfctl-daemon")
|
|
s.scope.SetDebug(s.cfg.Debug)
|
|
log.Printf("Connecting to telescope at %s ...", s.cfg.IP)
|
|
if err := s.scope.Connect(s.cfg.IP); err != nil {
|
|
return fmt.Errorf("connect: %w", err)
|
|
}
|
|
log.Println("Connected.")
|
|
|
|
// 2. Open camera.
|
|
s.camera = api.CameraTele
|
|
if s.cfg.Camera == "wide" {
|
|
s.camera = api.CameraWide
|
|
}
|
|
log.Printf("Opening %s camera...", s.cfg.Camera)
|
|
if err := s.scope.OpenCamera(s.camera); err != nil {
|
|
return fmt.Errorf("open camera: %w", err)
|
|
}
|
|
s.camOpen = true
|
|
log.Println("Camera opened.")
|
|
|
|
// 3. Start odometry loop in background.
|
|
s.wg.Add(1)
|
|
go s.odometryLoop()
|
|
|
|
// 4. HTTP server.
|
|
mux := http.NewServeMux()
|
|
s.registerRoutes(mux)
|
|
srv := &http.Server{Addr: s.cfg.Addr, Handler: mux}
|
|
|
|
// 5. Signal handling for systemd (SIGTERM/SIGINT → clean shutdown).
|
|
go func() {
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
|
sig := <-sigCh
|
|
log.Printf("Received %s — shutting down...", sig)
|
|
close(s.stopCh)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
srv.Shutdown(ctx)
|
|
}()
|
|
|
|
log.Printf("Daemon listening on http://%s (Ctrl-C to stop)", s.cfg.Addr)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
return err
|
|
}
|
|
|
|
// Cleanup.
|
|
s.scope.CloseCamera(s.camera)
|
|
s.scope.Close()
|
|
s.wg.Wait()
|
|
log.Println("Daemon stopped.")
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the daemon.
|
|
func (s *Server) Stop() {
|
|
close(s.stopCh)
|
|
if s.scope != nil {
|
|
s.scope.CloseCamera(s.camera)
|
|
s.scope.Close()
|
|
}
|
|
}
|
|
|
|
// odometryLoop continuously grabs RTSP frames and feeds the tracker.
|
|
func (s *Server) odometryLoop() {
|
|
defer s.wg.Done()
|
|
ch := s.rtspChannel()
|
|
url := fmt.Sprintf("rtsp://%s:554/%s/stream0", s.cfg.IP, ch)
|
|
tmpFile := "/tmp/dwarf_daemon_frame.jpg"
|
|
|
|
ticker := time.NewTicker(s.cfg.Interval)
|
|
defer ticker.Stop()
|
|
|
|
// Prime with first frame.
|
|
if img, err := grabFrame(url, tmpFile); err == nil {
|
|
s.tracker.Update(img)
|
|
log.Printf("Odometry primed (grid=%d, fov=%.1fx%.1f)", s.cfg.GridSize, s.cfg.FoVH, s.cfg.FoVV)
|
|
} else {
|
|
log.Printf("Odometry prime failed: %v", err)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-s.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
img, err := grabFrame(url, tmpFile)
|
|
if err != nil {
|
|
if s.cfg.Debug {
|
|
log.Printf("grab error: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
o, err := s.tracker.Update(img)
|
|
if err != nil {
|
|
log.Printf("odometry error: %v", err)
|
|
continue
|
|
}
|
|
if s.cfg.Debug {
|
|
log.Printf("[odometry] %s", o)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) rtspChannel() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.camera == api.CameraWide {
|
|
return "ch1"
|
|
}
|
|
return "ch0"
|
|
}
|
|
|
|
// grabFrame uses ffmpeg to grab a single RTSP frame, then decodes it.
|
|
func grabFrame(url, tmpFile string) (image.Image, error) {
|
|
cmd := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
|
|
"-i", url, "-frames:v", "1", "-q:v", "2", "-y", tmpFile)
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg: %w", err)
|
|
}
|
|
f, err := os.Open(tmpFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
img, _, err := image.Decode(f)
|
|
return img, err
|
|
}
|
|
|
|
// --- HTTP API ---
|
|
|
|
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
mux.HandleFunc("/orientation", s.handleOrientation)
|
|
mux.HandleFunc("/grab", s.handleGrab)
|
|
mux.HandleFunc("/reset", s.handleReset)
|
|
mux.HandleFunc("/camera", s.handleCamera)
|
|
mux.HandleFunc("/slew", s.handleSlew)
|
|
mux.HandleFunc("/stop", s.handleStop)
|
|
mux.HandleFunc("/fov", s.handleFoV)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"status": "ok",
|
|
"ip": s.cfg.IP,
|
|
"camera": s.cfg.Camera,
|
|
"cam_open": s.camOpen,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleOrientation(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, s.tracker.Orientation())
|
|
}
|
|
|
|
func (s *Server) handleGrab(w http.ResponseWriter, r *http.Request) {
|
|
ch := s.rtspChannel()
|
|
url := fmt.Sprintf("rtsp://%s:554/%s/stream0", s.cfg.IP, ch)
|
|
out := "/tmp/dwarf_daemon_grab.jpg"
|
|
if q := r.URL.Query().Get("path"); q != "" {
|
|
out = q
|
|
}
|
|
cmd := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
|
|
"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out)
|
|
if err := cmd.Run(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Return the image bytes or just the path.
|
|
if r.URL.Query().Get("raw") == "1" {
|
|
data, _ := os.ReadFile(out)
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Write(data)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"path": out})
|
|
}
|
|
|
|
func (s *Server) handleReset(w http.ResponseWriter, _ *http.Request) {
|
|
s.tracker.Reset()
|
|
writeJSON(w, map[string]string{"status": "reset"})
|
|
}
|
|
|
|
func (s *Server) handleCamera(w http.ResponseWriter, r *http.Request) {
|
|
action := r.URL.Query().Get("action") // open/close
|
|
cam := r.URL.Query().Get("cam") // wide/tele
|
|
|
|
s.mu.Lock()
|
|
target := s.camera
|
|
if cam == "wide" {
|
|
target = api.CameraWide
|
|
} else if cam == "tele" {
|
|
target = api.CameraTele
|
|
}
|
|
|
|
var err error
|
|
switch action {
|
|
case "open":
|
|
err = s.scope.OpenCamera(target)
|
|
if err == nil {
|
|
s.camera = target
|
|
s.camOpen = true
|
|
}
|
|
case "close":
|
|
err = s.scope.CloseCamera(target)
|
|
if err == nil {
|
|
s.camOpen = false
|
|
}
|
|
default:
|
|
err = fmt.Errorf("action must be 'open' or 'close'")
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"camera": cam, "action": action, "ok": true})
|
|
}
|
|
|
|
func (s *Server) handleSlew(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Angle float64 `json:"angle"`
|
|
Length float64 `json:"length"`
|
|
}
|
|
if r.Body != nil {
|
|
body, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(body, &req)
|
|
}
|
|
if err := s.scope.SlewJoystick(req.Angle, req.Length); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"angle": req.Angle, "length": req.Length})
|
|
}
|
|
|
|
func (s *Server) handleStop(w http.ResponseWriter, _ *http.Request) {
|
|
s.scope.MotorJoystickStop()
|
|
writeJSON(w, map[string]string{"status": "motors stopped"})
|
|
}
|
|
|
|
func (s *Server) handleFoV(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
H float64 `json:"h"`
|
|
V float64 `json:"v"`
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(body, &req)
|
|
s.tracker.SetFoV(req.H, req.V)
|
|
s.cfg.FoVH = req.H
|
|
s.cfg.FoVV = req.V
|
|
}
|
|
writeJSON(w, map[string]float64{"fov_h": s.cfg.FoVH, "fov_v": s.cfg.FoVV})
|
|
}
|