# Skydroid Camera FPV — Control Protocol Documentation
Reverse-engineered from `com.skydroid.camerafpv` (APK class `SkydroidControl`, `Decode`, `Decode$DealThread`, `TCPClient`, `String2ByteArrayUtils`, `PipelineManager`).
This document is everything you need to talk to a Skydroid FPV camera on a wired/ethernet link from your own application.
---
## 1. Overview — Two Protocol Families
The app uses **two distinct text-based protocols** depending on the camera model:
| **`#TP` protocol** | `#TP...` | TCP or UDP (main control, esp. **UDP port 9002**) | Skydroid Control / Gimbal units (C10/C11/C12/C13/C14/C20, EPTZ) |
| **AT command protocol** | `AT+...` | Serial / auxiliary socket `ar8030_helper_socket` | Legacy / ESP-style auxiliary controller (LED, video, angle, version) |
The bulk of camera-FPV control (`SkydroidControl`) uses the **`#TP` protocol over UDP port 9002**, with TCP as an alternative transport.
---
## 2. Network Endpoints
- **UDP port `9002`** — hardcoded in `SkydroidControl.createConnect` (`createUDPPipeline(9002, host, 9002)`). This is the primary command channel.
- **TCP** — `TCPClient` connects to `host:port` where `port` defaults around **5000** (observed in string pool). Both UDP 9002 and TCP pipelines are written to in `sendData()`.
- **Camera IP** — typically on the wired subnet `192.168.144.x` (e.g. `192.168.144.108`, `.10`, `.101`, `.102`, `.11`, `.66`). `255.255.255.0` subnet mask.
- **`AT+` auxiliary** — separate socket binary `assets/ar8030_helper_socket` used by `AR8030HelperSocketClient`.
---
## 3. The `#TP` Protocol — Message Format
A `#TP` message is a **plain ASCII command string**, followed by a **2-char uppercase hex checksum**, then sent as UTF-8 bytes.
```
fullCommand = command + checksum
wireBytes = UTF8(fullCommand)
```
Where:
- `command` = one of the command strings in §4 (all start with `#TP`).
- `checksum` = 2 uppercase hex digits = `(sum of all bytes of command) % 256`.
### Checksum algorithm (from `SkydroidControl.getCrc`)
```python
def checksum(command: str) -> str:
total = sum(ord(c) for c in command) # sum of ASCII bytes
crc = total & 0xFF # low 8 bits (mod 256)
return "%02X" % crc # 2 uppercase hex digits
```
### Example
To send "take picture":
```text
command = "#TPUD2wCAP01"
checksum = %02X of sum=... of all bytes
full = "#TPUD2wCAP01" + checksum # e.g. "#TPUD2wCAP013A"
```
> Note: `getCrc` sums the ASCII bytes (as characters) and takes the low byte → `%02X`. It is **not** a polynomial CRC — it is a simple bytewise sum modulo 256, hex-encoded.
---
## 4. `#TP` Command Reference (extracted from `SkydroidControl` & string pool)
### PTZ / Gimbal movement — `SkydroidControl.PTZControl(PTZ)`
| `UP` | `#TPUG2wPTZ00` |
| `DOWN` | `#TPUG2wPTZ01` |
| `LEFT` | `#TPUG2wPTZ02` |
| `RIGHT` | `#TPUG2wPTZ03` |
| `STOP` | `#TPUG2wPTZ04` |
| `X_ADD` | `#TPUG2wPTZ05` |
| `X_REDUCE` | `#TPUG2wPTZ06` |
| `Y_ADD` | `#TPUG2wPTZ07` |
| `Y_REDUCE` | `#TPUG2wPTZ08` |
| `Z_ADD` | `#TPUG2wPTZ09` |
| `Z_REDUCE` | `#TPUG2wPTZ0A` |
| `FOCUS_ADD`| `#TPUG2wPTZ0B` |
| `FOCUS_REDUCE` | `#TPUG2wPTZ0C` |
| `H_CALIBRATION` (horizontal cal) | `#TPUG2wPTZ0D` |
| `V_CALIBRATION` (vertical cal) | `#TPUG2wPTZ0E` |
| `CALIBRATION` (full) | `#TPUG2wPTZ0F` |
| `FOLLOW_SWITCH` | `#TPUG2wPTZ10` |
| `BACK_MID` (return center) | `#TPUG2wPTZ11` |
| `INVERSION` | `#TPUG2wPTZ12` |
| `HOISTING` | `#TPUG2wPTZ13` |
| `LOCK_HEAD` | `#TPUG2wPTZ14` |
| `CLEAR_ADJUST`, `FOLLOW` | (also defined in enum) |
### Gimbal velocity / pan-tilt motion — `move(Move)`
| Y+ (pitch up, speed 0x10) | `#TPUG2wGSY10` |
| Y− (pitch down, speed 0xF0) | `#TPUG2wGSYF0` |
| X+ (pan right, speed 0x10) | `#TPUG2wGSP10` |
| X− (pan left, speed 0xF0) | `#TPUG2wGSPF0` |
### Gimbal variable speed — `PTZControlSpeed(xSpeed, ySpeed)`
Formed dynamically (values are **signed bytes** as signed hex):
```text
yCommand = "#TPUG2wGSY" + int2Hex(ySpeed) # int2Hex handles negative → +256
xCommand = "#TPUG2wGSP" + int2Hex(-xSpeed) # note negative x
```
`int2Hex(v)`: if `v < 0` add 256, then two uppercase hex digits of the byte. Valid range `-99..99`. Sends the command with the greater `|speed|` first (throttled to ≥ 40 ms via `lastControlTime`).
### Angle control (degrees) — `angleControl(AngleControl, degrees)`
Sends `short2Hex(degrees * 100)` (a signed 16-bit little-endian-shifted hex, 4 digits) into:
| PITCH | `#TPUG6wGAP` + hex + `10` |
| ROLL | `#TPUG6wGAR` + hex + `10` |
| YAW | `#TPUG6wGAY` + hex + `10` |
e.g. `#TPUG6wGAP3A9810` where `3A98` = short2Hex(value*100).
### Zoom — `zoom(ZOOM)`
| STOP | `#TPUM2wZMC00` |
| IN | `#TPUM2wZMC01` |
| OUT | `#TPUM2wZMC02` |
### Focus — `focus(FOCUS)`
| STOP | `#TPUM2wFCC00` |
| ADD | `#TPUM2wFCC01` |
| REDUCE | `#TPUM2wFCC02` |
### Record / Capture
| Record STOP | `#TPUD2wREC00` |
| Record START | `#TPUD2wREC01` |
| Record flip | `#TPUD2wREC0A` |
| Take picture | `#TPUD2wCAP01` |
| Reset | `#TPUD2wRTF01` |
### Queries (read commands)
| Version | `#TPUD2rVER00` |
| Video config | `#TPUD2rVOM00` |
| Record state | `#TPUD2rREC00` |
| Video effect / IQE | `#TPUD2rIQE00` |
| IP | `#TPUD2rIPV00` |
| Gateway | `#TPUD2rGTW00` |
| (also: `#TPUD2rDZM00`, `#TPUD2rIMG00`, `#TPUD2rTAR00`, `#TPUD2rTAS00`, `#TPUD2rTGM00`, `#TPUD2rTIB00`, `#TPUD2rTIC00`, `#TPUD2rTSM00`, `#TPUD2rTTR00`, `#TPUD2rSLR00`, `#TPUD2rSDC01`, `#TPUD2rEXT00`, `#TPUD2rHWV00`, `#TPUD2rMOD00`, `#TPUD2rTDI00`, `#TPUD2rVID00`) |
### Configuration (write commands)
| Set video config | `#TPUDBwVOM` + `0/1` + `0/1` + `byte2Hex(fps)` + `byte2Hex(bitrate byte)` + `numToHex8(value)` + `1` |
| Set video effect | `#TPUDBwIQE` + `int2Hex(tone)` + `int2Hex(brightness)` + `int2Hex(saturation)` + `int2Hex(contrast)` + `int2Hex(sharpness)` + `style` |
| Set time | `#TPUDFwTIM` + `HHmmss` + `.00` + `ddMMyy` |
| Set gateway | `#TPUDCwGTW` + gateway |
| Set IP | `#TPUD` + lenHex + `wIPV` + ip (lenHex = byte2Hex(ip.length) minus leading '0', or 'F' fallback) |
| Reset IP & gateway | `#TPUD2wRST0163` + `ip:` + ip + `gateway:` + gw |
> Many more `#TP` strings exist in classes2.dex (OEM variants): `#TPUG4wGSM`, `#TPUG6wPGM`, `#TPUGCwGAM`, `#TPUDFwTIM`, `#TPUD2wDZM`, `#TPUD2wTAR`, etc.
---
## 5. `AT+` Command Protocol (secondary / auxiliary)
Sent as raw ASCII bytes with `\r\n` termination over serial/aux socket.
| LED on/off | `AT+LED -e1\r\n` / `AT+LED -e0\r\n` |
| Angle set | `AT+ANGLE -P90\r\n`, `-P0`, `-P180`, `-C1`, `-X0/-X1`, `-Z0/-Z1` |
| Version | `AT+VER -?\r\n` |
| Video mode | `AT+VIDEO -m0 -p1 -f15 -b300 -e1 -g8\r\n` |
| Reset net | `AT+RSTNET -f1\r\n` |
| Switch | `AT+SWITCH -d0\r\n` / `-d1\r\n` / `-e1\r\n` |
| Temp | `AT+TEMP -c1\r\n` |
| Encoder | `AT+AE -o%d -i1 -h1 -r0\r\nAT+AEM -a%d -b0 -c0 -d0 -e%d -f1024 -g1024 -h1024\r\n` |
| OK/Error | `AT+OK`, `AT+OK\r`, `AT+ERR -n`, `AT+ERROR` |
---
## 6. Receive Framing — Arlink `#tp` Frame (what the camera sends back)
From `Decode` / `Decode$DealThread` / `ArlinkRxFiFoHeader`. The frame synchronizer looks for the 3-byte header stream:
```
headerStream = 0x23 0x74 0x70 0x00 // "#tp\0" (4 bytes: '#','t','p',0x00)
```
The `#TP` frame received from the camera:
```
[ headerStream (4 bytes: '#','t','p',0x00) ]
[ msgid (1 byte) ]
[ dataLen (1 byte) ] // length of data payload
[ data (dataLen bytes) ] // max ARLINK_USR_DATA_MAX_LEN = 16384
[ checkSum(1 byte) ]
```
State-machine decode sequence (from `findHeader`):
1. Match the header stream byte-by-byte against `#tp\0`.
2. On full header match → read **msgid** (`ArlinkRxProtocolMSGID = 10`, i.e. state 10).
3. Read **dataLen** → set `dataLen` from the byte (`bd`).
4. Read `dataLen` bytes of payload (into `ArlinkRxFiFo.data` buffer, 1024-byte chunks, buffered in `FiFo`).
5. Read **checkSum** byte and compare with `header.checkSum`. If equal → deliver the full assembled frame to `Decode$Delegate.receive(byte[])`:
`[header(4)][msgid][dataLen][data...][checkSum]`.
(Receive checksum = values accumulated while parsing the header/payload; client validates it matches before dispatching.)
---
## 7. Transport Details
- `sendData(byte[])` writes to **every** live pipeline: TCP client, UDP 9002, and the configurable UDP channel. So a raw command is simply written as bytes to the socket.
- TCP pipeline: `PipelineManager.createTCPPipeline(host, port, false, false)`, then `connectPipeline`.
- UDP 9002: `PipelineManager.createUDPPipeline(9002, host, 9002)`.
- Command throttle: gimbal `PTZControlSpeed` enforces ≥ 40 ms between sends (`lastControlTime`).
- Logging tag observed: `cmd:<fullCommand>` (debug) and `发送数据:<bytes>`.
---
## 8. Minimal Reimplementation (Python-style pseudocode)
```python
import socket
UDP_PORT = 9002
camera_ip = "192.168.144.108" # discovered on wired link
def checksum(cmd: str) -> str:
total = sum(ord(c) for c in cmd) & 0xFF
return "%02X" % total
def build(cmd: str) -> bytes:
return (cmd + checksum(cmd)).encode("utf-8")
def send(udp: socket.socket, cmd: str):
udp.sendto(build(cmd), (camera_ip, UDP_PORT))
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
send(udp, "#TPUG2wPTZ02") # pan left
send(udp, "#TPUD2wCAP01") # take picture
send(udp, "#TPUD2wREC01") # start recording
```
---
## 9. Key Source Classes (for further digging)
| `com.skydroid.camerafpv.SkydroidControl` | Main `#TP` command builder + CRC + send |
| `com.skydroid.camerafpv.SkydroidControl$PTZ/$Move/$ZOOM/$FOCUS/$AngleControl/$RecordVideo/$AKey` | Command enums |
| `com.skydroid.camerafpv.TCPClient` | TCP transport wrapper |
| `com.skydroid.rcsdk.common.pipeline.UDPPipeline/TCPPipeline` | Socket implementations |
| `com.skydroid.camerafpv.utils.String2ByteArrayUtils` | int2Hex/byte2Hex/short2Hex conversion |
| `com.skydroid.camerafpv.Decode` / `Decode$DealThread` / `ArlinkRxFiFoHeader` | RX frame sync (`#tp\0`) + checksum |
| `com.skydroid.rcsdk.internal.RCCmdDecoder2` | RC command decoder (channel/value → #TP/AT) |
| `com.skydroid.rcsdk.internal.payload.SkydroidGimbalControlCore` | Higher-level gimbal API wrapper |
| `com.skydroid.rcsdk.internal.product.ar8030.AR8030HelperSocketClient` | Aux serial/AT helper |