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
This commit is contained in:
Jacquin Antoine
2026-07-13 23:00:30 +02:00
parent 8daa9b4d58
commit d319ef6b4a
12 changed files with 522 additions and 143 deletions

View File

@ -24,7 +24,7 @@ func NewClient(addr string) *Client {
}
return &Client{
baseURL: "http://" + addr,
http: &http.Client{Timeout: 30 * time.Second},
http: &http.Client{Timeout: 60 * time.Second},
}
}

View File

@ -8,13 +8,10 @@ import (
"context"
"encoding/json"
"fmt"
"image"
_ "image/jpeg"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"sync"
"syscall"
@ -22,6 +19,7 @@ import (
"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.
@ -29,14 +27,15 @@ const DefaultAddr = "127.0.0.1:7777"
// Config configures the daemon.
type Config struct {
IP string
Addr string
FoVH float64
FoVV float64
IP string
Addr string
FoVH float64
FoVV float64
GridSize int
Camera string // "wide" or "tele"
Camera string // "wide" or "tele"
Interval time.Duration // odometry frame interval
Debug bool
SettleDelay time.Duration // pause after motor stop before resuming
Debug bool
}
// Server is the persistent telescope daemon.
@ -45,11 +44,15 @@ type Server struct {
scope *api.Telescope
tracker *odometry.Tracker
mu sync.Mutex
camera api.Camera
camOpen bool
stopCh chan struct{}
wg sync.WaitGroup
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.
@ -72,6 +75,9 @@ func New(cfg Config) *Server {
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{}),
@ -97,11 +103,14 @@ func (s *Server) Run() error {
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.")
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)
@ -157,7 +166,7 @@ func (s *Server) odometryLoop() {
defer ticker.Stop()
// Prime with first frame.
if img, err := grabFrame(url, tmpFile); err == nil {
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 {
@ -169,7 +178,16 @@ func (s *Server) odometryLoop() {
case <-s.stopCh:
return
case <-ticker.C:
img, err := grabFrame(url, tmpFile)
// 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)
@ -197,22 +215,6 @@ func (s *Server) rtspChannel() string {
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) {
@ -223,6 +225,8 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
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)
}
@ -251,13 +255,10 @@ func (s *Server) handleGrab(w http.ResponseWriter, r *http.Request) {
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 {
if _, err := rtspgrab.GrabFrame(url, out); 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")
@ -318,16 +319,47 @@ func (s *Server) handleSlew(w http.ResponseWriter, r *http.Request) {
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})
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()
writeJSON(w, map[string]string{"status": "motors stopped"})
// 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) {