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
|
||||
)
|
||||
219
dwarfctl/internal/transport/client.go
Normal file
219
dwarfctl/internal/transport/client.go
Normal 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)
|
||||
}
|
||||
223
dwarfctl/internal/transport/client_test.go
Normal file
223
dwarfctl/internal/transport/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user