Add camera streaming guide with all access methods
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
This commit is contained in:
112
dwarfctl/internal/daemon/client.go
Normal file
112
dwarfctl/internal/daemon/client.go
Normal file
@ -0,0 +1,112 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/antitbone/dwarfctl/internal/odometry"
|
||||
)
|
||||
|
||||
// Client is a thin HTTP client that talks to the daemon.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a daemon client for the given address (e.g. "127.0.0.1:7777").
|
||||
func NewClient(addr string) *Client {
|
||||
if addr == "" {
|
||||
addr = DefaultAddr
|
||||
}
|
||||
return &Client{
|
||||
baseURL: "http://" + addr,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// IsAlive checks if the daemon is reachable.
|
||||
func (c *Client) IsAlive() bool {
|
||||
resp, err := c.http.Get(c.baseURL + "/health")
|
||||
return err == nil && resp.StatusCode == 200
|
||||
}
|
||||
|
||||
// Orientation returns the current cumulative orientation from the daemon.
|
||||
func (c *Client) Orientation() (odometry.Orientation, error) {
|
||||
resp, err := c.http.Get(c.baseURL + "/orientation")
|
||||
if err != nil {
|
||||
return odometry.Orientation{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var o odometry.Orientation
|
||||
return o, json.NewDecoder(resp.Body).Decode(&o)
|
||||
}
|
||||
|
||||
// Grab captures a frame. If path is empty, the daemon picks a temp path.
|
||||
// Returns the saved file path.
|
||||
func (c *Client) Grab(path string) (string, error) {
|
||||
url := c.baseURL + "/grab"
|
||||
if path != "" {
|
||||
url += "?path=" + path
|
||||
}
|
||||
resp, err := c.http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("daemon: %s", body)
|
||||
}
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
return result["path"], nil
|
||||
}
|
||||
|
||||
// Reset zeros the odometry.
|
||||
func (c *Client) Reset() error {
|
||||
_, err := c.http.Post(c.baseURL+"/reset", "", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Slew sends a joystick vector.
|
||||
func (c *Client) Slew(angle, length float64) error {
|
||||
body, _ := json.Marshal(map[string]float64{"angle": angle, "length": length})
|
||||
resp, err := c.http.Post(c.baseURL+"/slew", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopMotors stops all motors.
|
||||
func (c *Client) StopMotors() error {
|
||||
_, err := c.http.Post(c.baseURL+"/stop", "", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Camera opens or closes a camera.
|
||||
func (c *Client) Camera(action, cam string) error {
|
||||
url := fmt.Sprintf("%s/camera?action=%s&cam=%s", c.baseURL, action, cam)
|
||||
resp, err := c.http.Post(url, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFoV overrides the field of view.
|
||||
func (c *Client) SetFoV(h, v float64) error {
|
||||
body, _ := json.Marshal(map[string]float64{"h": h, "v": v})
|
||||
resp, err := c.http.Post(c.baseURL+"/fov", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
346
dwarfctl/internal/daemon/server.go
Normal file
346
dwarfctl/internal/daemon/server.go
Normal file
@ -0,0 +1,346 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user