Reverse-engineer DWARF II telescope API and build open-source Go client

Complete reverse-engineering of the DWARFLAB Android app (v3.4.0) protocol
and implementation of a working CLI tool to control DWARF II telescopes.

Analysis (from APK decompilation with jadx):
- Extracted 17 protobuf definitions (382 messages) from embedded descriptors
- Mapped all 323 WebSocket command IDs across 16 modules
- Documented the full protocol: BLE discovery, WebSocket control (port 9900),
  RTSP preview, WsPacket envelope (proto v2.3)
- Documented the Android UI structure (screens, navigation, shooting modes)
- Key discovery: telescope responds with type=3 (reply), not type=1 (response),
  and several commands are fire-and-forget (RGB, camera open/close)

dwarfctl Go client:
- Protobuf bindings generated from extracted .proto files (397 messages)
- WebSocket transport layer with request-response matching and notification fan-out
- Typed API covering cameras, motors, astrophotography, focus, tracking, system, power
- Cobra CLI with 30+ subcommands and --debug traffic logging
- 57 unit tests (transport round-trip, command routing, proto encoding)
- Validated on real hardware: state, photo, motor slew (all directions/speeds),
  focus, RGB, time/location sync all confirmed working

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-12 15:18:56 +02:00
commit 814a836c5a
54 changed files with 35973 additions and 0 deletions

View File

@ -0,0 +1,596 @@
package main
import (
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/antitbone/dwarfctl/internal/api"
"github.com/spf13/cobra"
)
var (
flagIP string
flagClientID string
flagDebug bool
)
func main() {
rootCmd := &cobra.Command{
Use: "dwarfctl",
Short: "DWARF telescope control CLI",
Long: "Open-source client for DWARF II / DWARFLAB smart telescopes.\nControls cameras, motors, astrophotography, focus, tracking, and power\nvia the telescope's WebSocket API (port 9900).",
}
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.AddCommand(
cmdState(),
cmdCamera(),
cmdMotor(),
cmdAstro(),
cmdFocus(),
cmdTrack(),
cmdSystem(),
cmdPower(),
cmdMonitor(),
)
rootCmd.MarkPersistentFlagRequired("ip")
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
// dial connects to the telescope and returns a ready Telescope + cleanup.
func dial() (*api.Telescope, func()) {
scope := api.New(flagClientID)
scope.SetDebug(flagDebug)
if err := scope.Connect(flagIP); err != nil {
fmt.Fprintf(os.Stderr, "Error: connect to %s: %v\n", flagIP, err)
os.Exit(1)
}
return scope, func() { scope.Close() }
}
func mustCam(s string) api.Camera {
switch strings.ToLower(s) {
case "tele", "t", "0":
return api.CameraTele
case "wide", "w", "1":
return api.CameraWide
default:
fmt.Fprintf(os.Stderr, "Error: invalid camera '%s' (use: tele|wide)\n", s)
os.Exit(1)
return api.CameraTele
}
}
// --- state ---
func cmdState() *cobra.Command {
return &cobra.Command{
Use: "state",
Short: "Get device state info",
Run: func(cmd *cobra.Command, args []string) {
scope, cleanup := dial()
defer cleanup()
state, err := scope.GetDeviceState()
if err != nil {
die("get state: %v", err)
}
fmt.Printf("Device State:\n")
printProto(state)
},
}
}
// --- camera ---
func cmdCamera() *cobra.Command {
c := &cobra.Command{
Use: "camera",
Short: "Camera control (open/close/photo/params)",
}
c.AddCommand(
&cobra.Command{
Use: "open [--cam tele|wide]",
Short: "Open camera",
Run: func(cmd *cobra.Command, _ []string) {
cam := mustCam(flagCam(cmd))
scope, cleanup := dial()
defer cleanup()
must(scope.OpenCamera(cam))
fmt.Println("camera opened")
},
},
&cobra.Command{
Use: "close [--cam tele|wide]",
Short: "Close camera",
Run: func(cmd *cobra.Command, _ []string) {
cam := mustCam(flagCam(cmd))
scope, cleanup := dial()
defer cleanup()
must(scope.CloseCamera(cam))
fmt.Println("camera closed")
},
},
&cobra.Command{
Use: "photo [--cam tele|wide] [--raw]",
Short: "Take a photo",
Run: func(cmd *cobra.Command, _ []string) {
cam := mustCam(flagCam(cmd))
raw, _ := cmd.Flags().GetBool("raw")
scope, cleanup := dial()
defer cleanup()
if raw {
must(scope.TakeRawPhoto(cam))
} else {
must(scope.TakePhoto(cam))
}
fmt.Println("photo captured")
},
},
&cobra.Command{
Use: "burst [--cam tele|wide] [start|stop]",
Short: "Start or stop burst capture",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cam := mustCam(flagCam(cmd))
scope, cleanup := dial()
defer cleanup()
switch args[0] {
case "start":
must(scope.StartBurst(cam))
case "stop":
must(scope.StopBurst(cam))
default:
die("usage: camera burst [start|stop]")
}
},
},
&cobra.Command{
Use: "record [--cam tele|wide] [start|stop]",
Short: "Start or stop video recording",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cam := mustCam(flagCam(cmd))
scope, cleanup := dial()
defer cleanup()
switch args[0] {
case "start":
must(scope.StartRecord(cam))
case "stop":
must(scope.StopRecord(cam))
default:
die("usage: camera record [start|stop]")
}
},
},
&cobra.Command{
Use: "exp [--cam tele|wide] <index>",
Short: "Set exposure index (0=1/10000s, see params_range.json)",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cam := mustCam(flagCam(cmd))
idx := parseInt32(args[0])
scope, cleanup := dial()
defer cleanup()
must(scope.SetExposure(cam, idx))
fmt.Printf("exposure set to %d\n", idx)
},
},
&cobra.Command{
Use: "gain [--cam tele|wide] <index>",
Short: "Set gain index (0-200+)",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cam := mustCam(flagCam(cmd))
idx := parseInt32(args[0])
scope, cleanup := dial()
defer cleanup()
must(scope.SetGain(cam, idx))
fmt.Printf("gain set to %d\n", idx)
},
},
&cobra.Command{
Use: "params [--cam tele|wide]",
Short: "Get all camera parameters",
Run: func(cmd *cobra.Command, _ []string) {
cam := mustCam(flagCam(cmd))
scope, cleanup := dial()
defer cleanup()
params, err := scope.GetAllParams(cam)
must(err)
printProto(params)
},
},
)
// Add --cam to all subcommands
for _, sub := range c.Commands() {
sub.Flags().String("cam", "tele", "camera: tele|wide")
}
return c
}
// --- motor ---
func cmdMotor() *cobra.Command {
c := &cobra.Command{Use: "motor", Short: "Motor control (slew/joystick)"}
c.AddCommand(
&cobra.Command{
Use: "slew <angle-deg> <length>",
Short: "Slew via joystick vector (angle 0-360, length 0-1)",
Args: cobra.ExactArgs(2),
Run: func(_ *cobra.Command, args []string) {
angle := parseFloat(args[0])
length := parseFloat(args[1])
scope, cleanup := dial()
defer cleanup()
must(scope.SlewJoystick(angle, length))
fmt.Printf("slew: angle=%.1f length=%.2f\n", angle, length)
},
},
&cobra.Command{
Use: "stop [motor-id]",
Short: "Stop motors (id 0=RA, 1=DEC, default=both)",
Run: func(cmd *cobra.Command, args []string) {
id := int32(0)
if len(args) > 0 {
id = parseInt32(args[0])
}
scope, cleanup := dial()
defer cleanup()
must(scope.MotorStop(id))
fmt.Println("motor stopped")
},
},
&cobra.Command{
Use: "goto <motor-id> <position> <speed>",
Short: "Move motor to position at speed",
Args: cobra.ExactArgs(3),
Run: func(_ *cobra.Command, args []string) {
id := parseInt32(args[0])
pos := parseFloat(args[1])
speed := parseFloat(args[2])
scope, cleanup := dial()
defer cleanup()
must(scope.MotorRunTo(id, pos, speed))
fmt.Printf("motor %d -> pos %.2f @ speed %.2f\n", id, pos, speed)
},
},
&cobra.Command{
Use: "reset <motor-id> [direction]",
Short: "Reset motor to home (0=back, 1=forward)",
Args: cobra.RangeArgs(1, 2),
Run: func(_ *cobra.Command, args []string) {
id := parseInt32(args[0])
dir := false
if len(args) > 1 && args[1] == "1" {
dir = true
}
scope, cleanup := dial()
defer cleanup()
must(scope.MotorReset(id, dir))
fmt.Printf("motor %d reset (dir=%v)\n", id, dir)
},
},
&cobra.Command{
Use: "position <motor-id>",
Short: "Get motor position (experimental cmd 14001)",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
id := parseInt32(args[0])
scope, cleanup := dial()
defer cleanup()
pos, err := scope.MotorGetPosition(id)
must(err)
fmt.Printf("motor %d: id=%d code=%d position=%.2f\n", id, pos.GetId(), pos.GetCode(), pos.GetPosition())
},
},
)
return c
}
// --- astro ---
func cmdAstro() *cobra.Command {
c := &cobra.Command{Use: "astro", Short: "Astrophotography (calibrate/goto/stack)"}
c.AddCommand(
&cobra.Command{
Use: "calibrate [start|stop]",
Short: "Start or stop star calibration",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
scope, cleanup := dial()
defer cleanup()
switch args[0] {
case "start":
must(scope.StartCalibration())
fmt.Println("calibration started")
case "stop":
must(scope.StopCalibration())
fmt.Println("calibration stopped")
default:
die("usage: astro calibrate [start|stop]")
}
},
},
&cobra.Command{
Use: "goto-dso <ra-deg> <dec-deg> [name]",
Short: "GoTo a deep-sky object by RA/Dec (degrees)",
Args: cobra.MinimumNArgs(2),
Run: func(_ *cobra.Command, args []string) {
ra := parseFloat(args[0])
dec := parseFloat(args[1])
name := ""
if len(args) > 2 {
name = strings.Join(args[2:], " ")
}
scope, cleanup := dial()
defer cleanup()
must(scope.GotoDSO(ra, dec, name))
fmt.Printf("GoTo DSO: RA=%.4f° Dec=%.4f° (%s)\n", ra, dec, name)
},
},
&cobra.Command{
Use: "goto-solar <index>",
Short: "GoTo solar system object (1=Mercury..7=Neptune, see planet table)",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
idx := parseInt32(args[0])
scope, cleanup := dial()
defer cleanup()
must(scope.GotoSolar(idx))
fmt.Printf("GoTo solar system object %d\n", idx)
},
},
&cobra.Command{
Use: "goto-stop",
Short: "Abort GoTo",
Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial()
defer cleanup()
must(scope.StopGoto())
fmt.Println("GoTo stopped")
},
},
&cobra.Command{
Use: "stack [start|stop]",
Short: "Start or stop live stacking",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
scope, cleanup := dial()
defer cleanup()
switch args[0] {
case "start":
must(scope.StartLiveStacking())
case "stop":
must(scope.StopLiveStacking())
default:
die("usage: astro stack [start|stop]")
}
},
},
&cobra.Command{
Use: "eq-solve [start|stop]",
Short: "Start or stop EQ solving (polar alignment)",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
scope, cleanup := dial()
defer cleanup()
switch args[0] {
case "start":
must(scope.StartEqSolving())
case "stop":
must(scope.StopEqSolving())
default:
die("usage: astro eq-solve [start|stop]")
}
},
},
)
return c
}
// --- focus ---
func cmdFocus() *cobra.Command {
c := &cobra.Command{Use: "focus", Short: "Focus control"}
c.AddCommand(
&cobra.Command{
Use: "auto",
Short: "Trigger autofocus",
Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial()
defer cleanup()
must(scope.AutoFocus())
fmt.Println("autofocus triggered")
},
},
&cobra.Command{
Use: "step <0|1>",
Short: "Manual single-step focus (0=in, 1=out)",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
dir := uint32(parseInt32(args[0]))
scope, cleanup := dial()
defer cleanup()
must(scope.ManualSingleStepFocus(dir))
},
},
&cobra.Command{
Use: "astro-af [start|stop]",
Short: "Start or stop astro autofocus",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
scope, cleanup := dial()
defer cleanup()
switch args[0] {
case "start":
must(scope.StartAstroAutoFocus())
case "stop":
must(scope.StopAstroAutoFocus())
}
},
},
)
return c
}
// --- track ---
func cmdTrack() *cobra.Command {
c := &cobra.Command{Use: "track", Short: "Tracking control"}
c.AddCommand(
&cobra.Command{
Use: "start <x> <y> <w> <h> [cam-id]",
Short: "Start tracking an object in a region",
Args: cobra.MinimumNArgs(4),
Run: func(_ *cobra.Command, args []string) {
x, y, w, h := parseInt32(args[0]), parseInt32(args[1]), parseInt32(args[2]), parseInt32(args[3])
cam := int32(0)
if len(args) > 4 {
cam = parseInt32(args[4])
}
scope, cleanup := dial()
defer cleanup()
must(scope.StartTracking(x, y, w, h, cam))
fmt.Printf("tracking started: bbox(%d,%d,%d,%d) cam=%d\n", x, y, w, h, cam)
},
},
&cobra.Command{
Use: "stop",
Short: "Stop tracking",
Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial()
defer cleanup()
must(scope.StopTracking())
fmt.Println("tracking stopped")
},
},
)
return c
}
// --- system ---
func cmdSystem() *cobra.Command {
c := &cobra.Command{Use: "system", Short: "System commands (time/location)"}
c.AddCommand(
&cobra.Command{
Use: "sync-time",
Short: "Sync telescope clock to this machine",
Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial()
defer cleanup()
_, tz := time.Now().Zone()
must(scope.SetTime(uint64(time.Now().Unix()), float64(tz)))
fmt.Println("time synced")
},
},
&cobra.Command{
Use: "set-location <lat> <lon> [alt]",
Short: "Set observing location",
Args: cobra.MinimumNArgs(2),
Run: func(_ *cobra.Command, args []string) {
lat := parseFloat(args[0])
lon := parseFloat(args[1])
alt := 0.0
if len(args) > 2 {
alt = parseFloat(args[2])
}
scope, cleanup := dial()
defer cleanup()
must(scope.SetLocation(lat, lon, alt))
fmt.Printf("location set: %.4f, %.4f, %.0fm\n", lat, lon, alt)
},
},
)
return c
}
// --- power ---
func cmdPower() *cobra.Command {
c := &cobra.Command{Use: "power", Short: "Power & RGB control"}
c.AddCommand(
&cobra.Command{Use: "rgb-on", Short: "Turn on RGB ring light", Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial(); defer cleanup(); must(scope.RGBOn()); fmt.Println("RGB on")
}},
&cobra.Command{Use: "rgb-off", Short: "Turn off RGB ring light", Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial(); defer cleanup(); must(scope.RGBOff()); fmt.Println("RGB off")
}},
&cobra.Command{Use: "off", Short: "Power down telescope", Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial(); defer cleanup(); must(scope.PowerDown()); fmt.Println("powering down")
}},
&cobra.Command{Use: "reboot", Short: "Reboot telescope", Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial(); defer cleanup(); must(scope.Reboot()); fmt.Println("rebooting")
}},
)
return c
}
// --- monitor ---
func cmdMonitor() *cobra.Command {
return &cobra.Command{
Use: "monitor",
Short: "Listen for telescope notifications (Ctrl-C to stop)",
Run: func(_ *cobra.Command, _ []string) {
scope, cleanup := dial()
defer cleanup()
ch := scope.Notifications()
fmt.Println("Monitoring notifications (Ctrl-C to stop)...")
for pkt := range ch {
fmt.Printf("[NOTIFY] cmd=%d module=%d type=%d data_len=%d\n",
pkt.GetCmd(), pkt.GetModuleId(), pkt.GetType(), len(pkt.GetData()))
}
},
}
}
// --- helpers ---
func flagCam(cmd *cobra.Command) string {
s, _ := cmd.Flags().GetString("cam")
return s
}
func parseInt32(s string) int32 {
v, err := strconv.ParseInt(s, 0, 32)
if err != nil {
die("invalid number '%s': %v", s, err)
}
return int32(v)
}
func parseFloat(s string) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
die("invalid float '%s': %v", s, err)
}
if math.IsNaN(v) || math.IsInf(v, 0) {
die("invalid float '%s'", s)
}
return v
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
os.Exit(1)
}
func must(err error) {
if err != nil {
die("%v", err)
}
}
func printProto(v any) {
fmt.Printf(" %+v\n", v)
}