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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
243
dwarfctl/internal/api/commands.go
Normal file
243
dwarfctl/internal/api/commands.go
Normal 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, 10000–10499).
|
||||
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, 12000–12499).
|
||||
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, 11000–11499).
|
||||
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, 13000–13299).
|
||||
const (
|
||||
CmdSystemSetTime = 13000
|
||||
CmdSystemSetTimeZone = 13001
|
||||
CmdSystemSetMtpMode = 13002
|
||||
CmdSystemSetCpuMode = 13003
|
||||
CmdSystemSetMasterLock = 13004
|
||||
CmdSystemSetLocation = 13010
|
||||
)
|
||||
|
||||
// RGB Power (MODULE_RGB_POWER, 13500–13799).
|
||||
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, 14800–14899).
|
||||
const (
|
||||
CmdTrackStart = 14800
|
||||
CmdTrackStop = 14801
|
||||
CmdSentryStart = 14802
|
||||
CmdSentryStop = 14803
|
||||
CmdSwitchMainPreview = 14809
|
||||
)
|
||||
|
||||
// Focus (MODULE_FOCUS, 15000–15199).
|
||||
const (
|
||||
CmdFocusAutoFocus = 15000
|
||||
CmdFocusManualSingle = 15001
|
||||
CmdFocusStartManualCont = 15002
|
||||
CmdFocusStopManualCont = 15003
|
||||
CmdFocusStartAstroAF = 15004
|
||||
CmdFocusStopAstroAF = 15005
|
||||
CmdFocusGetUserInfinity = 15011
|
||||
CmdFocusSetUserInfinity = 15012
|
||||
)
|
||||
|
||||
// Panorama (MODULE_PANORAMA, 15500–15599).
|
||||
const (
|
||||
CmdPanoStartGrid = 15500
|
||||
CmdPanoStop = 15501
|
||||
CmdPanoStartStitch = 15503
|
||||
CmdPanoStopStitch = 15504
|
||||
CmdPanoStartFraming = 15509
|
||||
CmdPanoStopFraming = 15510
|
||||
CmdPanoResetFraming = 15511
|
||||
CmdPanoUpdateFramingRect = 15512
|
||||
)
|
||||
|
||||
// Task Center (MODULE_TASK_CENTER, 16400–16599).
|
||||
const (
|
||||
CmdTaskStartTask = 16400
|
||||
CmdTaskStopTask = 16401
|
||||
CmdTaskSwitchMode = 16402
|
||||
CmdTaskSwitchTech = 16403
|
||||
CmdTaskGetDeviceState = 16405
|
||||
)
|
||||
|
||||
// Notify (MODULE_NOTIFY, 15200–15399) — server-pushed, not requestable.
|
||||
const (
|
||||
NotifyTrackResult = 15225
|
||||
NotifyStateCaptureRawDark = 15206
|
||||
NotifyCalibrationResult = 15256
|
||||
NotifyStateAstroGoto = 15211
|
||||
NotifyTemperature = 15243
|
||||
NotifySDCardInfo = 15203
|
||||
NotifyPowerOff = 15229
|
||||
)
|
||||
Reference in New Issue
Block a user