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:
231
dwarfctl/internal/api/client_test.go
Normal file
231
dwarfctl/internal/api/client_test.go
Normal file
@ -0,0 +1,231 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pb "github.com/antitbone/dwarfctl/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// TestModuleIDForCmd verifies that every command range maps to the correct
|
||||
// module_id, matching WsCmd.getModuleId() from the original APK.
|
||||
func TestModuleIDForCmd(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cmd uint32
|
||||
wantMod uint32
|
||||
}{
|
||||
// Camera Tele (10000–10499)
|
||||
{"tele open camera", CmdTeleOpenCamera, ModuleCameraTele},
|
||||
{"tele photo", CmdTelePhotograph, ModuleCameraTele},
|
||||
{"tele get all params", CmdTeleGetAllParams, ModuleCameraTele},
|
||||
{"tele boundary low", 10000, ModuleCameraTele},
|
||||
{"tele boundary high-1", 10499, ModuleCameraTele},
|
||||
|
||||
// Astro (11000–11499)
|
||||
{"astro calibrate", CmdAstroStartCalibration, ModuleAstro},
|
||||
{"astro goto dso", CmdAstroStartGotoDSO, ModuleAstro},
|
||||
{"astro boundary low", 11000, ModuleAstro},
|
||||
{"astro boundary high-1", 11499, ModuleAstro},
|
||||
|
||||
// Camera Wide (12000–12499)
|
||||
{"wide open camera", CmdWideOpenCamera, ModuleCameraWide},
|
||||
{"wide photo", CmdWidePhotograph, ModuleCameraWide},
|
||||
{"wide boundary low", 12000, ModuleCameraWide},
|
||||
{"wide boundary high-1", 12499, ModuleCameraWide},
|
||||
|
||||
// System (13000–13299)
|
||||
{"system set time", CmdSystemSetTime, ModuleSystem},
|
||||
{"system set location", CmdSystemSetLocation, ModuleSystem},
|
||||
{"system boundary low", 13000, ModuleSystem},
|
||||
{"system boundary high-1", 13299, ModuleSystem},
|
||||
|
||||
// RGB Power (13500–13799)
|
||||
{"rgb on", CmdRGBOn, ModuleRGBPower},
|
||||
{"reboot", CmdReboot, ModuleRGBPower},
|
||||
{"rgb boundary low", 13500, ModuleRGBPower},
|
||||
{"rgb boundary high-1", 13799, ModuleRGBPower},
|
||||
|
||||
// Motor (14000–14499)
|
||||
{"motor run", CmdMotorRun, ModuleMotor},
|
||||
{"motor joystick", CmdMotorJoystick, ModuleMotor},
|
||||
{"motor boundary low", 14000, ModuleMotor},
|
||||
{"motor boundary high-1", 14499, ModuleMotor},
|
||||
|
||||
// Track (14800–14899)
|
||||
{"track start", CmdTrackStart, ModuleTrack},
|
||||
{"switch preview", CmdSwitchMainPreview, ModuleTrack},
|
||||
{"track boundary low", 14800, ModuleTrack},
|
||||
{"track boundary high-1", 14899, ModuleTrack},
|
||||
|
||||
// Focus (15000–15199)
|
||||
{"focus auto", CmdFocusAutoFocus, ModuleFocus},
|
||||
{"focus boundary low", 15000, ModuleFocus},
|
||||
{"focus boundary high-1", 15199, ModuleFocus},
|
||||
|
||||
// Notify (15200–15499)
|
||||
{"notify track result", NotifyTrackResult, ModuleNotify},
|
||||
{"notify boundary low", 15200, ModuleNotify},
|
||||
{"notify boundary high-1", 15499, ModuleNotify},
|
||||
|
||||
// Panorama (15500–15599)
|
||||
{"pano start grid", CmdPanoStartGrid, ModulePanorama},
|
||||
{"pano boundary low", 15500, ModulePanorama},
|
||||
{"pano boundary high-1", 15599, ModulePanorama},
|
||||
|
||||
// ITips (15700–15799)
|
||||
{"itips boundary low", 15700, ModuleITips},
|
||||
|
||||
// Shooting Schedule (16100–16399)
|
||||
{"schedule boundary low", 16100, ModuleShootingSchedule},
|
||||
{"schedule boundary high-1", 16399, ModuleShootingSchedule},
|
||||
|
||||
// Task Center (16400–16599)
|
||||
{"task get state", CmdTaskGetDeviceState, ModuleTaskCenter},
|
||||
{"task boundary low", 16400, ModuleTaskCenter},
|
||||
{"task boundary high-1", 16599, ModuleTaskCenter},
|
||||
|
||||
// Param (16700–16799)
|
||||
{"param boundary low", 16700, ModuleParam},
|
||||
|
||||
// Voice Assistant (16800–16899)
|
||||
{"voice boundary low", 16800, ModuleVoiceAssistant},
|
||||
|
||||
// Device (17000–17099)
|
||||
{"device boundary low", 17000, ModuleDevice},
|
||||
{"device boundary high-1", 17099, ModuleDevice},
|
||||
|
||||
// Out of range
|
||||
{"unknown cmd 9999", 9999, ModuleNone},
|
||||
{"unknown cmd 99999", 99999, ModuleNone},
|
||||
{"cmd 0", 0, ModuleNone},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ModuleIDForCmd(tc.cmd)
|
||||
if got != tc.wantMod {
|
||||
t.Errorf("ModuleIDForCmd(%d) = %d, want %d", tc.cmd, got, tc.wantMod)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCameraCmdHelper verifies that cameraCmd dispatches correctly between
|
||||
// Tele and Wide command IDs.
|
||||
func TestCameraCmdHelper(t *testing.T) {
|
||||
cases := []struct {
|
||||
tele, wide uint32
|
||||
cam Camera
|
||||
want uint32
|
||||
}{
|
||||
{CmdTeleOpenCamera, CmdWideOpenCamera, CameraTele, CmdTeleOpenCamera},
|
||||
{CmdTeleOpenCamera, CmdWideOpenCamera, CameraWide, CmdWideOpenCamera},
|
||||
{10002, 12022, CameraTele, 10002},
|
||||
{10002, 12022, CameraWide, 12022},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := cameraCmd(tc.tele, tc.wide, tc.cam)
|
||||
if got != tc.want {
|
||||
t.Errorf("cameraCmd(%d,%d,%d) = %d, want %d",
|
||||
tc.tele, tc.wide, tc.cam, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeResponseEmpty verifies that decoding an empty Data field does
|
||||
// not error and leaves the message at its zero value.
|
||||
func TestDecodeResponseEmpty(t *testing.T) {
|
||||
pkt := &pb.WsPacket{Cmd: 10000, Data: nil}
|
||||
msg := &pb.ResGetAllParams{}
|
||||
if err := decodeResponse(pkt, msg); err != nil {
|
||||
t.Errorf("decodeResponse with empty data should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeResponseWithPayload verifies that a response payload decodes
|
||||
// correctly.
|
||||
func TestDecodeResponseWithPayload(t *testing.T) {
|
||||
// Build a ComResponse{code: 0} as the inner payload
|
||||
inner := &pb.ComResponse{Code: 0}
|
||||
data, err := proto.Marshal(inner)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ComResponse: %v", err)
|
||||
}
|
||||
pkt := &pb.WsPacket{Cmd: 10002, Data: data}
|
||||
|
||||
msg := &pb.ComResponse{}
|
||||
if err := decodeResponse(pkt, msg); err != nil {
|
||||
t.Fatalf("decodeResponse: %v", err)
|
||||
}
|
||||
if msg.GetCode() != 0 {
|
||||
t.Errorf("code: got %d, want 0", msg.GetCode())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeResponseBadData verifies that corrupt data returns an error.
|
||||
func TestDecodeResponseBadData(t *testing.T) {
|
||||
pkt := &pb.WsPacket{Data: []byte{0xff, 0xff, 0xff, 0xff}}
|
||||
msg := &pb.ComResponse{}
|
||||
// Invalid wire format should error (may or may not depending on content,
|
||||
// but definitely garbled). We just ensure it doesn't panic.
|
||||
_ = decodeResponse(pkt, msg)
|
||||
}
|
||||
|
||||
// TestCommandIDUniqueness verifies that command IDs are unique across all
|
||||
// modules (no accidental collisions).
|
||||
func TestCommandIDUniqueness(t *testing.T) {
|
||||
allCmds := []uint32{
|
||||
CmdTeleOpenCamera, CmdTeleCloseCamera, CmdTelePhotograph, CmdTeleBurst,
|
||||
CmdTeleStopBurst, CmdTeleStartRecord, CmdTeleStopRecord,
|
||||
CmdTeleSetExp, CmdTeleGetExp, CmdTeleSetGain, CmdTeleGetGain,
|
||||
CmdAstroStartCalibration, CmdAstroStopCalibration,
|
||||
CmdAstroStartGotoDSO, CmdAstroStartGotoSolar, CmdAstroStopGoto,
|
||||
CmdWideOpenCamera, CmdWideCloseCamera, CmdWidePhotograph,
|
||||
CmdSystemSetTime, CmdSystemSetLocation,
|
||||
CmdRGBOn, CmdRGBOff, CmdPowerDown, CmdReboot,
|
||||
CmdMotorRun, CmdMotorStop, CmdMotorJoystick,
|
||||
CmdTrackStart, CmdTrackStop, CmdSwitchMainPreview,
|
||||
CmdFocusAutoFocus, CmdFocusManualSingle,
|
||||
CmdPanoStartGrid, CmdPanoStop,
|
||||
CmdTaskStartTask, CmdTaskStopTask, CmdTaskGetDeviceState,
|
||||
}
|
||||
seen := make(map[uint32]string)
|
||||
for _, cmd := range allCmds {
|
||||
if prev, ok := seen[cmd]; ok {
|
||||
t.Errorf("command ID %d is duplicated (%s vs %s)", cmd, prev, "current")
|
||||
}
|
||||
seen[cmd] = "seen"
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTelescope verifies that New() returns a usable Telescope instance.
|
||||
func TestNewTelescope(t *testing.T) {
|
||||
scope := New("test-client")
|
||||
if scope == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
if scope.t == nil {
|
||||
t.Fatal("telescope transport is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConstants verify module IDs are non-zero and distinct.
|
||||
func TestModuleConstants(t *testing.T) {
|
||||
modules := []uint32{
|
||||
ModuleCameraTele, ModuleCameraWide, ModuleAstro, ModuleSystem,
|
||||
ModuleRGBPower, ModuleMotor, ModuleTrack, ModuleFocus, ModuleNotify,
|
||||
ModulePanorama, ModuleITips, ModuleShootingSchedule, ModuleTaskCenter,
|
||||
ModuleParam, ModuleVoiceAssistant, ModuleDevice,
|
||||
}
|
||||
seen := make(map[uint32]bool)
|
||||
for _, m := range modules {
|
||||
if m == 0 {
|
||||
t.Errorf("module constant is 0 (ModuleNone)")
|
||||
}
|
||||
if seen[m] {
|
||||
t.Errorf("module constant %d is duplicated", m)
|
||||
}
|
||||
seen[m] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user