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:
395
dwarfctl/internal/api/client.go
Normal file
395
dwarfctl/internal/api/client.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user