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,395 @@
package api
import (
"fmt"
"time"
"github.com/antitbone/dwarfctl/internal/transport"
pb "github.com/antitbone/dwarfctl/proto"
"google.golang.org/protobuf/proto"
)
// Telescope wraps the WebSocket transport with typed telescope commands.
type Telescope struct {
t *transport.Client
}
// New creates a Telescope API client.
func New(clientID string) *Telescope {
return &Telescope{t: transport.NewClient(clientID, 1)}
}
// SetDebug enables verbose WebSocket traffic logging.
func (t *Telescope) SetDebug(on bool) {
t.t.Debug = on
}
// Connect opens a WebSocket connection to the telescope.
func (t *Telescope) Connect(ip string) error {
return t.t.Connect(ip)
}
// Close disconnects from the telescope.
func (t *Telescope) Close() error {
return t.t.Close()
}
// Notifications returns a channel for receiving NOTIFY packets.
func (t *Telescope) Notifications() chan *pb.WsPacket {
return t.t.SubscribeNotifications()
}
// send marshals an inner proto message, sends it, and returns the raw response.
func (t *Telescope) send(cmd uint32, msg proto.Message) (*pb.WsPacket, error) {
data, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
return t.t.Send(ModuleIDForCmd(cmd), cmd, data, 10*time.Second)
}
// sendEmpty sends a command with no payload (empty data field).
func (t *Telescope) sendEmpty(cmd uint32) (*pb.WsPacket, error) {
return t.t.Send(ModuleIDForCmd(cmd), cmd, nil, 10*time.Second)
}
// sendNotify marshals and sends without waiting for a response.
func (t *Telescope) sendNotify(cmd uint32, msg proto.Message) error {
data, err := proto.Marshal(msg)
if err != nil {
return err
}
return t.t.SendNotify(ModuleIDForCmd(cmd), cmd, data)
}
// decodeResponse parses the WsPacket data field into the given message type.
func decodeResponse(pkt *pb.WsPacket, msg proto.Message) error {
if len(pkt.GetData()) == 0 {
return nil
}
return proto.Unmarshal(pkt.GetData(), msg)
}
// --- Camera commands ---
// Camera identifies which camera to address.
type Camera int
const (
CameraTele Camera = 0
CameraWide Camera = 1
)
func cameraCmd(tele, wide uint32, cam Camera) uint32 {
if cam == CameraWide {
return wide
}
return tele
}
// OpenCamera opens the specified camera.
// Some firmwares don't send a type=3 reply; fire-and-forget is safe.
func (t *Telescope) OpenCamera(cam Camera) error {
return t.sendNotify(cameraCmd(CmdTeleOpenCamera, CmdWideOpenCamera, cam), &pb.ReqMotorServiceJoystickStop{})
}
// CloseCamera closes the specified camera.
func (t *Telescope) CloseCamera(cam Camera) error {
return t.sendNotify(cameraCmd(CmdTeleCloseCamera, CmdWideCloseCamera, cam), &pb.ReqMotorServiceJoystickStop{})
}
// TakePhoto captures a single photograph.
func (t *Telescope) TakePhoto(cam Camera) error {
_, err := t.sendEmpty(cameraCmd(CmdTelePhotograph, CmdWidePhotograph, cam))
return err
}
// TakeRawPhoto captures a RAW photo.
func (t *Telescope) TakeRawPhoto(cam Camera) error {
_, err := t.sendEmpty(cameraCmd(CmdTelePhotoRaw, CmdWidePhotoRaw, cam))
return err
}
// StartBurst starts burst capture.
func (t *Telescope) StartBurst(cam Camera) error {
_, err := t.sendEmpty(cameraCmd(CmdTeleBurst, CmdWideBurst, cam))
return err
}
// StopBurst stops burst capture.
func (t *Telescope) StopBurst(cam Camera) error {
_, err := t.sendEmpty(cameraCmd(CmdTeleStopBurst, CmdWideStopBurst, cam))
return err
}
// StartRecord starts video recording.
func (t *Telescope) StartRecord(cam Camera) error {
_, err := t.sendEmpty(cameraCmd(CmdTeleStartRecord, CmdWideStartRecord, cam))
return err
}
// StopRecord stops video recording.
func (t *Telescope) StopRecord(cam Camera) error {
_, err := t.sendEmpty(cameraCmd(CmdTeleStopRecord, CmdWideStopRecord, cam))
return err
}
// SetExposure sets the exposure index on the camera.
func (t *Telescope) SetExposure(cam Camera, index int32) error {
_, err := t.send(cameraCmd(CmdTeleSetExp, CmdWideSetExp, cam), &pb.ReqSetExp{Index: index})
return err
}
// SetGain sets the gain index on the camera.
func (t *Telescope) SetGain(cam Camera, index int32) error {
_, err := t.send(cameraCmd(CmdTeleSetGain, CmdWideSetGain, cam), &pb.ReqSetGain{Index: index})
return err
}
// GetAllParams retrieves all camera parameters.
// NOTE: this command may not receive a type=3 reply on some firmwares.
// The params are also embedded in the GetDeviceState response.
func (t *Telescope) GetAllParams(cam Camera) (*pb.ResGetAllParams, error) {
pkt, err := t.sendEmpty(cameraCmd(CmdTeleGetAllParams, CmdWideGetAllParams, cam))
if err != nil {
return nil, err
}
resp := &pb.ResGetAllParams{}
return resp, decodeResponse(pkt, resp)
}
// --- Motor commands ---
// SlewJoystick sends a joystick vector for manual slewing (fire-and-forget).
func (t *Telescope) SlewJoystick(angle, length float64) error {
return t.sendNotify(CmdMotorJoystick, &pb.ReqMotorServiceJoystick{
VectorAngle: angle,
VectorLength: length,
})
}
// SlewJoystickFixedAngle sends a fixed-angle joystick vector.
func (t *Telescope) SlewJoystickFixedAngle(angle, length float64) error {
return t.sendNotify(CmdMotorJoystickFixedAngle, &pb.ReqMotorServiceJoystickFixedAngle{
VectorAngle: angle,
VectorLength: length,
})
}
// MotorStop stops a motor by ID (0=RA/az, 1=DEC/alt). Fire-and-forget —
// the telescope does not ack motor commands with a same-cmd response.
func (t *Telescope) MotorStop(motorID int32) error {
return t.sendNotify(CmdMotorStop, &pb.ReqMotorStop{Id: motorID})
}
// MotorJoystickStop stops joystick movement. Fire-and-forget.
func (t *Telescope) MotorJoystickStop() error {
return t.sendNotify(CmdMotorJoystickStop, &pb.ReqMotorServiceJoystickStop{})
}
// MotorRunTo moves a motor to a target position at a given speed.
// Fire-and-forget — verify via GetDeviceState after sending.
func (t *Telescope) MotorRunTo(id int32, endPos, speed float64) error {
return t.sendNotify(CmdMotorRun, &pb.ReqMotorRunTo{
Id: id,
EndPosition: endPos,
Speed: speed,
})
}
// MotorReset resets a motor (finds home via limit switch).
// cmd 14003 is inferred (not in WsCmd enum). direction: false=back, true=forward.
func (t *Telescope) MotorReset(id int32, direction bool) error {
return t.sendNotify(CmdMotorReset, &pb.ReqMotorReset{Id: id, Direction: direction})
}
// MotorGetPosition queries a motor's current position.
// cmd 14001 is inferred. May timeout if firmware doesn't support it.
func (t *Telescope) MotorGetPosition(id int32) (*pb.ResMotorPosition, error) {
pkt, err := t.send(CmdMotorGetPosition, &pb.ReqMotorGetPosition{Id: id})
if err != nil {
return nil, err
}
resp := &pb.ResMotorPosition{}
return resp, decodeResponse(pkt, resp)
}
// GetFocusInfinityPos retrieves the user-defined infinity focus position.
func (t *Telescope) GetFocusInfinityPos() (int32, error) {
pkt, err := t.sendEmpty(CmdFocusGetUserInfinity)
if err != nil {
return 0, err
}
resp := &pb.ReqSetUserInfinityPos{}
if err := decodeResponse(pkt, resp); err != nil {
return 0, err
}
return resp.GetPos(), nil
}
// SetFocusInfinityPos saves the current focus position as user infinity.
func (t *Telescope) SetFocusInfinityPos(pos int32) error {
_, err := t.send(CmdFocusSetUserInfinity, &pb.ReqSetUserInfinityPos{Pos: pos})
return err
}
// --- Astro commands ---
// StartCalibration begins star calibration.
func (t *Telescope) StartCalibration() error {
_, err := t.sendEmpty(CmdAstroStartCalibration)
return err
}
// StopCalibration cancels calibration.
func (t *Telescope) StopCalibration() error {
_, err := t.sendEmpty(CmdAstroStopCalibration)
return err
}
// GotoDSO slews to a deep-sky object by RA/Dec coordinates.
func (t *Telescope) GotoDSO(ra, dec float64, name string) error {
_, err := t.send(CmdAstroStartGotoDSO, &pb.ReqGotoDSO{Ra: ra, Dec: dec, TargetName: name})
return err
}
// GotoSolar slews to a solar system object by index.
func (t *Telescope) GotoSolar(index int32) error {
_, err := t.send(CmdAstroStartGotoSolar, &pb.ReqGotoSolarSystem{Index: index})
return err
}
// StopGoto aborts a GoTo operation.
func (t *Telescope) StopGoto() error {
_, err := t.sendEmpty(CmdAstroStopGoto)
return err
}
// StartLiveStacking begins live-stacking capture.
func (t *Telescope) StartLiveStacking() error {
_, err := t.sendEmpty(CmdAstroStartLiveStacking)
return err
}
// StopLiveStacking stops live-stacking.
func (t *Telescope) StopLiveStacking() error {
_, err := t.sendEmpty(CmdAstroStopLiveStacking)
return err
}
// StartEqSolving begins equatorial solving (polar alignment).
func (t *Telescope) StartEqSolving() error {
_, err := t.sendEmpty(CmdAstroStartEqSolving)
return err
}
// StopEqSolving stops equatorial solving.
func (t *Telescope) StopEqSolving() error {
_, err := t.sendEmpty(CmdAstroStopEqSolving)
return err
}
// --- Focus commands ---
// AutoFocus triggers autofocus.
func (t *Telescope) AutoFocus() error {
_, err := t.sendEmpty(CmdFocusAutoFocus)
return err
}
// ManualSingleStepFocus moves the focuser a single step (direction 0=in, 1=out).
func (t *Telescope) ManualSingleStepFocus(direction uint32) error {
_, err := t.send(CmdFocusManualSingle, &pb.ReqManualSingleStepFocus{Direction: direction})
return err
}
// StartAstroAutoFocus starts astro autofocus.
func (t *Telescope) StartAstroAutoFocus() error {
_, err := t.sendEmpty(CmdFocusStartAstroAF)
return err
}
// StopAstroAutoFocus stops astro autofocus.
func (t *Telescope) StopAstroAutoFocus() error {
_, err := t.sendEmpty(CmdFocusStopAstroAF)
return err
}
// --- Track commands ---
// StartTracking starts tracking an object at the given coordinates.
func (t *Telescope) StartTracking(x, y, w, h, camID int32) error {
_, err := t.send(CmdTrackStart, &pb.ReqStartTrack{
X: x, Y: y, W: w, H: h, CamId: camID,
})
return err
}
// StopTracking stops tracking.
func (t *Telescope) StopTracking() error {
_, err := t.sendEmpty(CmdTrackStop)
return err
}
// --- System commands ---
// SetTime syncs the telescope clock.
func (t *Telescope) SetTime(unixSec uint64, tzOffset float64) error {
_, err := t.send(CmdSystemSetTime, &pb.ReqSetTime{
Timestamp: unixSec,
TimezoneOffset: tzOffset,
})
return err
}
// SetLocation sets the observing location.
func (t *Telescope) SetLocation(lat, lng, alt float64) error {
_, err := t.send(CmdSystemSetLocation, &pb.ReqSetLocation{
Latitude: lat,
Longitude: lng,
Altitude: alt,
Enable: true,
})
return err
}
// --- Power / RGB commands ---
// RGBOn turns on the RGB ring light. Fire-and-forget — no type=3 reply.
// Verify via GetDeviceState().DeviceStateInfo.RgbState.
func (t *Telescope) RGBOn() error {
return t.sendNotify(CmdRGBOn, &pb.ReqMotorServiceJoystickStop{})
}
// RGBOff turns off the RGB ring light. Fire-and-forget.
func (t *Telescope) RGBOff() error {
return t.sendNotify(CmdRGBOff, &pb.ReqMotorServiceJoystickStop{})
}
// PowerDown powers off the telescope. Fire-and-forget.
func (t *Telescope) PowerDown() error {
return t.sendNotify(CmdPowerDown, &pb.ReqMotorServiceJoystickStop{})
}
// Reboot reboots the telescope. Fire-and-forget.
func (t *Telescope) Reboot() error {
return t.sendNotify(CmdReboot, &pb.ReqMotorServiceJoystickStop{})
}
// --- Task Center ---
// GetDeviceState queries the current device state.
func (t *Telescope) GetDeviceState() (*pb.ResGetDeviceStateInfo, error) {
pkt, err := t.sendEmpty(CmdTaskGetDeviceState)
if err != nil {
return nil, err
}
resp := &pb.ResGetDeviceStateInfo{}
return resp, decodeResponse(pkt, resp)
}
// SwitchShootingMode switches the active shooting mode.
func (t *Telescope) SwitchShootingMode(modeID int32) error {
_, err := t.send(CmdTaskSwitchMode, &pb.ReqSwitchShootingMode{Mode: modeID})
return err
}

View 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 (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
}
}

View File

@ -0,0 +1,243 @@
package api
// Module IDs (WsModuleId enum ordinals, derived from WsCmd.getModuleId ranges).
const (
ModuleNone = 0
ModuleCameraTele = 1
ModuleCameraWide = 2
ModuleAstro = 3
ModuleSystem = 4
ModuleRGBPower = 5
ModuleMotor = 6
ModuleTrack = 7
ModuleFocus = 8
ModuleNotify = 9
ModulePanorama = 10
ModuleITips = 11
ModuleShootingSchedule = 13
ModuleTaskCenter = 14
ModuleParam = 15
ModuleVoiceAssistant = 16
ModuleCameraGuide = 17
ModuleDevice = 18
)
// ModuleIDForCmd derives the module_id from a command ID using the same
// range logic as WsCmd.getModuleId() in the original APK.
func ModuleIDForCmd(cmd uint32) uint32 {
switch {
case cmd >= 10000 && cmd < 10500:
return ModuleCameraTele
case cmd >= 11000 && cmd < 11500:
return ModuleAstro
case cmd >= 12000 && cmd < 12500:
return ModuleCameraWide
case cmd >= 13000 && cmd < 13300:
return ModuleSystem
case cmd >= 13500 && cmd < 13800:
return ModuleRGBPower
case cmd >= 14000 && cmd < 14500:
return ModuleMotor
case cmd >= 14800 && cmd < 14900:
return ModuleTrack
case cmd >= 15000 && cmd < 15200:
return ModuleFocus
case cmd >= 15200 && cmd < 15500:
return ModuleNotify
case cmd >= 15500 && cmd < 15600:
return ModulePanorama
case cmd >= 15700 && cmd < 15800:
return ModuleITips
case cmd >= 16100 && cmd < 16400:
return ModuleShootingSchedule
case cmd >= 16400 && cmd < 16600:
return ModuleTaskCenter
case cmd >= 16700 && cmd < 16800:
return ModuleParam
case cmd >= 16800 && cmd < 16900:
return ModuleVoiceAssistant
case cmd >= 17000 && cmd < 17100:
return ModuleDevice
default:
return ModuleNone
}
}
// Command IDs — Camera Tele (MODULE_CAMERA_TELE, 1000010499).
const (
CmdTeleOpenCamera = 10000
CmdTeleCloseCamera = 10001
CmdTelePhotograph = 10002
CmdTeleBurst = 10003
CmdTeleStopBurst = 10004
CmdTeleStartRecord = 10005
CmdTeleStopRecord = 10006
CmdTeleSetExpMode = 10007
CmdTeleGetExpMode = 10008
CmdTeleSetExp = 10009
CmdTeleGetExp = 10010
CmdTeleSetGainMode = 10011
CmdTeleGetGainMode = 10012
CmdTeleSetGain = 10013
CmdTeleGetGain = 10014
CmdTeleSetBrightness = 10015
CmdTeleGetBrightness = 10016
CmdTeleSetContrast = 10017
CmdTeleGetContrast = 10018
CmdTeleSetSaturation = 10019
CmdTeleGetSaturation = 10020
CmdTeleSetHue = 10021
CmdTeleGetHue = 10022
CmdTeleSetSharpness = 10023
CmdTeleGetSharpness = 10024
CmdTeleSetWBMode = 10025
CmdTeleGetWBMode = 10026
CmdTeleSetWBScene = 10027
CmdTeleGetWBScene = 10028
CmdTeleSetWBCT = 10029
CmdTeleGetWBCT = 10030
CmdTeleSetIRCut = 10031
CmdTeleGetIRCut = 10032
CmdTeleStartTimelapse = 10033
CmdTeleStopTimelapse = 10034
CmdTeleSetAllParams = 10035
CmdTeleGetAllParams = 10036
CmdTeleSetFeatureParam = 10037
CmdTeleGetAllFeatureParams = 10038
CmdTeleGetWorkingState = 10039
CmdTeleSetJpgQuality = 10040
CmdTelePhotoRaw = 10041
CmdTeleSetRtspBitrate = 10042
CmdTeleSwitchResolution = 10047
CmdTeleSwitchFramerate = 10048
CmdTeleSwitchCropRatio = 10049
CmdTeleSetPreviewQuality = 10050
)
// Camera Wide (MODULE_CAMERA_WIDE, 1200012499).
const (
CmdWideOpenCamera = 12000
CmdWideCloseCamera = 12001
CmdWideSetExpMode = 12002
CmdWideGetExpMode = 12003
CmdWideSetExp = 12004
CmdWideGetExp = 12005
CmdWideSetGain = 12006
CmdWideGetGain = 12007
CmdWidePhotograph = 12022
CmdWideBurst = 12023
CmdWideStopBurst = 12024
CmdWidePhotoRaw = 12029
CmdWideStartRecord = 12030
CmdWideStopRecord = 12031
CmdWideGetAllParams = 12027
CmdWideSetAllParams = 12028
)
// Astro (MODULE_ASTRO, 1100011499).
const (
CmdAstroStartCalibration = 11000
CmdAstroStopCalibration = 11001
CmdAstroStartGotoDSO = 11002
CmdAstroStartGotoSolar = 11003
CmdAstroStopGoto = 11004
CmdAstroStartLiveStacking = 11005
CmdAstroStopLiveStacking = 11006
CmdAstroStartRawDark = 11007
CmdAstroStopRawDark = 11008
CmdAstroGoLive = 11010
CmdAstroStartOneClickGoto = 11013
CmdAstroStopOneClickGoto = 11015
CmdAstroStartEqSolving = 11018
CmdAstroStopEqSolving = 11019
CmdAstroStartAiEnhance = 11029
CmdAstroStopAiEnhance = 11030
CmdAstroStartMosaic = 11031
CmdAstroStartSkyFinder = 11047
CmdAstroStopSkyFinder = 11048
)
// System (MODULE_SYSTEM, 1300013299).
const (
CmdSystemSetTime = 13000
CmdSystemSetTimeZone = 13001
CmdSystemSetMtpMode = 13002
CmdSystemSetCpuMode = 13003
CmdSystemSetMasterLock = 13004
CmdSystemSetLocation = 13010
)
// RGB Power (MODULE_RGB_POWER, 1350013799).
const (
CmdRGBOn = 13500
CmdRGBOff = 13501
CmdPowerDown = 13502
CmdReboot = 13505
)
// Motor constants — extended (14001-14005 are inferred from gaps)
const (
CmdMotorRun = 14000
CmdMotorGetPosition = 14001 // inferred: missing ID between RUN and STOP
CmdMotorStop = 14002
CmdMotorReset = 14003 // inferred: missing ID after STOP
CmdMotorChangeSpeed = 14004 // inferred
CmdMotorChangeDirection = 14005 // inferred
CmdMotorJoystick = 14006
CmdMotorJoystickFixedAngle = 14007
CmdMotorJoystickStop = 14008
CmdMotorDualCameraLinkage = 14009
)
// Track (MODULE_TRACK, 1480014899).
const (
CmdTrackStart = 14800
CmdTrackStop = 14801
CmdSentryStart = 14802
CmdSentryStop = 14803
CmdSwitchMainPreview = 14809
)
// Focus (MODULE_FOCUS, 1500015199).
const (
CmdFocusAutoFocus = 15000
CmdFocusManualSingle = 15001
CmdFocusStartManualCont = 15002
CmdFocusStopManualCont = 15003
CmdFocusStartAstroAF = 15004
CmdFocusStopAstroAF = 15005
CmdFocusGetUserInfinity = 15011
CmdFocusSetUserInfinity = 15012
)
// Panorama (MODULE_PANORAMA, 1550015599).
const (
CmdPanoStartGrid = 15500
CmdPanoStop = 15501
CmdPanoStartStitch = 15503
CmdPanoStopStitch = 15504
CmdPanoStartFraming = 15509
CmdPanoStopFraming = 15510
CmdPanoResetFraming = 15511
CmdPanoUpdateFramingRect = 15512
)
// Task Center (MODULE_TASK_CENTER, 1640016599).
const (
CmdTaskStartTask = 16400
CmdTaskStopTask = 16401
CmdTaskSwitchMode = 16402
CmdTaskSwitchTech = 16403
CmdTaskGetDeviceState = 16405
)
// Notify (MODULE_NOTIFY, 1520015399) — server-pushed, not requestable.
const (
NotifyTrackResult = 15225
NotifyStateCaptureRawDark = 15206
NotifyCalibrationResult = 15256
NotifyStateAstroGoto = 15211
NotifyTemperature = 15243
NotifySDCardInfo = 15203
NotifyPowerOff = 15229
)

View File

@ -0,0 +1,219 @@
package transport
import (
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
pb "github.com/antitbone/dwarfctl/proto"
"google.golang.org/protobuf/proto"
)
const (
WsMajorVersion = 2
WsMinorVersion = 3
WsPort = 9900
)
// MsgType mirrors WsMessageType: request=0, response=1, notification=2, reply=3.
type MsgType uint32
const (
MsgRequest MsgType = 0
MsgResponse MsgType = 1
MsgNotification MsgType = 2
MsgReply MsgType = 3
)
// Client is a WebSocket client for the DWARF telescope control plane.
type Client struct {
conn *websocket.Conn
clientID string
deviceID uint32
mu sync.Mutex
pending map[uint32]chan *pb.WsPacket
notifyChs []chan *pb.WsPacket
done chan struct{}
Debug bool
}
// NewClient creates a client with the given client_id and device_id.
func NewClient(clientID string, deviceID uint32) *Client {
return &Client{
clientID: clientID,
deviceID: deviceID,
pending: make(map[uint32]chan *pb.WsPacket),
done: make(chan struct{}),
}
}
// Connect opens the WebSocket to the telescope.
func (c *Client) Connect(ip string) error {
url := fmt.Sprintf("ws://%s:%d/?client_id=%s", ip, WsPort, c.clientID)
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("ws dial %s: %w", url, err)
}
c.conn = conn
go c.readLoop()
return nil
}
// Close shuts down the connection.
func (c *Client) Close() error {
close(c.done)
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// IsConnected returns true if the WebSocket is open.
func (c *Client) IsConnected() bool {
return c.conn != nil
}
// readLoop continuously reads WsPacket frames and dispatches them.
func (c *Client) readLoop() {
for {
select {
case <-c.done:
return
default:
}
_, data, err := c.conn.ReadMessage()
if err != nil {
if c.Debug {
log.Printf("[WS] read error: %v", err)
}
return
}
pkt := &pb.WsPacket{}
if err := proto.Unmarshal(data, pkt); err != nil {
if c.Debug {
log.Printf("[WS] unmarshal error: %v (raw %d bytes)", err, len(data))
}
continue
}
if c.Debug {
log.Printf("[WS] recv cmd=%d module=%d type=%d data=%d bytes",
pkt.GetCmd(), pkt.GetModuleId(), pkt.GetType(), len(pkt.GetData()))
}
// Dispatch based on type. Many telescopes send responses with
// type=0 (default proto3 value) instead of type=1. So we try
// response dispatch for type 0, 1, and 3.
switch MsgType(pkt.GetType()) {
case MsgNotification:
c.dispatchNotification(pkt)
// Also try response dispatch — some notifications double as acks
c.dispatchResponse(pkt)
default:
// Covers type=0 (unset), type=1 (response), type=3 (reply)
c.dispatchResponse(pkt)
c.dispatchNotification(pkt)
}
}
}
// dispatchResponse delivers a response to the pending request waiter.
func (c *Client) dispatchResponse(pkt *pb.WsPacket) {
c.mu.Lock()
ch, ok := c.pending[pkt.GetCmd()]
if ok {
delete(c.pending, pkt.GetCmd())
}
c.mu.Unlock()
if ok {
ch <- pkt
}
}
// dispatchNotification fans out to all subscribers.
func (c *Client) dispatchNotification(pkt *pb.WsPacket) {
c.mu.Lock()
chs := make([]chan *pb.WsPacket, len(c.notifyChs))
copy(chs, c.notifyChs)
c.mu.Unlock()
for _, ch := range chs {
select {
case ch <- pkt:
default:
}
}
}
// SubscribeNotifications returns a channel for receiving NOTIFY packets.
func (c *Client) SubscribeNotifications() chan *pb.WsPacket {
ch := make(chan *pb.WsPacket, 64)
c.mu.Lock()
c.notifyChs = append(c.notifyChs, ch)
c.mu.Unlock()
return ch
}
// Send sends a raw command with the given module_id and cmd, carrying
// the serialized inner proto message as data. It returns the response packet.
func (c *Client) Send(moduleID uint32, cmd uint32, data []byte, timeout time.Duration) (*pb.WsPacket, error) {
pkt := &pb.WsPacket{
MajorVersion: WsMajorVersion,
MinorVersion: WsMinorVersion,
DeviceId: c.deviceID,
ModuleId: moduleID,
Cmd: cmd,
Type: uint32(MsgRequest),
Data: data,
ClientId: c.clientID,
}
raw, err := proto.Marshal(pkt)
if err != nil {
return nil, fmt.Errorf("marshal WsPacket: %w", err)
}
respCh := make(chan *pb.WsPacket, 1)
c.mu.Lock()
c.pending[cmd] = respCh
c.mu.Unlock()
if err := c.conn.WriteMessage(websocket.BinaryMessage, raw); err != nil {
c.mu.Lock()
delete(c.pending, cmd)
c.mu.Unlock()
return nil, fmt.Errorf("ws write: %w", err)
}
select {
case resp := <-respCh:
return resp, nil
case <-time.After(timeout):
c.mu.Lock()
delete(c.pending, cmd)
c.mu.Unlock()
return nil, fmt.Errorf("timeout waiting for response to cmd %d", cmd)
case <-c.done:
return nil, fmt.Errorf("connection closed")
}
}
// SendNotify sends a command without waiting for a response (fire-and-forget).
func (c *Client) SendNotify(moduleID uint32, cmd uint32, data []byte) error {
pkt := &pb.WsPacket{
MajorVersion: WsMajorVersion,
MinorVersion: WsMinorVersion,
DeviceId: c.deviceID,
ModuleId: moduleID,
Cmd: cmd,
Type: uint32(MsgRequest),
Data: data,
ClientId: c.clientID,
}
raw, err := proto.Marshal(pkt)
if err != nil {
return err
}
return c.conn.WriteMessage(websocket.BinaryMessage, raw)
}

View File

@ -0,0 +1,223 @@
package transport
import (
"testing"
"time"
pb "github.com/antitbone/dwarfctl/proto"
"google.golang.org/protobuf/proto"
)
// TestEnvelopeRoundTrip verifies that a WsPacket envelope survives a
// marshal → unmarshal round-trip with all fields intact. This is the core
// guarantee for every command sent to the telescope.
func TestEnvelopeRoundTrip(t *testing.T) {
original := &pb.WsPacket{
MajorVersion: WsMajorVersion,
MinorVersion: WsMinorVersion,
DeviceId: 1,
ModuleId: 6, // MODULE_MOTOR
Cmd: 14006,
Type: uint32(MsgRequest),
Data: []byte{0x08, 0x2a}, // some inner proto bytes
ClientId: "test-client-007",
}
raw, err := proto.Marshal(original)
if err != nil {
t.Fatalf("marshal WsPacket: %v", err)
}
if len(raw) == 0 {
t.Fatal("marshaled envelope is empty")
}
decoded := &pb.WsPacket{}
if err := proto.Unmarshal(raw, decoded); err != nil {
t.Fatalf("unmarshal WsPacket: %v", err)
}
if decoded.GetMajorVersion() != original.GetMajorVersion() {
t.Errorf("major_version: got %d, want %d", decoded.GetMajorVersion(), original.GetMajorVersion())
}
if decoded.GetMinorVersion() != original.GetMinorVersion() {
t.Errorf("minor_version: got %d, want %d", decoded.GetMinorVersion(), original.GetMinorVersion())
}
if decoded.GetDeviceId() != original.GetDeviceId() {
t.Errorf("device_id: got %d, want %d", decoded.GetDeviceId(), original.GetDeviceId())
}
if decoded.GetModuleId() != original.GetModuleId() {
t.Errorf("module_id: got %d, want %d", decoded.GetModuleId(), original.GetModuleId())
}
if decoded.GetCmd() != original.GetCmd() {
t.Errorf("cmd: got %d, want %d", decoded.GetCmd(), original.GetCmd())
}
if decoded.GetType() != original.GetType() {
t.Errorf("type: got %d, want %d", decoded.GetType(), original.GetType())
}
if decoded.GetClientId() != original.GetClientId() {
t.Errorf("client_id: got %q, want %q", decoded.GetClientId(), original.GetClientId())
}
if string(decoded.GetData()) != string(original.GetData()) {
t.Errorf("data: got %x, want %x", decoded.GetData(), original.GetData())
}
}
// TestEnvelopeWithInnerProto verifies that inner proto messages (the Data
// field) encode and decode correctly within the envelope.
func TestEnvelopeWithInnerProto(t *testing.T) {
inner := &pb.ReqMotorServiceJoystick{
VectorAngle: 90.5,
VectorLength: 0.75,
}
innerBytes, err := proto.Marshal(inner)
if err != nil {
t.Fatalf("marshal inner: %v", err)
}
envelope := &pb.WsPacket{
MajorVersion: WsMajorVersion,
MinorVersion: WsMinorVersion,
DeviceId: 1,
ModuleId: 6,
Cmd: 14006,
Type: uint32(MsgRequest),
Data: innerBytes,
ClientId: "dwarfctl-test",
}
raw, err := proto.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
decoded := &pb.WsPacket{}
if err := proto.Unmarshal(raw, decoded); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
// Decode the inner message
innerDecoded := &pb.ReqMotorServiceJoystick{}
if err := proto.Unmarshal(decoded.GetData(), innerDecoded); err != nil {
t.Fatalf("unmarshal inner: %v", err)
}
if innerDecoded.GetVectorAngle() != 90.5 {
t.Errorf("vector_angle: got %f, want 90.5", innerDecoded.GetVectorAngle())
}
if innerDecoded.GetVectorLength() != 0.75 {
t.Errorf("vector_length: got %f, want 0.75", innerDecoded.GetVectorLength())
}
}
// TestNewClientDefaults verifies that NewClient stores the client_id and
// device_id and initializes the pending map.
func TestNewClientDefaults(t *testing.T) {
c := NewClient("my-client", 2)
if c.clientID != "my-client" {
t.Errorf("clientID: got %q, want %q", c.clientID, "my-client")
}
if c.deviceID != 2 {
t.Errorf("deviceID: got %d, want 2", c.deviceID)
}
if c.pending == nil {
t.Error("pending map is nil")
}
if c.done == nil {
t.Error("done channel is nil")
}
if c.IsConnected() {
t.Error("should not be connected before Connect()")
}
}
// TestMsgTypeConstants verifies the wire-format type values match the
// original enum (request=0, response=1, notification=2, reply=3).
func TestMsgTypeConstants(t *testing.T) {
cases := map[MsgType]uint32{
MsgRequest: 0,
MsgResponse: 1,
MsgNotification: 2,
MsgReply: 3,
}
for mt, expected := range cases {
if uint32(mt) != expected {
t.Errorf("MsgType %d: got value %d, want %d", mt, uint32(mt), expected)
}
}
}
// TestSubscribeNotifications verifies the notification fan-out works:
// multiple subscribers all receive the same packet.
func TestSubscribeNotifications(t *testing.T) {
c := NewClient("test", 1)
ch1 := c.SubscribeNotifications()
ch2 := c.SubscribeNotifications()
pkt := &pb.WsPacket{Cmd: 15243, Type: uint32(MsgNotification)}
c.dispatchNotification(pkt)
// Both channels should receive a copy
select {
case got := <-ch1:
if got.GetCmd() != 15243 {
t.Errorf("ch1 cmd: got %d, want 15243", got.GetCmd())
}
case <-time.After(100 * time.Millisecond):
t.Fatal("ch1 did not receive notification")
}
select {
case got := <-ch2:
if got.GetCmd() != 15243 {
t.Errorf("ch2 cmd: got %d, want 15243", got.GetCmd())
}
case <-time.After(100 * time.Millisecond):
t.Fatal("ch2 did not receive notification")
}
}
// TestEmptyDataEnvelope verifies that commands with no payload (empty Data)
// survive a round-trip.
func TestEmptyDataEnvelope(t *testing.T) {
original := &pb.WsPacket{
MajorVersion: WsMajorVersion,
MinorVersion: WsMinorVersion,
DeviceId: 1,
ModuleId: 3, // MODULE_ASTRO
Cmd: 11000,
Type: uint32(MsgRequest),
Data: nil,
ClientId: "dwarfctl",
}
raw, err := proto.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
decoded := &pb.WsPacket{}
if err := proto.Unmarshal(raw, decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.GetCmd() != 11000 {
t.Errorf("cmd: got %d, want 11000", decoded.GetCmd())
}
if len(decoded.GetData()) != 0 {
t.Errorf("data should be empty, got %d bytes", len(decoded.GetData()))
}
}
// TestWsPort verifies the port constant matches the reverse-engineered value.
func TestWsPort(t *testing.T) {
if WsPort != 9900 {
t.Errorf("WsPort: got %d, want 9900", WsPort)
}
}
// TestVersionConstants verifies the protocol version constants.
func TestVersionConstants(t *testing.T) {
if WsMajorVersion != 2 {
t.Errorf("WsMajorVersion: got %d, want 2", WsMajorVersion)
}
if WsMinorVersion != 3 {
t.Errorf("WsMinorVersion: got %d, want 3", WsMinorVersion)
}
}