Files
dwarf-go/dwarfctl/internal/api/client_test.go
Jacquin Antoine 814a836c5a 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
2026-07-12 15:18:56 +02:00

232 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (1000010499)
{"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 (1100011499)
{"astro calibrate", CmdAstroStartCalibration, ModuleAstro},
{"astro goto dso", CmdAstroStartGotoDSO, ModuleAstro},
{"astro boundary low", 11000, ModuleAstro},
{"astro boundary high-1", 11499, ModuleAstro},
// Camera Wide (1200012499)
{"wide open camera", CmdWideOpenCamera, ModuleCameraWide},
{"wide photo", CmdWidePhotograph, ModuleCameraWide},
{"wide boundary low", 12000, ModuleCameraWide},
{"wide boundary high-1", 12499, ModuleCameraWide},
// System (1300013299)
{"system set time", CmdSystemSetTime, ModuleSystem},
{"system set location", CmdSystemSetLocation, ModuleSystem},
{"system boundary low", 13000, ModuleSystem},
{"system boundary high-1", 13299, ModuleSystem},
// RGB Power (1350013799)
{"rgb on", CmdRGBOn, ModuleRGBPower},
{"reboot", CmdReboot, ModuleRGBPower},
{"rgb boundary low", 13500, ModuleRGBPower},
{"rgb boundary high-1", 13799, ModuleRGBPower},
// Motor (1400014499)
{"motor run", CmdMotorRun, ModuleMotor},
{"motor joystick", CmdMotorJoystick, ModuleMotor},
{"motor boundary low", 14000, ModuleMotor},
{"motor boundary high-1", 14499, ModuleMotor},
// Track (1480014899)
{"track start", CmdTrackStart, ModuleTrack},
{"switch preview", CmdSwitchMainPreview, ModuleTrack},
{"track boundary low", 14800, ModuleTrack},
{"track boundary high-1", 14899, ModuleTrack},
// Focus (1500015199)
{"focus auto", CmdFocusAutoFocus, ModuleFocus},
{"focus boundary low", 15000, ModuleFocus},
{"focus boundary high-1", 15199, ModuleFocus},
// Notify (1520015499)
{"notify track result", NotifyTrackResult, ModuleNotify},
{"notify boundary low", 15200, ModuleNotify},
{"notify boundary high-1", 15499, ModuleNotify},
// Panorama (1550015599)
{"pano start grid", CmdPanoStartGrid, ModulePanorama},
{"pano boundary low", 15500, ModulePanorama},
{"pano boundary high-1", 15599, ModulePanorama},
// ITips (1570015799)
{"itips boundary low", 15700, ModuleITips},
// Shooting Schedule (1610016399)
{"schedule boundary low", 16100, ModuleShootingSchedule},
{"schedule boundary high-1", 16399, ModuleShootingSchedule},
// Task Center (1640016599)
{"task get state", CmdTaskGetDeviceState, ModuleTaskCenter},
{"task boundary low", 16400, ModuleTaskCenter},
{"task boundary high-1", 16599, ModuleTaskCenter},
// Param (1670016799)
{"param boundary low", 16700, ModuleParam},
// Voice Assistant (1680016899)
{"voice boundary low", 16800, ModuleVoiceAssistant},
// Device (1700017099)
{"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
}
}