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:
Jacquin Antoine
2026-07-13 19:56:04 +02:00
parent 7dc41ec368
commit c2f9e54eb4
13 changed files with 1866 additions and 5 deletions

View File

@ -3,6 +3,9 @@ package main
import (
"bufio"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"math"
"os"
"os/exec"
@ -11,6 +14,8 @@ import (
"time"
"github.com/antitbone/dwarfctl/internal/api"
"github.com/antitbone/dwarfctl/internal/daemon"
"github.com/antitbone/dwarfctl/internal/odometry"
pb "github.com/antitbone/dwarfctl/proto"
"github.com/spf13/cobra"
"google.golang.org/protobuf/proto"
@ -20,6 +25,7 @@ var (
flagIP string
flagClientID string
flagDebug bool
flagDaemon string // daemon addr, e.g. "127.0.0.1:7777"
)
func main() {
@ -31,8 +37,10 @@ func main() {
rootCmd.PersistentFlags().StringVarP(&flagIP, "ip", "i", "", "telescope IP address (required)")
rootCmd.PersistentFlags().StringVar(&flagClientID, "client-id", "dwarfctl", "WebSocket client_id")
rootCmd.PersistentFlags().BoolVarP(&flagDebug, "debug", "d", false, "verbose WebSocket traffic log")
rootCmd.PersistentFlags().StringVar(&flagDaemon, "daemon", "", "daemon addr (e.g. 127.0.0.1:7777)")
rootCmd.AddCommand(
cmdServe(),
cmdHealth(),
cmdState(),
cmdCamera(),
@ -46,9 +54,15 @@ func main() {
cmdInteractive(),
cmdPano(),
cmdPreview(),
cmdOrient(),
)
rootCmd.MarkPersistentFlagRequired("ip")
// --ip is required only when not using --daemon
rootCmd.PersistentPreRun = func(cmd *cobra.Command, _ []string) {
if flagDaemon == "" && flagIP == "" && cmd.Name() != "serve" && cmd.Name() != "help" && cmd.Name() != "completion" {
die("--ip is required (or use --daemon to connect to a running daemon)")
}
}
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
@ -687,8 +701,8 @@ func cmdPreview() *cobra.Command {
out = args[0]
}
scope, cleanup := dial()
defer cleanup()
must(scope.OpenCamera(cam))
cleanup()
time.Sleep(2 * time.Second)
url := fmt.Sprintf("rtsp://%s:554/%s/stream0", flagIP, ch)
ffmpegCmd := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
@ -817,6 +831,222 @@ func cmdInteractive() *cobra.Command {
}
}
// --- orient (visual odometry) ---
func cmdOrient() *cobra.Command {
c := &cobra.Command{
Use: "orient",
Short: "Image-based orientation (visual odometry via wide-angle frames)",
Long: "Estimate telescope pointing rotation by comparing wide-angle frames.\n" +
"Uses FFT phase correlation — works on any scene (sky, horizon, clouds)\n" +
"without star identification or plate solving.\n\n" +
"Designed for a static alt-az mount tracking airplanes (day) or satellites (night).",
}
c.AddCommand(
&cobra.Command{
Use: "status",
Short: "Get current orientation from the daemon (requires 'serve' running)",
Run: func(_ *cobra.Command, _ []string) {
dc := daemonClient()
if dc == nil {
die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
}
o, err := dc.Orientation()
must(err)
fmt.Println(o)
},
},
&cobra.Command{
Use: "reset",
Short: "Reset the odometry tracker (requires daemon)",
Run: func(_ *cobra.Command, _ []string) {
dc := daemonClient()
if dc == nil {
die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
}
must(dc.Reset())
fmt.Println("orientation reset")
},
},
&cobra.Command{
Use: "grab [output.jpg]",
Short: "Grab a frame via the daemon (camera stays open)",
Args: cobra.MaximumNArgs(1),
Run: func(_ *cobra.Command, args []string) {
dc := daemonClient()
if dc == nil {
die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
}
out := ""
if len(args) > 0 {
out = args[0]
}
path, err := dc.Grab(out)
must(err)
fmt.Printf("frame saved: %s\n", path)
},
},
&cobra.Command{
Use: "compare <img1.jpg> <img2.jpg>",
Short: "Measure rotation between two image files",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
fovH, _ := cmd.Flags().GetFloat64("fov-h")
fovV, _ := cmd.Flags().GetFloat64("fov-v")
size, _ := cmd.Flags().GetInt("size")
r, err := odometry.EstimateFiles(args[0], args[1], fovH, fovV, size)
must(err)
fmt.Printf("Scene shift (img1 -> img2):\n %s\n", r)
fmt.Printf(" grid=%dx%d fov=%.2f°x%.2f°\n", r.Size, r.Size, fovH, fovV)
if r.Confidence < 0.05 {
fmt.Println(" ⚠ low confidence (frames too similar or motion too large)")
}
},
},
&cobra.Command{
Use: "live",
Short: "Continuously track pointing from RTSP frames (Ctrl-C to stop)",
Run: func(cmd *cobra.Command, _ []string) {
cam := mustCam(cmd.Flag("cam").Value.String())
fovH, _ := cmd.Flags().GetFloat64("fov-h")
fovV, _ := cmd.Flags().GetFloat64("fov-v")
size, _ := cmd.Flags().GetInt("size")
interval, _ := cmd.Flags().GetDuration("interval")
ch := "ch0"
if cam == api.CameraWide {
ch = "ch1"
}
scope, cleanup := dial()
defer cleanup()
must(scope.OpenCamera(cam))
time.Sleep(2 * time.Second)
url := fmt.Sprintf("rtsp://%s:554/%s/stream0", flagIP, ch)
tracker := odometry.NewTracker(fovH, fovV, size)
tmpA := "/tmp/dwarf_orient_a.jpg"
tmpB := "/tmp/dwarf_orient_b.jpg"
useA := true
fmt.Printf("Live visual odometry on %s (fov %.1f°x%.1f°, grid %d, every %s)\n",
url, fovH, fovV, size, interval)
fmt.Println("Ctrl-C to stop.")
ticker := time.NewTicker(interval)
defer ticker.Stop()
grab := func(out string) error {
return exec.Command("ffmpeg", "-rtsp_transport", "tcp",
"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out).Run()
}
// Prime with first frame.
first := "/tmp/dwarf_orient_prime.jpg"
if err := grab(first); err != nil {
die("ffmpeg grab: %v (is ffmpeg installed?)", err)
}
img, err := decodeJPEG(first)
if err != nil {
die("decode: %v", err)
}
o, _ := tracker.Update(img)
fmt.Printf("[prime] %s\n", o)
for range ticker.C {
out := tmpA
if !useA {
out = tmpB
}
useA = !useA
if err := grab(out); err != nil {
fmt.Fprintf(os.Stderr, " grab error: %v\n", err)
continue
}
img, err := decodeJPEG(out)
if err != nil {
fmt.Fprintf(os.Stderr, " decode error: %v\n", err)
continue
}
o, err := tracker.Update(img)
if err != nil {
fmt.Fprintf(os.Stderr, " odometry error: %v\n", err)
continue
}
ts := time.Now().Format("15:04:05")
fmt.Printf("[%s] %s\n", ts, o)
}
},
},
)
for _, sub := range c.Commands() {
sub.Flags().Float64("fov-h", odometry.DefaultFoVH, "horizontal field of view (degrees)")
sub.Flags().Float64("fov-v", odometry.DefaultFoVV, "vertical field of view (degrees)")
sub.Flags().Int("size", odometry.DefaultSize, "FFT grid size (power of 2: 128/256/512)")
if sub.Name() == "live" {
sub.Flags().String("cam", "wide", "camera: tele|wide")
sub.Flags().Duration("interval", 3*time.Second, "time between frames")
}
}
return c
}
// decodeJPEG reads a JPEG/PNG file into an image.Image.
func decodeJPEG(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}
// --- serve (daemon) ---
func cmdServe() *cobra.Command {
c := &cobra.Command{
Use: "serve",
Short: "Start persistent telescope daemon (keeps WS + camera + odometry alive)",
Run: func(cmd *cobra.Command, _ []string) {
addr, _ := cmd.Flags().GetString("addr")
cam, _ := cmd.Flags().GetString("cam")
fovH, _ := cmd.Flags().GetFloat64("fov-h")
fovV, _ := cmd.Flags().GetFloat64("fov-v")
size, _ := cmd.Flags().GetInt("size")
interval, _ := cmd.Flags().GetDuration("interval")
srv := daemon.New(daemon.Config{
IP: flagIP,
Addr: addr,
FoVH: fovH,
FoVV: fovV,
GridSize: size,
Camera: cam,
Interval: interval,
Debug: flagDebug,
})
if err := srv.Run(); err != nil {
die("daemon: %v", err)
}
},
}
c.Flags().String("addr", daemon.DefaultAddr, "HTTP listen address")
c.Flags().String("cam", "wide", "camera to open: tele|wide")
c.Flags().Float64("fov-h", odometry.DefaultFoVH, "horizontal FoV (degrees)")
c.Flags().Float64("fov-v", odometry.DefaultFoVV, "vertical FoV (degrees)")
c.Flags().Int("size", odometry.DefaultSize, "FFT grid size")
c.Flags().Duration("interval", 5*time.Second, "odometry frame interval")
return c
}
// daemonClient returns a daemon client if --daemon is set, else nil.
func daemonClient() *daemon.Client {
if flagDaemon == "" {
return nil
}
return daemon.NewClient(flagDaemon)
}
// --- helpers ---
func flagCam(cmd *cobra.Command) string {

View File

@ -0,0 +1,71 @@
package main
import (
"fmt"
"os"
"os/exec"
"time"
"github.com/antitbone/dwarfctl/internal/api"
)
func main() {
ip := "192.168.88.1"
if len(os.Args) > 1 {
ip = os.Args[1]
}
scope := api.New("rtsp-test")
scope.SetDebug(true)
fmt.Println("Connecting to", ip)
if err := scope.Connect(ip); err != nil {
die("connect: %v", err)
}
defer scope.Close()
fmt.Println("Opening wide camera...")
if err := scope.OpenCamera(api.CameraWide); err != nil {
die("open camera: %v", err)
}
fmt.Println("Wide camera opened. Waiting 3s for stream to initialize...")
time.Sleep(3 * time.Second)
url := fmt.Sprintf("rtsp://%s:554/ch1/stream0", ip)
out := "/tmp/rtsp_test_frame.jpg"
fmt.Println("Grabbing frame via ffmpeg...")
cmd := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
die("ffmpeg: %v", err)
}
info, _ := os.Stat(out)
if info != nil {
fmt.Printf("SUCCESS: frame saved (%d bytes)\n", info.Size())
}
// Also try tele
fmt.Println("\nOpening tele camera...")
if err := scope.OpenCamera(api.CameraTele); err != nil {
die("open tele: %v", err)
}
time.Sleep(3 * time.Second)
url0 := fmt.Sprintf("rtsp://%s:554/ch0/stream0", ip)
out0 := "/tmp/rtsp_test_tele.jpg"
fmt.Println("Grabbing tele frame...")
cmd0 := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
"-i", url0, "-frames:v", "1", "-q:v", "2", "-y", out0)
cmd0.Stdout = os.Stdout
cmd0.Stderr = os.Stderr
cmd0.Run()
if i, _ := os.Stat(out0); i != nil {
fmt.Printf("SUCCESS: tele frame saved (%d bytes)\n", i.Size())
}
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...)
os.Exit(1)
}