Files
dwarf-go/dwarfctl/internal/daemon/server.go
Jacquin Antoine d319ef6b4a Improve odometry with rotation estimation, daemon stability, and RTSP fixes
- Add rotation estimation via log-polar phase correlation (EstimateRotation,
  rotateGray) — de-rotate before translation to avoid aliasing
- Track cumulative field rotation in Tracker, reject low-confidence frames
- Extract RTSP grabber into internal/rtspgrab module
- Daemon: pause odometry during slew (motion blur), auto-resume after settle
  delay, add /pause and /resume HTTP endpoints, switch shooting mode before
  opening camera, use ReqOpenCamera with rtsp_encode_type=1
- WebSocket heartbeat (ping/pong every 10s) to prevent idle disconnects
- FFmpeg low-latency flags (-fflags nobuffer, -probesize, -analyzeduration)
- Add SendRaw API for custom payloads, increase daemon HTTP timeout to 60s

💘 Generated with Crush

Assisted-by: Crush:/models/Qwen3.6-27B-uncensored-heretic-v2-Native-MTP-Preserved-Q4_K_M.gguf
2026-07-13 23:00:30 +02:00

379 lines
9.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"
"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})
}