-* Bought [this X5](https://www.aliexpress.com/item/1005006287788979.html) and [this A9](https://www.aliexpress.com/item/1005006117593880.html).
-* App is [YsxLite](https://play.google.com/store/apps/details?id=com.ysxlite.cam&hl=en&gl=US)
+
cam-reverse-rtsp
+
+ Reverse-engineered RTSP/HTTP server for ultra-cheap iLnkP2P IP cameras (X5, A9, A7)
+
-Per pictures of the [X5](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb.jpg?raw=true), [A9](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb_a9.jpg?raw=true) the main chip is TXW817 ([chinese](https://www.taixin-semi.com/Product/ProductDetail?productId=306), [eng, google translate](https://www-taixin--semi-com.translate.goog/Product/ProductDetail?productId=306&_x_tr_sl=auto&_x_tr_tl=en&_x_tr_hl=en&_x_tr_pto=wapp))
-
-## Features
-
-- Multi camera support
-- Audio & video streaming
-- Rotation / mirroring of video streams
-- Friendly names for cameras
-- Ability to configure "blank" cameras with Wifi settings
-
-## Building
-
-Run `make build` or `npm run build` to build the server artifact. You can also find some pre-built files [in the CI results](https://github.com/DavidVentura/cam-reverse/actions) or [in the releases](https://github.com/DavidVentura/cam-reverse/releases/)
-
-## Pairing a new camera
-
-Ensure your device in access point mode (the blue LED blinks slowly to indicate that); optionally, press the MODE button for 5s to switch to access point mode.
-
-Connect to the device's access point (e.g., FTYC811847AGFDZ) and run `node dist/bin.cjs pair --ssid --password `.
-
-
-## Running
-
-### HTTP Server
-To execute the HTTP server, run `node dist/bin.cjs http_server`; you can access the JPEG stream at http://localhost:5000/.
-
-The roundtrip delay when using MJPEG is [~350ms](pics/delay.jpg?raw=true).
-
-There's a basic UI which can display multiple cameras:
-
-
-
-Clicking on the image will take you to a page that has audio streaming. Click the button below the image to mute/unmute the audio.
-
-
-#### Settings
-
-You can provide a config file in `yml` format, then pass it as an argument: `node bin.cjs http_server --config_file `
-
-```yml
-http_server:
- port: 5000
-
-logging:
- level: debug
- use_color: true
-
-cameras:
- FTYC477360FAWUK:
- alias: "A9"
- rotate: 1
- mirror: false
- fix_packet_loss: yes
- audio: true
- BATC609531EXLVS:
- alias: "X5"
-
-# If you are crossing broadcast domains (VLANs) then
-# you need to specify all IPs as unicast targets
-discovery_ips:
- - 192.168.40.101
- - 192.168.40.102
- - 192.168.40.103
- - 192.168.40.104
- - 192.168.40.105
-
-# If you are in the same broadcast domain, then
-# it's easier to just use the broadcast address of your network
-# discovery_ips:
-# - 192.168.1.255
-
-blacklisted_ips:
- - 192.168.40.102
-```
-
-All keys are optional
-
-You must restart the HTTP server for changes to the settings file to take effect.
-
-### Single capture mode
-
-```bash
-node bin.cjs frame --discovery_ip 192.168.40.104 --out out.jpg
-```
-
-----
-
-## Protocol
-
-The protocol is weirdly complex, though very little communication is necessary to use the device
-
-The base structure of a packet is:
-
-
-
-The payload is command-dependent; most commands have only a literal payload, but the `Drw` (`0xf1d0`) command has a framing scheme:
-
-By using the second byte in the payload as a discriminant, we can split the payload into two types of subcommands:
-
-**Control packets**:
-
-
-
-The payload on control packets is "encrypted" when the length is > 5.
-
-**Data packets**:
-
-
-
-Data packets further discriminate based on the first 4 bytes into: Audio Data (0x55aa15a8), Video data.
-
-### Session
-
-To establish a session, a few _control packets_ are sent.
-```mermaid
----
-title: Establish session
---
-sequenceDiagram
- autonumber
- App->>+Cam: [C] LanSearch
- Cam->>-App: [C] PunchPkt (SerialNo)
- App->>+Cam: [C] P2PRdy
- Cam->>-App: [C] P2PRdy
- App->>+Cam: [C] ConnectUser
- Cam->>-App: [C] ConnectUserAck (Ticket)
-
- loop Every 400-500ms
- Cam-->>+App: [C] P2PAlive
- App-->>-Cam: [C] P2PAliveAck
- end
-```
-
-To start a stream, a single _control packet_ is sent.
-
-The received stream is broken up into 1028 byte payloads, along with a sequence number.
+Re-implementation of the **iLnkP2P/PPPP** protocol used on cheap (<$5) IP cameras with the **TXW817** chip. Streams camera video directly to NVRs, VLC, Blue Iris, Home Assistant, or any RTSP/MJPEG client -- no cloud, no app, no intermediaries.
-Stitching the payloads together yields JPEG frames for video, and 8KHz A-law PCM for audio.
+Tested with [X5](https://www.aliexpress.com/item/1005006287788979.html), [A9](https://www.aliexpress.com/item/1005006117593880.html), and [A7 1080p](http://pt.aliexpress.com/item/1005011735155071.html). App: [YsxLite](https://play.google.com/store/apps/details?id=com.ysxlite.cam).
-```mermaid
----
-title: Stream audio/video
----
-
-sequenceDiagram
- App->>Cam: [C] StreamStart (with Ticket)
-
- loop
- Cam-->>+App: [D] Audio/Video Payload
- App-->>-Cam: [C] DrwAck
- end
-```
-
-### Serial
-
-The A9 cameras have a TX/RX test points - connecting with UART at 921600 8N1 gives _read only_ access to some debug logs.
-
-### Discrepancies between cameras
+## Features
-1. Wifi Strength
- - A9 reports '100%' strength
- - X5 reports different strength values
+- **RTSP server** -- H.264 (via GStreamer) or JPEG/RTP, TCP and UDP transport
+- **HTTP server** -- MJPEG streaming + web UI dashboard
+- Multi-camera support, audio & video
+- Rotation / mirroring, friendly names
+- WiFi camera configuration (pairing)
+- Single frame capture
-I bricked two cameras by patching out part of the WiFi setup - unclear yet which commands.
+## Known Limitations
-After bricking itself, it reports very broken configuration via serial:
+- **RTSP multi-camera:** Currently all cameras share a single RTSP endpoint (`/camera`). Each camera needs its own path (`/camera/`). Workaround: run one RTSP server instance per camera with different ports. Fix planned.
-```
-network interface: ƀ (Default)
-MTU: 51050
-MAC: 06 18 40 06 3e 51 b4 e2 c6 80 06 3f 77 30 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 74 00 00 00 01 00 00 00 9c ea 01 20 00 00 00 00 00 00 00 00 00 28 60 00 00 00 00 00 00 00 00 00 06 4e 00 20 2a 00 2a 00 80 00 00 00 00 00 ff ff ff ff ff ff 3e 51 b4 e2 c6 80 08 06 00 01 08 00 06 04 00 01 3e 51 b4 e2 c6 80 01 01 01 01 00 00 00 00 00 00 01 01 01 01 00 28 74 00 00 00 00 00 00 00 00 00 58 4e 00 20 48 00 48 00 80 00 00 00 00 00 ff ff ff ff ff ff 3e 51 b4 e2 c6 80 08 06 45 00 00 48 00 51 00 00 ff 11 c8 be 01 01 01 01 23 9c cc f7 7d 6c
-FLAGS: DOWN LINK_DOWN IGMP
-ip address: 1.1.1.1
-gw address: 1.1.1.1
-net mask : 1.1.1.1
+## Quick start
-network i
-nterface: ^@^@
-MTU: 0
-MAC:
-FLAGS: DOWN LINK_DOWN
-ip address: 127.0.0.1
-gw address: 127.0.0.1
-net mask : 255.0.0.0
-```
-
-## Spyware
+```bash
+npm install && npm run build
-When connecting the camera to a network, it tries to send a HELLO (?) to 4 IP addresses:
-```
-139.155.68.77 - Shenzhen Tencent Computer Systems Company Limited
-119.45.114.92 - Shenzhen Tencent Computer Systems Company Limited
-162.62.63.154 - Tencent Building, Kejizhongyi Avenue
-3.132.215.40 - ec2-3-132-215-40.us-east-2.compute.amazonaws.com
-```
+# RTSP (for NVR / VLC / Blue Iris)
+node dist/bin.cjs rtsp_server --discovery_ip 192.168.1.255
-With the payload
-```
-0000 f1 10 00 28 42 41 54 43 00 00 00 00 00 09 4d 2c ...(BATC......M,
-0010 48 56 44 43 53 00 00 00 08 00 02 01 00 00 6c 7d HVDCS.........l}
-0020 65 28 a8 c0 00 00 00 00 00 00 00 00 e(..........
+# HTTP (for browser)
+node dist/bin.cjs http_server --discovery_ip 192.168.1.255
```
-which is `DevLogin`
-
-
-These addresses are decoded (script at `scripts/dec_svr.py`) from the string `SWPNPDPFLVAOLNSXPHSQPIEOPAIDENLXHXEHIFLKPGLRHUARSTLQEEEPSUIHPDLSPEAOICLOSQEMLPPALNIBIAERHZLKHXEJHYHUEIEHELEEEKEG`.
-
-Every 8-10s
-
-There are some other strings in the APK ending in `-$$` which decode to other ips/hostnames.
-
-Most of the IPs point to AWS compute instances, and this connection is probably used to see live streams over the Internet using the app. It's fine (and recommended!) to block outgoing traffic from the cameras, as it won't affect the HTTP server.
-
-
-## Other stuff
-
-These little cameras have quite some packet loss - I _tried_ to deal with it by splicing around it on the JPEG payloads, but it's probably wrong, I expected artifacts like this:
+Connect to `rtsp://:8554/camera` or open `http://localhost:5000`.
-
+## Documentation
-but most of the time got:
+| | |
+| -------------------------------------------- | --------------------------------------------------- |
+| [Architecture](docs/architecture.md) | Project structure, data flow, source files |
+| [Initial Setup](docs/guide-initial-setup.md) | Building, pairing cameras, running, config |
+| [RTSP Server](docs/rtsp.md) | RTSP/RTP streaming, H.264/JPEG modes, compatibility |
+| [HTTP Server](docs/http_server.md) | MJPEG streaming, web UI, routes |
+| [iLnkP2P Protocol](docs/protocol.md) | Reverse-engineered camera protocol |
+| [GStreamer](docs/gstreamer.md) | JPEG-to-H.264 transcoding pipeline |
+| [Reverse Engineering](docs/reversing.md) | Ghidra, Frida, Wireshark dissector |
-
+## Camera PCB
-which _moves_ the rest of the image, causing more visual noise.
+
+
+
-For now, images on which there was packet loss get skipped. The algorithm to "fix" packet loss can be enabled as an option.
+Per pictures of the [X5](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb.jpg?raw=true) and [A9](https://github.com/DavidVentura/cam-reverse/blob/master/pics/pcb_a9.jpg?raw=true), the main chip is TXW817 ([chinese](https://www.taixin-semi.com/Product/ProductDetail?productId=306), [english](https://www-taixin--semi-com.translate.goog/Product/ProductDetail?productId=306&_x_tr_sl=auto&_x_tr_tl=en&_x_tr_hl=en&_x_tr_pto=wapp)).
+## Cloud / spyware
-## Reversing
+The cameras connect to Tencent cloud servers on boot. **Block outbound internet access** on your router. Both servers work fully offline. See [Protocol docs](docs/protocol.md) for details on the spyware IPs.
-The interesting implementation is in `libvdp.so`, part of the apk bundle.
+## Firmware alternatives
-Protocol reversing was done with a combination of static analysis of the shared object with [Ghidra](https://ghidra-sre.org/) and dynamic analysis with [Frida](https://frida.re/docs/javascript-api/).
+[OpenBK7231T](https://github.com/openshwprojects/OpenBK7231T_App) provides open firmware for XR872, but the camera driver is not yet implemented. cam-reverse-rtsp is the current best option for local streaming.
-The headers reversed with Ghidra are at `types/all.h`. They are almost not used by this minimal implementation though.
-
-The hooks used with frida are at `frida-hooks.js`, but it's mostly a playground - some useful functions got deleted once I understood the protocol.
-
-There's also a partial Wireshark dissector at `dissector.lua`. You can install it with `make install-wireshark-dissector`.
+## Building
-### Take APK from emulator/sacrificial device
-```
-adb shell pm list packages | grep ysx
-adb shell pm path com.ysxlite.cam
-adb shell pm path com.ysxlite.cam | while read -r line ; do adb pull $(echo $line | cut -d: -f2-) ; done
-```
-### Push to sacrificial device
-```
-adb install-multiple *apk
+```bash
+npm run build # esbuild -> dist/bin.cjs
+npm run typecheck # TypeScript type checking
+npm test # Mocha tests
```
-### Frida install Android
-
-[docs](https://frida.re/docs/android/)
+Pre-built binaries: [CI results](https://github.com/DavidVentura/cam-reverse/actions) | [Releases](https://github.com/DavidVentura/cam-reverse/releases/)
-### Start frida server
+## License
-```
-adb shell 'su -c nohup /data/local/tmp/frida-server-16.1.11-android-arm64 &'
-```
+See repository for license details. This is a fork of [DavidVentura/cam-reverse](https://github.com/DavidVentura/cam-reverse).
diff --git a/asd.html b/asd.html
index 87006d4..240b19e 100644
--- a/asd.html
+++ b/asd.html
@@ -1,103 +1,543 @@
-
-
-
- ${name}
-
-
${name}
-
-
-
-
+ /* FPS and signal quality */
+ (function() {
+ var img = document.getElementById('streamImg');
+ var fpsEl = document.getElementById('fps');
+ var signalEl = document.getElementById('signal');
+ var statusEl = document.getElementById('streamStatus');
+ var frameTimes = [];
+ var MAX_FRAMES = 30;
+ var lastFrameTime = Date.now();
+ var checking = false;
+
+ function updateStats() {
+ var now = Date.now();
+ frameTimes.push(now);
+ if (frameTimes.length > MAX_FRAMES) { frameTimes.shift(); }
+ lastFrameTime = now;
+ if (frameTimes.length > 1) {
+ var elapsed = (now - frameTimes[0]) / 1000;
+ var fps = (frameTimes.length - 1) / elapsed;
+ fpsEl.textContent = fps.toFixed(1);
+ if (fps >= 20) { signalEl.textContent = 'Good'; signalEl.style.color = ''; signalEl.style.removeProperty ? signalEl.style.removeProperty('color') : (signalEl.style.color = 'var(--success)'); }
+ else if (fps >= 10) { signalEl.textContent = 'Fair'; signalEl.style.color = ''; signalEl.style.removeProperty ? signalEl.style.removeProperty('color') : (signalEl.style.color = 'var(--warning)'); }
+ else { signalEl.textContent = 'Poor'; signalEl.style.color = ''; signalEl.style.removeProperty ? signalEl.style.removeProperty('color') : (signalEl.style.color = 'var(--danger)'); }
+ }
+ statusEl.textContent = 'Live';
+ statusEl.className = 'stream-status live';
+ }
+
+ if (img) {
+ img.addEventListener('load', updateStats);
+ img.addEventListener('error', function() {
+ statusEl.textContent = 'Error';
+ statusEl.className = 'stream-status stalled';
+ fpsEl.textContent = '--';
+ signalEl.textContent = '--';
+ });
+ }
+
+ setInterval(function() {
+ if (Date.now() - lastFrameTime > 3000) {
+ statusEl.textContent = 'Stalled';
+ statusEl.className = 'stream-status stalled';
+ if (!checking) { fpsEl.textContent = '--'; signalEl.textContent = '--'; }
+ }
+ }, 1000);
+ })();
+
+
diff --git a/bin.ts b/bin.ts
new file mode 100644
index 0000000..44c6468
--- /dev/null
+++ b/bin.ts
@@ -0,0 +1,133 @@
+import process from "node:process";
+import { hideBin } from "yargs/helpers";
+import yargs from "yargs/yargs";
+import { captureSingle } from "../capture_single.js";
+import { serveHttp } from "../http_server.js";
+import { serveRtsp } from "../rtsp_server.js";
+import { pair } from "../pair.js";
+import { loadConfig, config } from "../settings.js";
+import { buildLogger, logger } from "../logger.js";
+
+const majorVersion = process.versions.node.split(".").map(Number)[0];
+
+yargs(hideBin(process.argv))
+ .command(
+ "http_server",
+ "start http server",
+ (yargs) => {
+ return yargs
+ .option("color", { describe: "Use color in logs" })
+ .boolean(["audio", "color"])
+ .option("config_file", { describe: "Specify config file" })
+ .option("log_level", { describe: "Set log level" })
+ .option("discovery_ip", { describe: "Camera discovery IP address" })
+ .option("port", { describe: "HTTP Port to listen on" })
+ .string(["log_level", "discovery_ip", "config_file"])
+ .number(["port"])
+ .strict();
+ },
+ (argv) => {
+ if (argv.config_file !== undefined) {
+ loadConfig(argv.config_file);
+ }
+ if (argv.port) {
+ config.http_server.port = argv.port;
+ }
+ if (argv.color !== undefined) {
+ config.logging.use_color = argv.color;
+ }
+ if (argv.log_level !== undefined) {
+ config.logging.level = argv.log_level;
+ }
+ if (argv.discovery_ip !== undefined) {
+ config.discovery_ips = [argv.discovery_ip];
+ }
+ buildLogger(config.logging.level, config.logging.use_color);
+ if (majorVersion < 16) {
+ logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
+ }
+ serveHttp(config.http_server.port);
+ },
+ )
+ .command(
+ "rtsp_server",
+ "start RTSP server (streams camera directly via RTSP/RTP)",
+ (yargs) => {
+ return yargs
+ .option("color", { describe: "Use color in logs" })
+ .boolean(["color"])
+ .option("config_file", { describe: "Specify config file" })
+ .option("log_level", { describe: "Set log level", default: "info" })
+ .option("discovery_ip", { describe: "Camera discovery IP address" })
+ .option("port", { describe: "RTSP port to listen on", default: 8554 })
+ .string(["log_level", "discovery_ip", "config_file"])
+ .number(["port"])
+ .strict();
+ },
+ (argv) => {
+ if (argv.config_file !== undefined) {
+ loadConfig(argv.config_file);
+ }
+ if (argv.color !== undefined) {
+ config.logging.use_color = argv.color;
+ }
+ if (argv.log_level !== undefined) {
+ config.logging.level = argv.log_level;
+ }
+ if (argv.discovery_ip !== undefined) {
+ config.discovery_ips = [argv.discovery_ip];
+ }
+ buildLogger(config.logging.level, config.logging.use_color);
+ if (majorVersion < 16) {
+ logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
+ }
+ serveRtsp(argv.port).catch((err) => {
+ logger.error(`RTSP server error: ${err}`);
+ process.exit(1);
+ });
+ },
+ )
+ .command(
+ "pair",
+ "configure a camera",
+ (yargs) => {
+ return yargs
+ .option("log_level", { describe: "Set log level", default: "info" })
+ .option("discovery_ip", { describe: "Camera discovery IP address" })
+ .option("ssid", { describe: "Wifi network for the camera to connect to" })
+ .option("password", { describe: "Wifi network password" })
+ .demandOption(["ssid", "password"])
+ .string(["ssid", "password"]);
+ },
+ (argv) => {
+ buildLogger(argv.log_level, undefined);
+ if (majorVersion < 16) {
+ logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
+ }
+ if (argv.discovery_ip !== undefined) {
+ config.discovery_ips = [argv.discovery_ip];
+ }
+ pair({ ssid: argv.ssid, password: argv.password });
+ },
+ )
+ .command(
+ "frame",
+ "capture a single frame from the first discovered camera",
+ (yargs) => {
+ return yargs
+ .option("log_level", { describe: "Set log level", default: "info" })
+ .option("discovery_ip", { describe: "Camera discovery IP address", default: "192.168.1.255" })
+ .option("out", { describe: "Path for output file" })
+ .demandOption(["out"])
+ .string(["out", "discovery_ip"]);
+ },
+ (argv) => {
+ buildLogger(argv.log_level, undefined);
+ if (majorVersion < 16) {
+ logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
+ }
+ captureSingle({ discovery_ip: argv.discovery_ip, out_file: argv.out });
+ },
+ )
+ .demandCommand()
+ .parseSync();
diff --git a/cmd/bin.ts b/cmd/bin.ts
index e4f03f2..44c6468 100644
--- a/cmd/bin.ts
+++ b/cmd/bin.ts
@@ -1,12 +1,11 @@
import process from "node:process";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
-
import { captureSingle } from "../capture_single.js";
import { serveHttp } from "../http_server.js";
+import { serveRtsp } from "../rtsp_server.js";
import { pair } from "../pair.js";
import { loadConfig, config } from "../settings.js";
-
import { buildLogger, logger } from "../logger.js";
const majorVersion = process.versions.node.split(".").map(Number)[0];
@@ -43,7 +42,6 @@ yargs(hideBin(process.argv))
if (argv.discovery_ip !== undefined) {
config.discovery_ips = [argv.discovery_ip];
}
-
buildLogger(config.logging.level, config.logging.use_color);
if (majorVersion < 16) {
logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
@@ -51,6 +49,44 @@ yargs(hideBin(process.argv))
serveHttp(config.http_server.port);
},
)
+ .command(
+ "rtsp_server",
+ "start RTSP server (streams camera directly via RTSP/RTP)",
+ (yargs) => {
+ return yargs
+ .option("color", { describe: "Use color in logs" })
+ .boolean(["color"])
+ .option("config_file", { describe: "Specify config file" })
+ .option("log_level", { describe: "Set log level", default: "info" })
+ .option("discovery_ip", { describe: "Camera discovery IP address" })
+ .option("port", { describe: "RTSP port to listen on", default: 8554 })
+ .string(["log_level", "discovery_ip", "config_file"])
+ .number(["port"])
+ .strict();
+ },
+ (argv) => {
+ if (argv.config_file !== undefined) {
+ loadConfig(argv.config_file);
+ }
+ if (argv.color !== undefined) {
+ config.logging.use_color = argv.color;
+ }
+ if (argv.log_level !== undefined) {
+ config.logging.level = argv.log_level;
+ }
+ if (argv.discovery_ip !== undefined) {
+ config.discovery_ips = [argv.discovery_ip];
+ }
+ buildLogger(config.logging.level, config.logging.use_color);
+ if (majorVersion < 16) {
+ logger.error(`Node version ${majorVersion} is not supported, may malfunction`);
+ }
+ serveRtsp(argv.port).catch((err) => {
+ logger.error(`RTSP server error: ${err}`);
+ process.exit(1);
+ });
+ },
+ )
.command(
"pair",
"configure a camera",
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..f2e111f
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,9 @@
+# cam-reverse-rtsp Documentation
+
+- [Architecture](architecture.md) -- project structure, data flow, file overview
+- [Initial Setup](guide-initial-setup.md) -- building, pairing cameras, running
+- [RTSP Server](rtsp.md) -- RTSP/RTP streaming, H.264 transcoding, client compatibility
+- [HTTP Server](http_server.md) -- MJPEG streaming, web UI, configuration
+- [iLnkP2P Protocol](protocol.md) -- reverse-engineered camera protocol details
+- [GStreamer Transcoding](gstreamer.md) -- JPEG to H.264 pipeline, requirements
+- [Reverse Engineering](reversing.md) -- Ghidra, Frida, Wireshark dissector
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..8cc51af
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,97 @@
+# Architecture
+
+## Project overview
+
+cam-reverse-rtsp is a reverse-engineered re-implementation of the **iLnkP2P/PPPP** protocol used by ultra-cheap (<$5) IP cameras (branded as X5, A9, A7). It provides two streaming modes: an HTTP/MJPEG server with web UI, and a native RTSP server for NVR integration.
+
+Main chip: **TXW817** (Taixin Semiconductor). Companion app: **YsxLite**.
+
+## Data flow
+
+```
+Camera (iLnkP2P/UDP, port 32108)
+ |
+ v
+ discovery.ts -- UDP broadcast LanSearch, receive PunchPkt
+ |
+ v
+ session.ts -- P2PRdy -> ConnectUser -> login -> StartVideo
+ Keepalive loop (P2PAlive/Ack every 400ms, 5s timeout)
+ |
+ v
+ handlers.ts -- Drw packets -> JPEG frame assembly / audio extraction
+ |
+ +--------> http_server.ts (MJPEG HTTP streaming + web UI)
+ |
+ +--------> rtsp_server.ts (RTSP/RTP streaming)
+ |
+ +--- JPEG mode (RFC 2435, no transcoding)
+ |
+ +--- H.264 mode (GStreamer transcoder.ts)
+ JPEG -> openh264enc -> rtph264pay -> UDP -> RTP forwarding
+```
+
+## Source files
+
+| File | Purpose |
+| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `bin.ts` / `cmd/bin.ts` | CLI entry point (yargs). Commands: `http_server`, `rtsp_server`, `pair`, `frame` |
+| `rtsp_server.ts` | RTSP server -- protocol handling, RTP/JPEG and RTP/H.264 packetization, SDP, TCP/UDP transport, RTCP SR, session management |
+| `transcoder.ts` | GStreamer JPEG-to-H.264 transcoder -- subprocess management, UDP socket, SPS/PPS extraction |
+| `http_server.ts` | HTTP server -- MJPEG streaming, WebSocket, web UI dashboard (dark/light theme) |
+| `session.ts` | Camera session lifecycle -- UDP socket, packet dispatch, keepalive, timeout, retransmission |
+| `handlers.ts` | Protocol command handlers -- JPEG frame assembly, audio extraction, control command dispatch |
+| `impl.ts` | Protocol command construction -- Drw packet builder, login, video start, WiFi config |
+| `datatypes.ts` | Command constants and protocol type definitions |
+| `discovery.ts` | UDP broadcast device discovery (port 32108) |
+| `settings.ts` | YAML config file loading |
+| `exif.ts` | EXIF orientation insertion for JPEG rotation |
+| `shim.ts` | DataView convenience methods extension |
+| `logger.ts` | Winston logging setup |
+| `pair.ts` | WiFi pairing workflow |
+| `capture_single.ts` | Single frame capture |
+| `dissector.lua` | Wireshark protocol dissector |
+| `func_replacements.js` | Frida function replacement hooks |
+
+## Network ports
+
+| Port | Protocol | Purpose |
+| ------- | -------- | --------------------------------------------- |
+| 32108 | UDP | iLnkP2P discovery and camera communication |
+| 5000 | TCP | HTTP server (default) |
+| 8554 | TCP | RTSP server (default) |
+| Dynamic | UDP | GStreamer RTP output (127.0.0.1 loopback) |
+| Dynamic | UDP | Per-camera session socket |
+| Dynamic | UDP | RTP/RTCP to RTSP clients (UDP transport mode) |
+
+## Configuration
+
+Config file format (YAML):
+
+```yml
+http_server:
+ port: 5000
+
+rtsp_server:
+ port: 8554
+
+logging:
+ level: info # debug, info, warning
+ use_color: true
+
+cameras:
+ FTYC477360FAWUK:
+ alias: "A9"
+ rotate: 1 # 0=0deg, 1=90deg, 2=180deg, 3=270deg
+ mirror: false
+ audio: true
+ fix_packet_loss: yes
+
+discovery_ips:
+ - 192.168.1.255 # broadcast address, or individual IPs for VLANs
+
+blacklisted_ips:
+ - 192.168.0.100
+```
+
+All keys are optional. See [Initial Setup](guide-initial-setup.md) for details.
diff --git a/docs/gstreamer.md b/docs/gstreamer.md
new file mode 100644
index 0000000..0dee0f9
--- /dev/null
+++ b/docs/gstreamer.md
@@ -0,0 +1,134 @@
+# GStreamer Transcoding
+
+The RTSP server uses GStreamer to transcode JPEG frames from the camera into H.264 for maximum NVR compatibility.
+
+## Pipeline
+
+```
+fdsrc fd=0 -- reads JPEG from stdin
+ ! jpegdec -- decode JPEG to raw video
+ ! videoconvert -- color space conversion
+ ! openh264enc -- H.264 software encoder
+ complexity=low -- fastest encoding
+ bitrate=300000 -- 300 kbps (adjust per device CPU)
+ gop-size=15 -- keyframe every 15 frames (~1s at 15fps)
+ usage-type=camera -- optimized for camera content
+ ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline
+ ! h264parse -- parse H.264 stream
+ ! rtph264pay -- RTP packetization
+ config-interval=-1 -- SPS/PPS with every IDR frame
+ pt=96 -- payload type 96
+ ! udpsink host=127.0.0.1 port= -- UDP loopback output
+```
+
+## How it works
+
+Each camera gets its own transcoder instance (`transcoder.ts`):
+
+1. A UDP socket is bound to `127.0.0.1` on an OS-assigned port
+2. GStreamer is spawned with the pipeline above, outputting RTP to that socket
+3. JPEG frames from the camera are written to GStreamer's stdin
+4. RTP packets are received on the UDP socket and emitted via EventEmitter
+5. SPS (NAL type 7) and PPS (NAL type 8) NAL units are extracted for inline delivery
+6. If GStreamer crashes, the transcoder emits an `exit` event and the server creates a new instance
+
+## Requirements
+
+| Plugin | Package (Ubuntu/Debian) | Purpose |
+| -------------- | --------------------------- | ---------------------- |
+| `fdsrc` | `gstreamer1.0` | Read JPEG from stdin |
+| `jpegdec` | `gstreamer1.0-plugins-good` | Decode JPEG |
+| `videoconvert` | `gstreamer1.0-plugins-good` | Color space conversion |
+| `openh264enc` | `gstreamer1.0-plugins-bad` | H.264 encoder |
+| `h264parse` | `gstreamer1.0-plugins-bad` | Parse H.264 |
+| `rtph264pay` | `gstreamer1.0-plugins-good` | RTP packetization |
+| `udpsink` | `gstreamer1.0-plugins-good` | UDP output |
+
+### Install on Ubuntu/Debian
+
+```bash
+sudo apt install gstreamer1.0-tools \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good \
+ gstreamer1.0-plugins-bad
+```
+
+### Verify installation
+
+```bash
+gst-launch-1.0 --version
+```
+
+If GStreamer is not installed, the RTSP server falls back to JPEG/RTP mode automatically.
+
+## Encoder parameters
+
+| Parameter | Value | Effect |
+| ----------------- | ---------------------- | ---------------------------------------------------------- |
+| `complexity` | `low` | Fastest encoding, minimal CPU |
+| `bitrate` | `300000` | 300 kbps target bitrate (see Tuning for per-device values) |
+| `gop-size` | `15` | Keyframe every 15 frames (~1s) |
+| `usage-type` | `camera` | Optimized for camera content |
+| `profile` | `constrained-baseline` | Maximum client compatibility |
+| `config-interval` | `-1` | SPS/PPS with every IDR frame |
+
+## Tuning
+
+All encoder parameters are in `transcoder.ts`, inside the GStreamer args array (line ~90):
+
+```typescript
+const args = [
+ "fdsrc",
+ "fd=0",
+ "!",
+ "jpegdec",
+ "!",
+ "videoconvert",
+ "!",
+ "openh264enc",
+ "complexity=low", // ← encoding speed
+ "bitrate=300000", // ← change this value
+ "gop-size=15", // ← keyframe interval
+ "usage-type=camera",
+ "!",
+ "video/x-h264,stream-format=byte-stream,profile=constrained-baseline",
+ "!",
+ "h264parse",
+ "!",
+ "rtph264pay",
+ "config-interval=-1",
+ "pt=96",
+ "!",
+ "udpsink",
+ "host=127.0.0.1",
+ `port=${port}`,
+];
+```
+
+| Goal | Parameter to change | Effect |
+| ------------------- | ------------------------------------ | ----------------------------------------------------- |
+| **Lower bandwidth** | `bitrate=300000` | Reduces quality but uses less CPU and bandwidth |
+| **Higher quality** | `bitrate=500000` or `bitrate=700000` | Better image, more CPU and bandwidth |
+| **Faster encoding** | `complexity=low` (already set) | Fastest, use `medium` or `high` only on fast machines |
+| **Lower latency** | `gop-size=10` | More frequent keyframes, slightly more bandwidth |
+
+Recommended values by device:
+
+| Device | `bitrate` | `complexity` | Notes |
+| ---------------------------- | ------------------ | -------------- | -------------------------------- |
+| ARM 1GHz (msm8916, RPi Zero) | `200000`–`300000` | `low` | openh264enc is slow on weak CPUs |
+| ARM 2GHz+ (RPi 4, SBC) | `300000`–`500000` | `low` | comfortable headroom |
+| x86 notebook/desktop | `500000`–`1000000` | `low`–`medium` | plenty of CPU |
+
+Typical end-to-end latency: 100-300ms depending on network.
+
+## NAL unit extraction
+
+The transcoder extracts SPS and PPS from the RTP stream by parsing NAL unit types:
+
+- NAL type 7 (`& 0x1f == 7`): SPS (Sequence Parameter Set)
+- NAL type 8 (`& 0x1f == 8`): PPS (Picture Parameter Set)
+
+For FU-A fragmented NALs (type 28), the start bit (`S` flag in FU header) is checked before extracting the type from the FU header.
+
+These are stored and can be accessed via `transcoder.getSps()` and `transcoder.getPps()`.
diff --git a/docs/guide-initial-setup.md b/docs/guide-initial-setup.md
new file mode 100644
index 0000000..1fb5933
--- /dev/null
+++ b/docs/guide-initial-setup.md
@@ -0,0 +1,124 @@
+# Initial Setup
+
+## Requirements
+
+- Node.js >= 16
+- npm
+- (Optional) GStreamer for H.264 transcoding -- see [GStreamer docs](gstreamer.md)
+
+## Building
+
+```bash
+npm install
+npm run build
+```
+
+This produces `dist/bin.cjs` via esbuild.
+
+Pre-built binaries may be available in [CI results](https://github.com/DavidVentura/cam-reverse/actions) or [releases](https://github.com/DavidVentura/cam-reverse/releases/).
+
+## Pairing a new camera
+
+1. Put the camera in access point mode -- the blue LED blinks slowly. Press the MODE button for 5s if needed.
+2. Connect your computer to the camera's AP (e.g., `FTYC811847AGFDZ`).
+3. Run:
+
+```bash
+node dist/bin.cjs pair --ssid --password
+```
+
+The camera will join your WiFi network. Its LED will indicate connection status.
+
+## Running the servers
+
+### HTTP server (MJPEG + web UI)
+
+```bash
+node dist/bin.cjs http_server --discovery_ip 192.168.1.255
+```
+
+Open `http://localhost:5000` in a browser. See [HTTP Server docs](http_server.md).
+
+### RTSP server (NVR / VLC / Blue Iris)
+
+```bash
+node dist/bin.cjs rtsp_server --discovery_ip 192.168.1.255
+```
+
+Point your NVR or player to `rtsp://:8554/camera`. See [RTSP Server docs](rtsp.md).
+
+### Single frame capture
+
+```bash
+node dist/bin.cjs frame --discovery_ip 192.168.1.255 --out snapshot.jpg
+```
+
+## CLI options
+
+### http_server
+
+| Option | Default | Description |
+| ---------------- | --------------- | ------------------------------------------ |
+| `--port` | `5000` | HTTP port |
+| `--discovery_ip` | `192.168.1.255` | Camera discovery IP (broadcast or unicast) |
+| `--config_file` | -- | Path to YAML config |
+| `--log_level` | `info` | `debug`, `info`, `warning` |
+| `--audio` | `false` | Enable audio streaming |
+
+### rtsp_server
+
+| Option | Default | Description |
+| ---------------- | --------------- | -------------------------- |
+| `--port` | `8554` | RTSP port |
+| `--discovery_ip` | `192.168.1.255` | Camera discovery IP |
+| `--config_file` | -- | Path to YAML config |
+| `--log_level` | `info` | `debug`, `info`, `warning` |
+
+### pair
+
+| Option | Required | Description |
+| ---------------- | -------- | ------------------- |
+| `--ssid` | Yes | WiFi network name |
+| `--password` | Yes | WiFi password |
+| `--discovery_ip` | No | Camera discovery IP |
+
+### frame
+
+| Option | Default | Description |
+| ---------------- | --------------- | --------------------------- |
+| `--out` | -- | Output file path (required) |
+| `--discovery_ip` | `192.168.1.255` | Camera discovery IP |
+
+## Config file
+
+```yml
+http_server:
+ port: 5000
+
+logging:
+ level: info
+ use_color: true
+
+cameras:
+ FTYC477360FAWUK:
+ alias: "A9"
+ rotate: 1
+ mirror: false
+ audio: true
+ fix_packet_loss: yes
+ BATC609531EXLVS:
+ alias: "X5"
+
+# Use broadcast for same subnet, individual IPs for VLANs
+discovery_ips:
+ - 192.168.1.255
+
+blacklisted_ips:
+ - 192.168.0.100
+```
+
+Pass with `--config_file config.yml`. Restart the server for changes to take effect.
+
+## Cloud / spyware
+
+The cameras connect to Tencent cloud servers on boot. Block outbound internet access on your router. Both servers work fully offline.
diff --git a/docs/http_server.md b/docs/http_server.md
new file mode 100644
index 0000000..95b079b
--- /dev/null
+++ b/docs/http_server.md
@@ -0,0 +1,96 @@
+# HTTP Server
+
+HTTP server for MJPEG streaming with a built-in web UI dashboard.
+
+## Quick start
+
+```bash
+node dist/bin.cjs http_server --discovery_ip 192.168.1.255
+```
+
+Open `http://localhost:5000` in a browser.
+
+## Routes
+
+| Route | Description |
+| ----------------- | ------------------------------------------------------------- |
+| `/` | Dashboard -- camera grid with dark/light theme, search/filter |
+| `/camera/` | MJPEG stream (`multipart/x-mixed-replace`) |
+| `/ui/` | Per-camera UI page |
+| `/audio/` | Audio stream via Server-Sent Events (SSE) |
+| `/rotate/` | Rotate camera 90 degrees (cycles 0-3) |
+| `/mirror/` | Toggle mirror |
+| `/favicon.ico` | Favicon |
+
+## MJPEG streaming
+
+Each camera's stream is served as `multipart/x-mixed-replace` with JPEG frames. EXIF orientation headers are inserted based on `rotate`/`mirror` config.
+
+Multiple clients can connect simultaneously per camera.
+
+## Web UI
+
+- Dark/light theme toggle
+- Responsive grid layout
+- Camera search/filter
+- FPS and signal quality indicators
+- Audio streaming controls
+
+### Screenshots
+
+**Mobile:**
+
+| Dashboard | Camera View |
+| ----------------------------------- | ----------------------------------- |
+|  |  |
+
+**Desktop:**
+
+| Dashboard | Camera View |
+| ------------------------------- | ------------------------------- |
+|  |  |
+
+## Latency
+
+MJPEG roundtrip delay is [~350ms](../pics/delay.jpg?raw=true).
+
+## Options
+
+| Option | Default | Description |
+| ---------------- | --------------- | -------------------------- |
+| `--port` | `5000` | HTTP port |
+| `--discovery_ip` | `192.168.1.255` | Camera discovery IP |
+| `--config_file` | -- | YAML config path |
+| `--log_level` | `info` | `debug`, `info`, `warning` |
+| `--audio` | `false` | Enable audio streaming |
+
+## Config
+
+```yml
+http_server:
+ port: 5000
+
+logging:
+ level: info
+ use_color: true
+
+cameras:
+ FTYC477360FAWUK:
+ alias: "A9"
+ rotate: 1
+ mirror: false
+ audio: true
+ fix_packet_loss: yes
+```
+
+All keys are optional. Restart the server for changes to take effect.
+
+### Camera options
+
+| Key | Type | Description |
+| ----------------- | ------ | --------------------------------------------- |
+| `alias` | string | Custom name displayed in UI |
+| `rotate` | 0-3 | Rotation: 0=0deg, 1=90deg, 2=180deg, 3=270deg |
+| `mirror` | bool | Horizontal mirror |
+| `audio` | bool | Enable audio for this camera |
+| `fix_packet_loss` | bool | Attempt to fix JPEG packet loss artifacts |
diff --git a/docs/protocol.md b/docs/protocol.md
new file mode 100644
index 0000000..982b950
--- /dev/null
+++ b/docs/protocol.md
@@ -0,0 +1,166 @@
+# iLnkP2P Protocol
+
+Reverse-engineered protocol used by X5/A9/A7 IP cameras. Communication happens over UDP on port 32108.
+
+## Packet structure
+
+Base packet format:
+
+
+
+The `Drw` command (`0xf1d0`) carries both control and data payloads, discriminated by the second byte.
+
+### Control packets
+
+
+
+Payloads longer than 5 bytes are obfuscated with `XqBytesEnc` (XOR-rotation, see below).
+
+### Data packets
+
+
+
+Data packets are further discriminated by the first 4 bytes:
+
+- `0x55aa15a8` -- framed audio/video data
+- `0xffd8ffdb` -- unframed JPEG start (SOI + DQT)
+
+## Command constants
+
+### Top-level commands
+
+| Name | Value | Description |
+| -------------- | -------- | --------------------------------------- |
+| `LanSearch` | `0xf130` | Device discovery broadcast |
+| `LanSearchExt` | `0xf132` | Extended LAN search |
+| `PunchPkt` | `0xf141` | Discovery response (contains serial) |
+| `P2pRdy` | `0xf142` | Session establishment |
+| `P2PAlive` | `0xf1e0` | Keepalive |
+| `P2PAliveAck` | `0xf1e1` | Keepalive response |
+| `Drw` | `0xf1d0` | Data read/write (control + stream data) |
+| `DrwAck` | `0xf1d1` | Drw acknowledgment |
+| `Close` | `0xf1f0` | Close connection |
+| `Hello` | `0xf100` | Hello |
+| `HelloAck` | `0xf101` | Hello ack |
+| `PunchTo` | `0xf140` | Punch to |
+| `RlyTo` | `0xf102` | Relay to |
+| `DevLgnAck` | `0xf111` | Device login ack |
+| `P2pReq` | `0xf120` | P2P request |
+| `P2PReqAck` | `0xf121` | P2P request ack |
+| `LstReq` | `0xf167` | List request |
+| `ListenReqAck` | `0xf169` | Listen request ack |
+| `RlyHelloAck` | `0xf170` | Relay hello ack |
+| `RlyHelloAck2` | `0xf171` | Relay hello ack 2 |
+
+### Control sub-commands (within Drw)
+
+| Name | Value | Description |
+| ------------------ | -------- | ---------------------------------------- |
+| `ConnectUser` | `0x2010` | Login (admin/admin) |
+| `ConnectUserAck` | `0x2011` | Login response (contains ticket) |
+| `DevStatus` | `0x0810` | Query device status |
+| `DevStatusAck` | `0x0811` | Status response (battery, WiFi, version) |
+| `StartVideo` | `0x1030` | Start video stream |
+| `StartVideoAck` | `0x1031` | Stream started |
+| `StopVideo` | `0x1130` | Stop video stream |
+| `VideoParamSet` | `0x1830` | Set video resolution |
+| `VideoParamSetAck` | `0x1831` | Resolution set |
+| `VideoParamGet` | `0x1930` | Get video params |
+| `WifiSettings` | `0x0260` | Get WiFi settings |
+| `WifiSettingsAck` | `0x0261` | WiFi settings response |
+| `ListWifi` | `0x0360` | Scan WiFi networks |
+| `ListWifiAck` | `0x0361` | WiFi scan results |
+| `IRToggle` | `0x0a30` | Toggle IR cut filter |
+| `Reboot` | `0x1110` | Reboot camera |
+| `Shutdown` | `0x1010` | Shutdown camera |
+
+## Session flow
+
+### Discovery and connection
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant Cam
+
+ App->>Cam: LanSearch (UDP broadcast, port 32108)
+ Cam->>App: PunchPkt (serial number)
+ App->>Cam: P2PRdy (with serial)
+ Cam->>App: P2PRdy
+ App->>Cam: ConnectUser (admin/admin)
+ Cam->>App: ConnectUserAck (ticket)
+
+ loop Every 400-500ms
+ Cam->>App: P2PAlive
+ App->>Cam: P2PAliveAck
+ end
+```
+
+### Video streaming
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant Cam
+
+ App->>Cam: SendVideoResolution (640x480)
+ App->>Cam: SendStartVideo (with ticket)
+
+ loop
+ Cam-->>App: Audio/Video data (1028-byte fragments)
+ App-->>Cam: DrwAck
+ end
+```
+
+## Data packet format
+
+Video data arrives in 1028-byte payloads with sequence numbers. Two framing modes exist:
+
+### Framed packets (0x55aa15a8 header)
+
+| Offset | Size | Description |
+| ------ | ---- | ------------------------------------------ |
+| 0 | 4 | Header: `55 aa 15 a8` |
+| 4 | 1 | Stream type: `0x06` = audio, `0x03` = JPEG |
+| 6 | 2 | Sequence ID |
+| 8 | 4 | Packet length |
+| 12+16 | var | Data payload |
+
+### Unframed packets
+
+JPEG data arrives raw. A new frame starts with `0xff 0xd8 0xff 0xdb` (SOI + DQT). Subsequent segments are appended until the next SOI or until packet loss is detected.
+
+Packet loss detection: if `pkt_id > rcvSeqId + 1`, the frame is marked as bad and skipped.
+
+## Byte obfuscation
+
+The protocol uses a simple obfuscation (not encryption) for control payloads:
+
+```
+XqBytesEnc(data, length, rotate):
+ for each byte:
+ if byte is odd: byte -= 1
+ if byte is even: byte += 1
+ rotate left by `rotate` positions
+```
+
+`rotate` is always 4 in this implementation. See `func_replacements.js` for the Frida-based original C implementation.
+
+## Resolution values
+
+| Value | Resolution |
+| ----- | ------------ |
+| 1 | 320x240 |
+| 2 | 640x480 |
+| 3 | 640x480 (X5) |
+| 4 | 640x480 (X5) |
+
+## Wireshark dissector
+
+A partial Wireshark dissector is included at `dissector.lua`. It registers on UDP port 32108 and decodes all command types with in-place deobfuscation.
+
+Install with:
+
+```bash
+make install-wireshark-dissector
+```
diff --git a/docs/reversing.md b/docs/reversing.md
new file mode 100644
index 0000000..aafb22a
--- /dev/null
+++ b/docs/reversing.md
@@ -0,0 +1,71 @@
+# Reverse Engineering
+
+Notes on how the iLnkP2P protocol was reverse-engineered.
+
+## Tools used
+
+- [Ghidra](https://ghidra-sre.org/) -- static analysis of `libvdp.so` from the APK
+- [Frida](https://frida.re/docs/javascript-api/) -- dynamic analysis and function hooking
+- Wireshark -- network traffic analysis with custom dissector
+
+## Sources
+
+The interesting implementation is in `libvdp.so`, part of the YsxLite APK bundle.
+
+### Extracting the APK
+
+```bash
+adb shell pm list packages | grep ysx
+adb shell pm path com.ysxlite.cam
+adb shell pm path com.ysxlite.cam | while read -r line ; do
+ adb pull $(echo $line | cut -d: -f2-)
+done
+```
+
+### Installing on a test device
+
+```bash
+adb install-multiple *apk
+```
+
+### Frida setup
+
+[Android docs](https://frida.re/docs/android/)
+
+Start frida server:
+
+```bash
+adb shell 'su -c nohup /data/local/tmp/frida-server-16.1.11-android-arm64 &'
+```
+
+### Files
+
+| File | Description |
+| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `func_replacements.js` | Frida hooks -- `Interceptor.replace` for `NetCmd`, `CmdSndPush`, `AvCmd`, `SystemCmd`. Contains JS translations of the original C functions: `XqBytesEnc`/`XqBytesDec`, packet builders |
+| `frida-hooks.js` | Playground for Frida hooks (mostly cleaned up) |
+| `dissector.lua` | Wireshark dissector for the iLnkP2P protocol on UDP port 32108 |
+| `types/all.h` | Ghidra-reversed header definitions (barely used by this implementation) |
+
+## Ghidra headers
+
+Reversed struct/enum definitions are in `types/all.h`. They document the C structures used by `libvdp.so` but are not directly used by the TypeScript implementation.
+
+## Wireshark dissector
+
+Install:
+
+```bash
+make install-wireshark-dissector
+```
+
+The dissector (`dissector.lua`) registers on UDP port 32108 with heuristic detection. It decodes all command types, Drw control/data packets, PunchPkt serial numbers, and performs in-place deobfuscation of encrypted payloads.
+
+## Serial debugging
+
+The A9 cameras have TX/RX test points. UART at 921600 8N1 gives read-only access to debug logs.
+
+## Discrepancies between cameras
+
+- A9 reports 100% WiFi strength; X5 reports actual values
+- Video resolution values 3 and 4 both map to 640x480 on X5
diff --git a/docs/rtsp.md b/docs/rtsp.md
new file mode 100644
index 0000000..0cd7aff
--- /dev/null
+++ b/docs/rtsp.md
@@ -0,0 +1,222 @@
+# RTSP Server
+
+Native RTSP server for streaming camera video to NVRs, Blue Iris, Home Assistant, VLC, and any RTSP-compatible client.
+
+## Quick start
+
+```bash
+node dist/bin.cjs rtsp_server --discovery_ip 192.168.1.255
+```
+
+Connect to `rtsp://:8554/camera`. No credentials required.
+
+## Installing GStreamer (H.264 mode)
+
+The H.264 transcoding mode requires GStreamer and the `openh264enc` encoder. If GStreamer is not installed, the server falls back to JPEG/RTP automatically.
+
+### Ubuntu / Debian
+
+```bash
+sudo apt install gstreamer1.0-tools \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good \
+ gstreamer1.0-plugins-bad
+```
+
+### Fedora
+
+```bash
+sudo dnf install gstreamer1-plugins-base \
+ gstreamer1-plugins-good \
+ gstreamer1-plugins-bad-free
+```
+
+### Arch Linux
+
+```bash
+sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad
+```
+
+### Verify
+
+```bash
+gst-launch-1.0 --version
+```
+
+If this prints a version number, the RTSP server will automatically use H.264 mode.
+
+## Streaming modes
+
+The server selects a mode automatically at startup based on GStreamer availability.
+
+### H.264 mode (GStreamer, recommended)
+
+When GStreamer is installed, JPEG frames from the camera are transcoded to H.264. This mode is compatible with virtually all NVRs.
+
+```
+Camera (iLnkP2P/UDP)
+ -> JPEG frames (1028-byte fragments)
+ -> handlers.ts (frame assembly)
+ -> GStreamer (transcoder.ts): JPEG -> openh264enc -> rtph264pay
+ -> RTP/AVP/TCP or RTP/AVP/UDP
+ -> NVR / VLC / Android
+```
+
+### JPEG/RTP mode (no GStreamer)
+
+Without GStreamer, JPEG is streamed directly via RTP/JPEG (RFC 2435). Works with VLC and Android, but not all NVRs support JPEG/RTP.
+
+```
+Camera (iLnkP2P/UDP)
+ -> JPEG frames (1028-byte fragments)
+ -> handlers.ts (frame assembly)
+ -> RTP/JPEG packetization (RFC 2435)
+ -> RTP/AVP/TCP or RTP/AVP/UDP
+ -> NVR / VLC / Android
+```
+
+## Transport modes
+
+The server supports both TCP interleaved and UDP unicast, auto-detected from the client's SETUP request.
+
+### TCP interleaved (RFC 2326)
+
+```
+Transport: RTP/AVP/TCP;unicast;interleaved=0-1
+```
+
+- RTP on channel 0, RTCP on channel 1
+- Framed as `$<2-byte-length>`
+- More reliable, works through firewalls/NAT
+- Default for most RTSP clients
+
+### UDP unicast
+
+```
+Transport: RTP/AVP/UDP;unicast;client_port=50000-50001
+```
+
+- RTP to `client_ip:50000`, RTCP to `client_ip:50001`
+- Server responds with `server_port=-`
+
+## SDP
+
+### H.264 SDP
+
+```
+v=0
+o=- 0 0 IN IP4
+s=cam-reverse
+c=IN IP4
+t=0 0
+a=control:*
+m=video 0 RTP/AVP 96
+a=rtpmap:96 H264/90000
+a=fmtp:96 packetization-mode=1; profile-level-id=42C01E
+a=control:trackID=0
+```
+
+- Payload type 96 (dynamic)
+- H.264 Constrained Baseline Profile, Level 3.0
+- Non-interleaved packetization mode (single NAL + FU-A)
+- SPS/PPS sent inline with every IDR frame (`config-interval=-1`)
+
+### JPEG SDP
+
+```
+v=0
+o=- 0 0 IN IP4
+s=cam-reverse
+c=IN IP4
+t=0 0
+a=control:*
+m=video 0 RTP/AVP 26
+a=rtpmap:26 JPEG/90000
+a=fmtp:26 quantization=255; width=640; height=480
+a=control:trackID=0
+```
+
+- Payload type 26 (static, RFC 2435)
+- Quantization tables embedded in first RTP fragment
+
+## RTSP methods
+
+| Method | Description |
+| --------------- | ------------------------------------ |
+| `OPTIONS` | Returns supported methods |
+| `DESCRIBE` | Returns SDP |
+| `SETUP` | Negotiates transport (TCP or UDP) |
+| `PLAY` | Starts streaming |
+| `GET_PARAMETER` | Keepalive response (LIVE555 clients) |
+| `SET_PARAMETER` | Keepalive response |
+| `TEARDOWN` | Stops stream, closes session |
+
+## RTCP Sender Reports
+
+Sent every 5 seconds on the RTCP channel per RFC 3550 section 6.4.1. Required by many NVRs for stream liveness detection and timing synchronization.
+
+Contents: NTP timestamp, RTP timestamp, packet count, octet count.
+
+## RTP packetization
+
+### RTP/JPEG (RFC 2435)
+
+```
+ 0 1 2 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+|V=2|P|X| CC |M| PT | Sequence Number |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Timestamp |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| SSRC |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Type-specific | Fragment Offset |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Type | Q | Width | Height |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Quantization Table (if Q=255, first fragment only) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Scan Data |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+- Type: 1, Q: 255 (tables present), Width/Height: divided by 8
+- Fragment offset: 24-bit, max 16 MB
+- MAX_RTP_PAYLOAD: 1400 bytes per fragment
+
+### RTP/H.264 (RFC 6184)
+
+Generated by GStreamer's `rtph264pay`:
+
+- **Single NAL**: Small NAL units in one RTP packet
+- **FU-A fragmentation**: Large NALs split across packets (FU indicator: `F|NRI|Type=28`, FU header: `S|E|R|Type`)
+- **STAP-A**: SPS/PPS aggregation
+- SPS/PPS sent with every IDR frame
+
+## Client compatibility
+
+| Client | H.264 | JPEG | Notes |
+| ----------------- | ----- | ----- | -------------------------------------- |
+| VLC | Yes | Yes | Set RTP over RTSP (TCP) in preferences |
+| Android (YsxLite) | Yes | Yes | -- |
+| Generic NVR | Yes | Maybe | Most NVRs require H.264 |
+| Blue Iris | Yes | Yes | Add as Generic RTSP |
+| Home Assistant | Yes | Yes | Generic camera integration |
+
+## Options
+
+| Option | Default | Description |
+| ---------------- | ----------- | -------------------------- |
+| `--port` | `8554` | RTSP port |
+| `--discovery_ip` | from config | Camera discovery IP |
+| `--config_file` | -- | YAML config path |
+| `--log_level` | `info` | `debug`, `info`, `warning` |
+
+## Troubleshooting
+
+- **No video in NVR**: Check H.264 support; use `--log_level debug`
+- **GStreamer not detected**: Run `gst-launch-1.0 --version`; falls back to JPEG mode
+- **High latency**: openh264enc configured for low latency; typical 100-300ms
+- **Black screen in VLC**: Enable RTP over RTSP (TCP) in codec preferences
+- **Connection refused**: Check port, firewall rules, UDP port range for UDP mode
diff --git a/http_server.ts b/http_server.ts
index eba7cc4..fef73b1 100644
--- a/http_server.ts
+++ b/http_server.ts
@@ -119,19 +119,151 @@ export const serveHttp = (port: number) => {
logger.info(`Video stream closed for camera ${devId}`);
});
} else {
- res.write("");
- res.write("");
- res.write(``);
- res.write("All cameras");
- res.write("");
- res.write("");
- res.write("