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
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
#!/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()
|