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

109
AGENTS.md Normal file
View File

@ -0,0 +1,109 @@
# AGENTS.md — DWARFLAB APK reverse-engineering workspace
This directory reverse-engineers the DWARFLAB Android app to document the
DWARF II telescope control protocol for an open-source client.
## What's here
```
DWARFLAB.apk original APK (v3.4.0, com.convergence.dwarflab)
tools/jadx/ jadx 1.5.6 decompiler (run tools/jadx/bin/jadx)
extracted/raw/ unzip of the APK (dex, assets, lib/*.so, manifest)
extracted/jadx/ jadx decompiled Java sources (23968 classes)
analysis/
extract_protos.py regenerates clean .proto from jadx *Proto.java
protos/ 17 recovered .proto files (382 messages) ← THE API
CMD_TABLE.md all 323 command ids ↔ modules (auto-generated)
API_REFERENCE.md full protocol spec (read this first)
```
**Start with `analysis/API_REFERENCE.md`** — it explains BLE discovery →
WebSocket control (port 9900) → RTSP preview, the `WsPacket` envelope, and how
command routing works.
## The protocol in one paragraph
Phone discovers telescope over **BLE** (`ble.proto`, gets IP/SSID/PSK via
`DwarfEcho`), joins its Wi-Fi, opens `ws://<ip>:9900/?client_id=<id>`, then sends
**binary protobuf `WsPacket`** frames `{major=2, minor=3, device_id, module_id,
cmd, type, data, client_id}`. `cmd` (1000017099) selects the operation;
`module_id` is *derived* from `cmd` by range; `data` is a serialized inner proto
(`Req*`/`Res*`). Live preview is a separate **RTSP** stream (`stream0`).
## Commands for working here
```bash
# Re-decompile (only if APK changes)
tools/jadx/bin/jadx --show-bad-code --deobf -d extracted/jadx DWARFLAB.apk
# Regenerate .proto files after re-decompile
python3 analysis/extract_protos.py \
extracted/jadx/sources/com/convergence/dwarflab/proto analysis/protos
# Regenerate the command table
python3 -c "…" # see the inline script that built analysis/CMD_TABLE.md
```
Both extractors are deterministic — re-running after a new APK version produces a
clean diff for tracking protocol changes.
## Where things live in the decompiled source
All paths relative to `extracted/jadx/sources/`.
| Looking for… | Go to… |
|---|---|
| Protobuf message definitions | `com/convergence/dwarflab/proto/*Proto.java` → run extractor → `analysis/protos/` |
| All command IDs | `com/convergence/dwarflab/data/bean/p021ws/WsCmd.java` |
| Module enum (camera/astro/motor…) | `…/p021ws/WsModuleId.java` |
| Message type enum (req/resp/notify) | `…/p021ws/WsMessageType.java` |
| Request interface + sender | `…/p021ws/request/WsMessageReq.java`, `p000/d49.java` |
| WsPacket envelope builder | `p000/e49.java` (`C9312a.m36361a`) |
| WebSocket connection / URL / port | `p000/v55.java` (field `f43155b`, method `m55420t`) |
| OkHttp WebSocket wrapper | `p000/b49.java` |
| Inbound dispatch / req-resp matching | `com/convergence/dwarflab/data/websocket/WsRequestHandle.java`, `WsResponseHandle.java` |
| Per-module handlers | `com/convergence/dwarflab/data/websocket/<module>/*WsResponseHandle.java` |
| RTSP preview player | `com/convergence/dwarflab/media/RtspPlayerView.java`, `…/activity/capture/CaptureActivity.java` |
| BLE handshake | `com/convergence/dwarflab/data/bluetooth/`, `…/data/bean/ble/` |
| Camera param value maps | APK `assets/params_range.json`, `assets/shoot_plan_config.json` |
## Gotchas
- **`p000/` is the obfuscated package.** R8 minification collapsed most app
internals into 14 char names there (`v55`, `b49`, `e49`, `d49`, `jc0`…).
Kotlin metadata comments (`@Metadata(d2={…})`) often still carry the original
readable names — read them when a class is opaque.
- **`module_id` is never stored** on a command — it is computed by range checks
in `WsCmd.getModuleId()`. Don't grep for "set module id"; read that method.
- **Some command IDs appear as symbolic constants** in `WsCmd.java` (jadx didn't
inline them). Resolved values: `IMediaPlayer.MEDIA_INFO_*` = 10004/10006/10008/10009,
`RequestManager.NOTIFY_CONNECT_*` = 10011/10012/10013, `ComposeVersion.version`
= 13000, `FirebaseError.ERROR_INVALID_CUSTOM_TOKEN` = 17000,
`ERROR_CUSTOM_TOKEN_MISMATCH` = 17002. These are already resolved in
`CMD_TABLE.md`.
- **Notifications outnumber requests.** ~130 of the 323 cmds are `CMD_NOTIFY_*`
(1520015303). They are server-pushed (`type=2`), not requestable. An OSS
client must subscribe to them for live state.
- **Two telescopes share one app.** "DWARF II" (current, id 1) and "Bilbo"
(codename for the next model, likely DWARF III) appear in `shoot_plan_config.json`
and `www/modules/eq/indexBilbo.html`. Capability detection should key off
`StationModel` from `DwarfEcho`, not hardcode.
- **`type` field (WsMessageType):** request=0, response=1, notification=2,
reply=3. Responses usually reuse the request's `cmd`; match on `cmd`.
- **RTSP uses TCP.** The app sets `rtsp_transport=tcp` (`RtspPlayerView.java:195`).
Path is `rtsp://<ip>/<stream_selector>/stream0`.
- **jadx reports ~294 decompile errors** out of ~24k classes — expected and
irrelevant for the proto/command extraction (those come from clean descriptor
data, not decompiled bodies).
## Conventions when extending this analysis
- Keep generated artifacts regenerable: `extract_protos.py` + the CMD_TABLE
inline script read only from `extracted/jadx`. Never hand-edit `protos/` or
`CMD_TABLE.md`.
- When a command's payload type is unclear, find the `WsMessageReq` subclass in
`data/bean/p021ws/request/``getMessage()` returns the proto, `getCmd()`
returns the `WsCmd`.
- Cloud/relay features (`app.dwarflabapp.com/uls`, NetEase IM `nim/`, Huawei HMS,
Firebase analytics, Meizu push, ByteDance ASR) are NOT part of telescope
control — ignore them unless working on remote/cloud access.