Reverse-engineer DWARF II telescope API and build open-source Go client

Complete reverse-engineering of the DWARFLAB Android app (v3.4.0) protocol
and implementation of a working CLI tool to control DWARF II telescopes.

Analysis (from APK decompilation with jadx):
- Extracted 17 protobuf definitions (382 messages) from embedded descriptors
- Mapped all 323 WebSocket command IDs across 16 modules
- Documented the full protocol: BLE discovery, WebSocket control (port 9900),
  RTSP preview, WsPacket envelope (proto v2.3)
- Documented the Android UI structure (screens, navigation, shooting modes)
- Key discovery: telescope responds with type=3 (reply), not type=1 (response),
  and several commands are fire-and-forget (RGB, camera open/close)

dwarfctl Go client:
- Protobuf bindings generated from extracted .proto files (397 messages)
- WebSocket transport layer with request-response matching and notification fan-out
- Typed API covering cameras, motors, astrophotography, focus, tracking, system, power
- Cobra CLI with 30+ subcommands and --debug traffic logging
- 57 unit tests (transport round-trip, command routing, proto encoding)
- Validated on real hardware: state, photo, motor slew (all directions/speeds),
  focus, RGB, time/location sync all confirmed working

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-12 15:18:56 +02:00
commit 814a836c5a
54 changed files with 35973 additions and 0 deletions

View File

@ -0,0 +1,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)
}
}