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:
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