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
110 lines
5.7 KiB
Markdown
110 lines
5.7 KiB
Markdown
# 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` (10000–17099) 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 1–4 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_*`
|
||
(15200–15303). 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.
|