// 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" "io" "log" "net/http" "os" "os/signal" "sync" "syscall" "time" "github.com/antitbone/dwarfctl/internal/api" "github.com/antitbone/dwarfctl/internal/odometry" "github.com/antitbone/dwarfctl/internal/rtspgrab" ) // 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 SettleDelay time.Duration // pause after motor stop before resuming 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 // paused disables odometry frame capture (e.g. during motor slew to avoid // motion blur which destroys phase correlation). paused bool } // 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 } if cfg.SettleDelay <= 0 { cfg.SettleDelay = 3 * 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) // Switch to shooting mode 1 (photo/preview) first — required for streaming. s.scope.SwitchShootingMode(1) if err := s.scope.OpenCamera(s.camera); err != nil { return fmt.Errorf("open camera: %w", err) } s.camOpen = true log.Println("Camera opened. Waiting 5s for RTSP to initialize...") time.Sleep(5 * time.Second) // 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 := rtspgrab.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: // Skip frame capture while motors are moving (motion blur destroys // phase correlation). The slew handler sets paused=true/false. s.mu.Lock() if s.paused { s.mu.Unlock() continue } s.mu.Unlock() img, err := rtspgrab.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" } // --- 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("/pause", s.handlePause) mux.HandleFunc("/resume", s.handleResume) 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 } if _, err := rtspgrab.GrabFrame(url, out); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } 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) } // Pause odometry during slew to avoid motion blur. s.mu.Lock() s.paused = true s.mu.Unlock() 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, "odometry": "paused"}) } func (s *Server) handleStop(w http.ResponseWriter, _ *http.Request) { s.scope.MotorJoystickStop() // Keep odometry paused — a settle delay prevents motion-blurred frames // from corrupting the tracker. The odometry loop auto-resumes after the // delay via a timer. s.mu.Lock() s.paused = true s.mu.Unlock() go func() { time.Sleep(s.cfg.SettleDelay) s.mu.Lock() s.paused = false s.mu.Unlock() }() writeJSON(w, map[string]string{"status": "motors stopped, settling..."}) } func (s *Server) handlePause(w http.ResponseWriter, _ *http.Request) { s.mu.Lock() s.paused = true s.mu.Unlock() writeJSON(w, map[string]string{"status": "odometry paused"}) } func (s *Server) handleResume(w http.ResponseWriter, _ *http.Request) { s.mu.Lock() s.paused = false s.mu.Unlock() writeJSON(w, map[string]string{"status": "odometry resumed"}) } 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}) }