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

150
analysis/extract_protos.py Normal file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Extract serialized FileDescriptorProto bytes from jadx-generated *Proto.java
files and regenerate clean .proto definitions using the real protobuf library.
"""
import re
import sys
import os
from google.protobuf import descriptor_pb2
# proto3 -> no "optional" label; proto2 prints labels.
TYPE_MAP = {
1: 'double', 2: 'float', 3: 'int64', 4: 'uint64', 5: 'int32',
6: 'fixed64', 7: 'fixed32', 8: 'bool', 9: 'string', 11: 'message',
12: 'bytes', 13: 'uint32', 14: 'enum', 15: 'sfixed32', 16: 'sfixed64',
17: 'sint32', 18: 'sint64',
}
def field_type_str(f):
if f.type_name:
return f.type_name.lstrip('.')
return TYPE_MAP.get(f.type, f'scalar{f.type}')
def render_field(f, syntax):
ty = field_type_str(f)
label = ''
if f.label == descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED:
if f.options.packed:
label = 'packed repeated '
else:
label = 'repeated '
elif syntax == 'proto2' and f.label == descriptor_pb2.FieldDescriptorProto.LABEL_REQUIRED:
label = 'required '
elif syntax == 'proto2':
label = 'optional '
return f' {label}{ty} {f.name} = {f.number};'
def render_enum(e, indent):
lines = [f'{indent}enum {e.name} {{']
for v in e.value:
lines.append(f'{indent} {v.name} = {v.number};')
lines.append(f'{indent}}}')
return '\n'.join(lines)
def render_message(m, indent, syntax):
lines = [f'{indent}message {m.name} {{']
for oneof in m.oneof_decl:
lines.append(f'{indent} // oneof {oneof.name}')
for nested in m.enum_type:
lines.append(render_enum(nested, indent + ' '))
for nested in m.nested_type:
lines.append(render_message(nested, indent + ' ', syntax))
for f in m.field:
lines.append(render_field(f, syntax))
lines.append(f'{indent}}}')
return '\n'.join(lines)
def render_file(fd):
out = [f'// source: {fd.name}']
if fd.package:
out.append(f'package {fd.package};')
syntax = fd.syntax or 'proto3'
out.append(f'syntax = "{syntax}";')
if fd.dependency:
out.append('')
for dep in fd.dependency:
out.append(f'import "{dep}";')
out.append('')
for e in fd.enum_type:
out.append(render_enum(e, ''))
out.append('')
for m in fd.message_type:
out.append(render_message(m, '', syntax))
out.append('')
return '\n'.join(out)
DESC_RE = re.compile(
r'internalBuildGeneratedFileFrom\(new String\[\]\{(.*?)\}\s*,\s*new Descriptors',
re.DOTALL)
def decode_java_string_literal_body(lit):
"""Decode a Java string-literal body (between quotes) to raw bytes."""
out = bytearray()
i = 0; n = len(lit)
while i < n:
c = lit[i]
if c == '\\':
nxt = lit[i+1]
simple = {'n': 10, 'r': 13, 't': 9, '"': 34, "'": 39, '\\': 92, '0': 0, 'b': 8, 'f': 12}
if nxt in simple:
out.append(simple[nxt]); i += 2
elif nxt == 'u':
out += chr(int(lit[i+2:i+6], 16)).encode('latin-1', 'replace')
i += 6
else:
out.append(ord(nxt) & 0xff); i += 2
else:
out += c.encode('latin-1', 'replace'); i += 1
return bytes(out)
def extract_descriptor_bytes(java_path):
txt = open(java_path, encoding='utf-8', errors='replace').read()
m = DESC_RE.search(txt)
if not m:
return None
body = m.group(1)
lits = re.findall(r'"((?:[^"\\]|\\.)*)"', body)
# protobuf concatenates ALL adjacent string elements into ONE descriptor
combined = b''.join(decode_java_string_literal_body(l) for l in lits)
return combined if combined else None
def main():
proto_dir = sys.argv[1]
out_dir = sys.argv[2]
os.makedirs(out_dir, exist_ok=True)
total_msgs = 0
for fn in sorted(os.listdir(proto_dir)):
if not fn.endswith('Proto.java'):
continue
path = os.path.join(proto_dir, fn)
raw = extract_descriptor_bytes(path)
base = fn[:-5].replace('Proto', '')
if not raw:
print(f' [WARN] no descriptor in {fn}')
continue
try:
fd_set = descriptor_pb2.FileDescriptorSet()
fd = fd_set.file.add()
fd.MergeFromString(raw)
except Exception as ex:
# may be a FileDescriptorSet directly, or multiple
try:
fd_set.ParseFromString(raw)
if not fd_set.file:
raise
except Exception as ex2:
print(f' [ERR ] {fn}: {ex2}')
continue
out_path = os.path.join(out_dir, base + '.proto')
chunks = []
for one_fd in fd_set.file:
chunks.append(render_file(one_fd))
total_msgs += len(one_fd.message_type)
with open(out_path, 'w', encoding='utf-8') as f:
f.write('\n\n'.join(chunks) + '\n')
print(f' [OK] {fn} -> {base}.proto ({len(raw)} bytes, {len(fd_set.file)} file(s), {sum(len(x.message_type) for x in fd_set.file)} msgs)')
print(f'\nTotal messages recovered: {total_msgs}')
if __name__ == '__main__':
main()