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:
150
analysis/extract_protos.py
Normal file
150
analysis/extract_protos.py
Normal 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()
|
||||
Reference in New Issue
Block a user