diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f32bb35 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +.git +.github +pics +docs +tests +scripts +types +diagrams +frida-hooks.js +dissector.lua +loader3.py +mock_server.ts diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index b64ecc9..8064360 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,13 +5,12 @@ name: Node.js CI on: push: - branches: [ "master" ] + branches: ["master"] pull_request: - branches: [ "master" ] + branches: ["master"] jobs: build: - runs-on: ubuntu-latest strategy: @@ -21,17 +20,17 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run tsc - - run: npm run build - - run: npm test - - uses: actions/upload-artifact@v4 - with: - name: bundle-${{ matrix.node-version }} - path: dist/bin.cjs + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + - run: npm run tsc + - run: npm run build + - run: npm test + - uses: actions/upload-artifact@v4 + with: + name: bundle-${{ matrix.node-version }} + path: dist/bin.cjs diff --git a/.mocharc.json b/.mocharc.json index 1b2a178..282f942 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,8 +1,4 @@ { "extensions": ["ts", "js"], - "node-option": [ - "experimental-specifier-resolution=node", - "loader=ts-node/esm" - ] + "node-option": ["experimental-specifier-resolution=node", "loader=ts-node/esm"] } - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0dac09 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:22-slim AS build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ── runtime ────────────────────────────────────────────── +FROM node:22-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gstreamer1.0-tools \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + openh264 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=build /app/dist/bin.cjs ./dist/bin.cjs + +RUN useradd -r -s /usr/sbin/nologin cam +USER cam + +EXPOSE 8554/udp +EXPOSE 8554/tcp + +ENTRYPOINT ["node", "dist/bin.cjs", "rtsp_server"] +CMD ["--discovery_ip", "192.168.1.255"] diff --git a/README.md b/README.md index ba571c5..74f3353 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,84 @@ -Re-implementation of the "iLnk"/"iLnkP2P"/"PPPP" protocol used on some cheap (\<$5) IP cameras (sometimes branded as 'X5' or 'A9'). +

+ X5 Camera +

-* 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: - -![](pics/web-ui.jpg?raw=true) - -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: - -![](diagrams/packet.svg) - -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**: - -![](diagrams/control_packet.svg) - -The payload on control packets is "encrypted" when the length is > 5. - -**Data packets**: - -![](diagrams/data_packet.svg) - -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`. -![](pics/packet_loss_good.jpg?raw=true) +## 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 | -![](pics/packet_loss_bad.jpg?raw=true) +## Camera PCB -which _moves_ the rest of the image, causing more visual noise. +

+ X5 PCB +

-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 | +| ----------------------------------- | ----------------------------------- | +| ![](../pics/mobileAll.png?raw=true) | ![](../pics/mobileCam.png?raw=true) | + +**Desktop:** + +| Dashboard | Camera View | +| ------------------------------- | ------------------------------- | +| ![](../pics/pcAll.png?raw=true) | ![](../pics/pcCam.png?raw=true) | + +## 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: + +![](../diagrams/packet.svg) + +The `Drw` command (`0xf1d0`) carries both control and data payloads, discriminated by the second byte. + +### Control packets + +![](../diagrams/control_packet.svg) + +Payloads longer than 5 bytes are obfuscated with `XqBytesEnc` (XOR-rotation, see below). + +### Data packets + +![](../diagrams/data_packet.svg) + +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("

All cameras


"); - Object.keys(sessions).forEach((id) => - res.write(`

${cameraName(id)}


`), - ); - res.write(""); - res.write(""); - res.end(); + const cameraCards = Object.keys(sessions) + .map((id) => { + const s = sessions[id]; + const cls = s.connected ? "online" : "offline"; + const label = s.connected ? "Online" : "Offline"; + return `
+
+

${cameraName(id)}

+ ${label} +
+ + Live feed from ${cameraName(id)} + + +
`; + }) + .join(""); + res.end(` + + + + + +All Cameras - Cam Reverse RTSP + + + +
+
+

Cam Reverse RTSP

+ Active +
+
+ + GitHub +
+
+
+
+

Cameras

+ ${Object.keys(sessions).length} device(s) +
+ +
+
+ ${cameraCards || '

No cameras discovered

Waiting for devices to appear on the network...

'} +
+ + + +`); } }); @@ -162,7 +294,9 @@ export const serveHttp = (port: number) => { const exifSegment = orientations[orientation]; const jpegHeader = addExifToJpeg(s.curImage[0], exifSegment); const assembled = Buffer.concat([jpegHeader, ...s.curImage.slice(1)]); - const header = Buffer.from(`\r\n--${BOUNDARY}\r\nContent-Length: ${assembled.length}\r\nContent-Type: image/jpeg\r\n\r\n`); + const header = Buffer.from( + `\r\n--${BOUNDARY}\r\nContent-Length: ${assembled.length}\r\nContent-Type: image/jpeg\r\n\r\n`, + ); responses[dev.devId].forEach((res) => { res.write(header); res.write(assembled); diff --git a/package-lock.json b/package-lock.json index 1ffa07c..d4a809d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "cam-reverse", + "name": "cam-reverse-rtsp", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/pics/mobileAll.png b/pics/mobileAll.png new file mode 100644 index 0000000..e042568 Binary files /dev/null and b/pics/mobileAll.png differ diff --git a/pics/mobileCam.png b/pics/mobileCam.png new file mode 100644 index 0000000..3218bcb Binary files /dev/null and b/pics/mobileCam.png differ diff --git a/pics/pcAll.png b/pics/pcAll.png new file mode 100644 index 0000000..5714c7e Binary files /dev/null and b/pics/pcAll.png differ diff --git a/pics/pcCam.png b/pics/pcCam.png new file mode 100644 index 0000000..e7a2b45 Binary files /dev/null and b/pics/pcCam.png differ diff --git a/pics/web-ui.jpg b/pics/web-ui.jpg deleted file mode 100644 index 8854ae1..0000000 Binary files a/pics/web-ui.jpg and /dev/null differ diff --git a/rtsp_server.ts b/rtsp_server.ts new file mode 100644 index 0000000..0f526c3 --- /dev/null +++ b/rtsp_server.ts @@ -0,0 +1,590 @@ +import * as net from "node:net"; +import * as os from "node:os"; +import * as dgram from "node:dgram"; +import { RemoteInfo } from "dgram"; + +import { logger } from "./logger.js"; +import { config } from "./settings.js"; +import { discoverDevices } from "./discovery.js"; +import { DevSerial } from "./impl.js"; +import { Handlers, makeSession, Session, startVideoStream } from "./session.js"; +import { isGStreamerAvailable, createTranscoder, Transcoder } from "./transcoder.js"; + +// RTP payload type 26 = JPEG (RFC 2435) +const MAX_RTP_PAYLOAD = 1400; +// 90kHz clock, ~15fps increment +const TIMESTAMP_INCREMENT = 6000; +// NTP epoch offset: seconds between 1900-01-01 and 1970-01-01 +const NTP_EPOCH_OFFSET = 2208988800; +// RTCP Sender Report interval (ms) +const RTCP_SR_INTERVAL_MS = 5000; + +type TransportMode = "tcp" | "udp"; + +type RtspSession = { + socket: net.Socket; + clientIp: string; + sessionId: string; + playing: boolean; + seqNum: number; + timestamp: number; + ssrc: number; + rtpChannel: number; + rtcpChannel: number; + packetCount: number; + octetCount: number; + rtcpTimer: ReturnType | null; + transport: TransportMode; + udpSocket: dgram.Socket | null; + clientRtpPort: number; + clientRtcpPort: number; + serverRtpPort: number; +}; + +// Get the primary local IP (non-loopback) +function getLocalIp(): string { + const ifaces = os.networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const iface of ifaces[name] ?? []) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address; + } + } + } + return "127.0.0.1"; +} + +// Parse JPEG SOF0 marker to extract width/height +function parseJpegDimensions(jpeg: Buffer): { width: number; height: number } { + for (let i = 0; i < jpeg.length - 8; i++) { + if (jpeg[i] === 0xff && jpeg[i + 1] === 0xc0) { + return { + height: jpeg.readUInt16BE(i + 5), + width: jpeg.readUInt16BE(i + 7), + }; + } + } + return { width: 640, height: 480 }; +} + +// Extract DQT quantization tables from JPEG +function extractQTables(jpeg: Buffer): Buffer | null { + const tables: Buffer[] = []; + let i = 0; + while (i < jpeg.length - 4) { + if (jpeg[i] !== 0xff) { + i++; + continue; + } + const marker = jpeg[i + 1]; + if (marker === 0xd8) { + i += 2; + continue; + } + if (marker === 0xd9 || marker === 0xda) break; + const segLen = jpeg.readUInt16BE(i + 2); + if (marker === 0xdb) { + tables.push(jpeg.slice(i + 5, i + 2 + segLen)); + } + i += 2 + segLen; + } + return tables.length > 0 ? Buffer.concat(tables) : null; +} + +// Build a single RTP packet (RFC 2435 JPEG payload) +function buildRtpPacket( + chunk: Buffer, + fragmentOffset: number, + isLast: boolean, + isFirst: boolean, + seqNum: number, + timestamp: number, + ssrc: number, + width: number, + height: number, + qTable: Buffer | null, +): Buffer { + let qHeader: Buffer | null = null; + if (isFirst && qTable) { + qHeader = Buffer.alloc(4 + qTable.length); + qHeader[0] = 0; + qHeader[1] = 0; + qHeader.writeUInt16BE(qTable.length, 2); + qTable.copy(qHeader, 4); + } + + const headerSize = 12 + 8 + (qHeader ? qHeader.length : 0); + const pkt = Buffer.alloc(headerSize + chunk.length); + let off = 0; + + // RTP fixed header (12 bytes) + pkt[off++] = 0x80; + pkt[off++] = (isLast ? 0x80 : 0x00) | 26; // M bit + PT=26 (JPEG) + pkt.writeUInt16BE(seqNum & 0xffff, off); + off += 2; + pkt.writeUInt32BE(timestamp >>> 0, off); + off += 4; + pkt.writeUInt32BE(ssrc >>> 0, off); + off += 4; + + // JPEG RTP header (8 bytes, RFC 2435 §3.1) + pkt[off++] = 0; + pkt[off++] = (fragmentOffset >> 16) & 0xff; + pkt[off++] = (fragmentOffset >> 8) & 0xff; + pkt[off++] = fragmentOffset & 0xff; + pkt[off++] = 1; // type = YUV 4:2:2 + pkt[off++] = qTable ? 255 : 10; + pkt[off++] = Math.min(255, Math.floor(width / 8)); + pkt[off++] = Math.min(255, Math.floor(height / 8)); + + if (isFirst && qHeader) { + qHeader.copy(pkt, off); + off += qHeader.length; + } + + chunk.copy(pkt, off); + return pkt; +} + +// Wrap RTP in RTSP interleaved framing: $ | channel(1) | length(2) | data +function interleavedFrame(channel: number, data: Buffer): Buffer { + const frame = Buffer.alloc(4 + data.length); + frame[0] = 0x24; // '$' + frame[1] = channel; + frame.writeUInt16BE(data.length, 2); + data.copy(frame, 4); + return frame; +} + +// Build RTCP Sender Report (RFC 3550 §6.4.1) +function buildRtcpSr(ssrc: number, rtpTimestamp: number, packetCount: number, octetCount: number): Buffer { + const now = Date.now() / 1000; + const ntpSec = Math.floor(now) + NTP_EPOCH_OFFSET; + const ntpFrac = Math.floor((now % 1) * 0x100000000); + + const pkt = Buffer.alloc(28); + pkt[0] = 0x80; // V=2, P=0, RC=0 + pkt[1] = 200; // PT = SR (200) + pkt.writeUInt16BE(6, 2); // Length = 6 (28 bytes / 4 - 1) + pkt.writeUInt32BE(ssrc >>> 0, 4); + pkt.writeUInt32BE(ntpSec >>> 0, 8); + pkt.writeUInt32BE(ntpFrac >>> 0, 12); + pkt.writeUInt32BE(rtpTimestamp >>> 0, 16); + pkt.writeUInt32BE(packetCount >>> 0, 20); + pkt.writeUInt32BE(octetCount >>> 0, 24); + return pkt; +} + +// Send periodic RTCP Sender Report on the RTCP interleaved channel +function sendRtcpSr(sess: RtspSession): void { + if (!sess.playing || sess.socket.destroyed) return; + const sr = buildRtcpSr(sess.ssrc, sess.timestamp, sess.packetCount, sess.octetCount); + try { + if (sess.transport === "udp" && sess.udpSocket) { + sess.udpSocket.send(sr, sess.clientRtcpPort, sess.clientIp); + } else { + const frame = interleavedFrame(sess.rtcpChannel, sr); + sess.socket.write(frame); + } + } catch (e) { + logger.debug(`RTCP write error: ${e}`); + } +} + +// Send one JPEG frame as RTP over TCP (interleaved) or UDP +function sendFrameOverTcp(sess: RtspSession, jpeg: Buffer): void { + if (!sess.playing || sess.socket.destroyed) return; + + const { width, height } = parseJpegDimensions(jpeg); + const qTable = extractQTables(jpeg); + + // Find SOS marker — RFC 2435 carries scan data only + let dataStart = 0; + for (let i = 0; i < jpeg.length - 1; i++) { + if (jpeg[i] === 0xff && jpeg[i + 1] === 0xda) { + const sosLen = jpeg.readUInt16BE(i + 2); + dataStart = i + 2 + sosLen; + break; + } + } + const payload = dataStart > 0 ? jpeg.slice(dataStart) : jpeg; + + let fragmentOffset = 0; + let isFirst = true; + + while (fragmentOffset < payload.length) { + const chunkSize = Math.min(MAX_RTP_PAYLOAD, payload.length - fragmentOffset); + const chunk = payload.slice(fragmentOffset, fragmentOffset + chunkSize); + const isLast = fragmentOffset + chunkSize >= payload.length; + + const rtp = buildRtpPacket( + chunk, + fragmentOffset, + isLast, + isFirst, + sess.seqNum, + sess.timestamp, + sess.ssrc, + width, + height, + isFirst ? qTable : null, + ); + + try { + if (sess.transport === "udp" && sess.udpSocket) { + sess.udpSocket.send(rtp, sess.clientRtpPort, sess.clientIp); + } else { + const frame = interleavedFrame(sess.rtpChannel, rtp); + sess.socket.write(frame); + } + } catch (e) { + logger.debug(`RTP write error: ${e}`); + return; + } + + sess.seqNum = (sess.seqNum + 1) & 0xffff; + sess.packetCount++; + sess.octetCount += chunk.length; + fragmentOffset += chunkSize; + isFirst = false; + } + + sess.timestamp = (sess.timestamp + TIMESTAMP_INCREMENT) >>> 0; +} + +export const serveRtsp = async (port: number) => { + const localIp = getLocalIp(); + const rtspSessions = new Map(); + const ssrc = Math.floor(Math.random() * 0xffffffff); + + const useH264 = await isGStreamerAvailable(); + if (useH264) { + logger.info("GStreamer detected — serving H.264 via openh264enc"); + } else { + logger.info("GStreamer not available — serving JPEG (RFC 2435)"); + } + + const buildJpegSdp = (): string => { + return [ + "v=0", + `o=- 0 0 IN IP4 ${localIp}`, + "s=cam-reverse", + `c=IN IP4 ${localIp}`, + "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", + "", + ].join("\r\n"); + }; + + const buildH264Sdp = (): string => { + return [ + "v=0", + `o=- 0 0 IN IP4 ${localIp}`, + "s=cam-reverse", + `c=IN IP4 ${localIp}`, + "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", + "", + ].join("\r\n"); + }; + + const buildSdp = (): string => (useH264 ? buildH264Sdp() : buildJpegSdp()); + + const parseHeaders = (raw: string): Record => { + const headers: Record = {}; + for (const line of raw.split("\r\n").slice(1)) { + const idx = line.indexOf(": "); + if (idx !== -1) headers[line.slice(0, idx).toLowerCase()] = line.slice(idx + 2); + } + return headers; + }; + + const tcpServer = net.createServer((socket) => { + const clientIp = (socket.remoteAddress ?? "").replace("::ffff:", ""); + logger.info(`RTSP client connected from ${clientIp}`); + + let buf = ""; + let activeSessId: string | null = null; + let processing = false; + + socket.setEncoding("binary"); + + socket.on("data", async (data) => { + buf += data; + if (processing) return; + processing = true; + + while (buf.includes("\r\n\r\n")) { + const end = buf.indexOf("\r\n\r\n") + 4; + const raw = buf.slice(0, end); + buf = buf.slice(end); + + const firstLine = raw.split("\r\n")[0]; + const [method, uri] = firstLine.split(" "); + const headers = parseHeaders(raw); + const cseq = headers["cseq"] ?? "0"; + + logger.debug(`RTSP ${method} ${uri}`); + + switch (method) { + case "OPTIONS": + socket.write( + `RTSP/1.0 200 OK\r\n` + `CSeq: ${cseq}\r\n` + `Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN\r\n\r\n`, + ); + break; + + case "DESCRIBE": { + const sdp = buildSdp(); + socket.write( + `RTSP/1.0 200 OK\r\n` + + `CSeq: ${cseq}\r\n` + + `Content-Type: application/sdp\r\n` + + `Content-Base: rtsp://${localIp}:${port}/camera/\r\n` + + `Content-Length: ${Buffer.byteLength(sdp, "utf8")}\r\n\r\n` + + sdp, + ); + break; + } + + case "SETUP": { + const transport = headers["transport"] ?? ""; + const chanMatch = transport.match(/interleaved=(\d+)-(\d+)/); + const portMatch = transport.match(/client_port=(\d+)-(\d+)/); + const useUdp = !chanMatch && !!portMatch; + + const rtpChan = chanMatch ? parseInt(chanMatch[1]) : 0; + const rtcpChan = chanMatch ? parseInt(chanMatch[2]) : 1; + const clientRtpPort = portMatch ? parseInt(portMatch[1]) : 0; + const clientRtcpPort = portMatch ? parseInt(portMatch[2]) : 0; + + // Reuse existing session for this socket if it exists (NVR may + // send multiple SETUP for different tracks on same connection) + const existingSid = headers["session"]?.split(";")[0].trim(); + const sessionId = + existingSid && rtspSessions.has(existingSid) ? existingSid : Math.random().toString(36).slice(2, 12); + activeSessId = sessionId; + + // For UDP: create a bound UDP socket + let udpSocket: dgram.Socket | null = null; + let serverRtpPort = 0; + if (useUdp) { + udpSocket = dgram.createSocket("udp4"); + // Bind to port 0 → OS picks an available port + // Use a Promise-based bind + address read + await new Promise((resolve) => { + udpSocket!.bind(0, () => { + const addr = udpSocket!.address(); + serverRtpPort = addr.port; + resolve(); + }); + }); + logger.info( + `UDP transport: server port ${serverRtpPort}, client ${clientIp}:${clientRtpPort}-${clientRtcpPort}`, + ); + } + + rtspSessions.set(sessionId, { + socket, + clientIp, + sessionId, + playing: false, + seqNum: Math.floor(Math.random() * 0xffff), + timestamp: Math.floor(Math.random() * 0xffffffff), + ssrc, + rtpChannel: rtpChan, + rtcpChannel: rtcpChan, + packetCount: 0, + octetCount: 0, + rtcpTimer: null, + transport: useUdp ? "udp" : "tcp", + udpSocket, + clientRtpPort, + clientRtcpPort, + serverRtpPort, + }); + + if (useUdp) { + socket.write( + `RTSP/1.0 200 OK\r\n` + + `CSeq: ${cseq}\r\n` + + `Transport: RTP/AVP/UDP;unicast;client_port=${clientRtpPort}-${clientRtcpPort};server_port=${serverRtpPort}-${serverRtpPort + 1}\r\n` + + `Session: ${sessionId};timeout=60\r\n\r\n`, + ); + } else { + socket.write( + `RTSP/1.0 200 OK\r\n` + + `CSeq: ${cseq}\r\n` + + `Transport: RTP/AVP/TCP;unicast;interleaved=${rtpChan}-${rtcpChan}\r\n` + + `Session: ${sessionId};timeout=60\r\n\r\n`, + ); + } + break; + } + + case "PLAY": { + // Session header may include timeout suffix e.g. "g76g2gwwod;timeout=60" + const sid = headers["session"]?.split(";")[0].trim() ?? activeSessId ?? ""; + const sess = rtspSessions.get(sid); + if (sess) { + sess.playing = true; + // Start periodic RTCP Sender Reports + sess.rtcpTimer = setInterval(() => sendRtcpSr(sess), RTCP_SR_INTERVAL_MS); + logger.info(`RTSP PLAY from ${clientIp} session=${sid}`); + } else { + logger.debug(`RTSP PLAY: session ${sid} not found, known: ${[...rtspSessions.keys()].join(",")}`); + } + socket.write( + `RTSP/1.0 200 OK\r\n` + `CSeq: ${cseq}\r\n` + `Session: ${sid}\r\n` + `Range: npt=0.000-\r\n\r\n`, + ); + break; + } + + case "GET_PARAMETER": + case "SET_PARAMETER": { + // LIVE555 sends GET_PARAMETER as keepalive — must respond 200 + const sid = headers["session"]?.split(";")[0].trim() ?? activeSessId ?? ""; + socket.write(`RTSP/1.0 200 OK\r\n` + `CSeq: ${cseq}\r\n` + `Session: ${sid}\r\n\r\n`); + break; + } + + case "TEARDOWN": { + const sid = headers["session"]?.split(";")[0].trim() ?? activeSessId ?? ""; + const torn = rtspSessions.get(sid); + if (torn?.rtcpTimer) clearInterval(torn.rtcpTimer); + if (torn?.udpSocket) { + try { + torn.udpSocket.close(); + } catch (_) {} + } + rtspSessions.delete(sid); + logger.info(`RTSP TEARDOWN from ${clientIp}`); + socket.write(`RTSP/1.0 200 OK\r\n` + `CSeq: ${cseq}\r\n` + `Session: ${sid}\r\n\r\n`); + socket.destroy(); + break; + } + + default: + socket.write(`RTSP/1.0 501 Not Implemented\r\n` + `CSeq: ${cseq}\r\n\r\n`); + } + } + + processing = false; + }); + + socket.on("close", () => { + if (activeSessId) { + const closed = rtspSessions.get(activeSessId); + if (closed?.rtcpTimer) clearInterval(closed.rtcpTimer); + if (closed?.udpSocket) { + try { + closed.udpSocket.close(); + } catch (_) {} + } + rtspSessions.delete(activeSessId); + logger.info(`RTSP client ${clientIp} disconnected`); + } + }); + + socket.on("error", (err) => { + logger.debug(`RTSP socket error: ${err.message}`); + }); + }); + + // Camera discovery + const camSessions: Record = {}; + const transcoders: Record = {}; + + const forwardRtp = (rtpPacket: Buffer) => { + for (const sess of rtspSessions.values()) { + if (!sess.playing || sess.socket.destroyed) continue; + try { + if (sess.transport === "udp" && sess.udpSocket) { + sess.udpSocket.send(rtpPacket, sess.clientRtpPort, sess.clientIp); + } else { + const frame = interleavedFrame(sess.rtpChannel, rtpPacket); + sess.socket.write(frame); + } + } catch (_) {} + } + }; + + const startSession = (s: Session) => { + startVideoStream(s); + logger.info(`Camera ${s.devName} ready, streaming to RTSP`); + }; + + const devEv = discoverDevices(config.discovery_ips); + + devEv.on("discover", (rinfo: RemoteInfo, dev: DevSerial) => { + if (dev.devId in camSessions) { + logger.info(`Camera ${dev.devId} already discovered, ignoring`); + return; + } + + logger.info(`Discovered camera ${dev.devId} at ${rinfo.address}`); + config.cameras[dev.devId] = { + rotate: 0, + mirror: false, + audio: false, + ...(config.cameras[dev.devId] || {}), + }; + + const s = makeSession(Handlers, dev, rinfo, startSession, 5000); + camSessions[dev.devId] = s; + + if (useH264) { + const tc = createTranscoder(); + transcoders[dev.devId] = tc; + + tc.eventEmitter.on("rtp", (rtpPacket: Buffer) => { + forwardRtp(rtpPacket); + }); + + tc.eventEmitter.on("exit", () => { + if (!(dev.devId in camSessions)) { + logger.debug(`Transcoder for ${dev.devId} exited after camera disconnect, ignoring`); + return; + } + logger.warning(`Transcoder for ${dev.devId} exited, restarting...`); + delete transcoders[dev.devId]; + const newTc = createTranscoder(); + transcoders[dev.devId] = newTc; + newTc.eventEmitter.on("rtp", forwardRtp); + }); + + s.eventEmitter.on("frame", () => { + const jpeg = Buffer.concat(s.curImage); + tc.writeJpeg(jpeg); + }); + } else { + s.eventEmitter.on("frame", () => { + const jpeg = Buffer.concat(s.curImage); + for (const sess of rtspSessions.values()) { + sendFrameOverTcp(sess, jpeg); + } + }); + } + + s.eventEmitter.on("disconnect", () => { + logger.info(`Camera ${dev.devId} disconnected`); + if (transcoders[dev.devId]) { + transcoders[dev.devId].close(); + delete transcoders[dev.devId]; + } + delete camSessions[dev.devId]; + }); + }); + + tcpServer.listen(port, () => { + logger.info(`RTSP server listening on rtsp://${localIp}:${port}/camera`); + logger.info(`Connect your NVR/VLC to: rtsp://${localIp}:${port}/camera`); + }); +}; diff --git a/session.ts b/session.ts index a2e2745..c0e9e7b 100644 --- a/session.ts +++ b/session.ts @@ -45,11 +45,9 @@ type msgCb = ( ) => void; const handleIncoming: msgCb = (session, handlers, msg, rinfo) => { - const ab = new Uint8Array(msg).buffer; - const dv = new DataView(ab); + const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); const raw = dv.readU16(); const cmd = CommandsByValue[raw]; - logger.log("trace", `<< ${cmd}`); handlers[cmd](session, dv, rinfo); if (raw != Commands.P2PAlive && raw != Commands.P2PAliveAck) { session.lastReceivedPacket = Date.now(); @@ -74,6 +72,9 @@ export const makeSession = ( sock.on("message", (msg, rinfo) => handleIncoming(session, handlers, msg, rinfo)); sock.on("listening", () => { + try { + sock.setRecvBufferSize(1024 * 1024); + } catch (_) {} const buf = makeP2pRdy(dev); session.send(buf); session.started = true; @@ -100,7 +101,6 @@ export const makeSession = ( const { sent_ts, data } = value; if (now - sent_ts > 100) { const pkt_id = data.add(6).readU16(); - logger.debug(`Resending packet ${pkt_id} as ${session.outgoingCommandId}`); data.add(6).writeU16(session.outgoingCommandId); session.outgoingCommandId++; delete session.unackedDrw[key]; @@ -120,18 +120,14 @@ export const makeSession = ( started: false, send: (msg: DataView) => { const raw = msg.readU16(); - const cmd = CommandsByValue[raw]; // send command if (raw == 0xf1d0 && msg.add(4).readU8() == 0xd1) { const packet_id = msg.add(6).readU16(); - logger.debug(`Sending Drw Packet with id ${packet_id}`); unackedDrw[packet_id] = { sent_ts: Date.now(), data: msg }; } - logger.log("trace", `>> ${cmd}`); sock.send(new Uint8Array(msg.buffer), ra.port, session.dst_ip); }, ackDrw: (id: number) => { - logger.debug(`Removing ${id} from pending`); delete unackedDrw[id]; }, dst_ip: ra.address, diff --git a/transcoder.ts b/transcoder.ts new file mode 100644 index 0000000..6e63e32 --- /dev/null +++ b/transcoder.ts @@ -0,0 +1,192 @@ +import { spawn, ChildProcess } from "node:child_process"; +import * as dgram from "node:dgram"; +import { EventEmitter } from "node:events"; + +import { logger } from "./logger.js"; + +export type Transcoder = { + eventEmitter: EventEmitter; + writeJpeg: (jpeg: Buffer) => void; + close: () => void; + getSps: () => Buffer | null; + getPps: () => Buffer | null; +}; + +export async function isGStreamerAvailable(): Promise { + const gstVersion = await new Promise((resolve) => { + const proc = spawn("gst-launch-1.0", ["--version"]); + let out = ""; + proc.stdout?.on("data", (d: Buffer) => { + out += d; + }); + proc.stderr?.on("data", (d: Buffer) => { + out += d; + }); + proc.on("close", (code) => { + if (code === 0) logger.info(`GStreamer available: ${out.split("\n")[0]}`); + resolve(code === 0); + }); + proc.on("error", () => resolve(false)); + }); + + if (!gstVersion) return false; + + const hasPlugin = await new Promise((resolve) => { + const proc = spawn("gst-launch-1.0", [ + "-q", + "videotestsrc", + "num-buffers=1", + "!", + "videoconvert", + "!", + "openh264enc", + "!", + "fakesink", + ]); + proc.on("close", (code) => resolve(code === 0)); + proc.on("error", () => resolve(false)); + }); + + if (!hasPlugin) { + logger.warning("GStreamer found but openh264enc plugin missing — falling back to JPEG/RTP"); + } + + return hasPlugin; +} + +function extractNalType(rtp: Buffer): number | null { + if (rtp.length < 13) return null; + const p = 12; // RTP header start + const first = rtp[p]; + const nalType = first & 0x1f; + + if (nalType === 28) { + // FU-A fragmentation + if (rtp.length < 14) return null; + const fuHeader = rtp[p + 1]; + if (!(fuHeader & 0x80)) return null; // not first fragment + return fuHeader & 0x1f; + } + return nalType; +} + +export function createTranscoder(): Transcoder { + const eventEmitter = new EventEmitter(); + const udpSocket = dgram.createSocket("udp4"); + let gstProcess: ChildProcess | null = null; + let sps: Buffer | null = null; + let pps: Buffer | null = null; + let stdinBackpressure = false; + let droppedFrames = 0; + + udpSocket.on("message", (msg: Buffer) => { + const nalType = extractNalType(msg); + if (nalType === 7) { + sps = Buffer.from(msg.subarray(12)); + logger.debug(`Transcoder: SPS captured (${sps.length} bytes)`); + } else if (nalType === 8) { + pps = Buffer.from(msg.subarray(12)); + logger.debug(`Transcoder: PPS captured (${pps.length} bytes)`); + } + eventEmitter.emit("rtp", msg); + }); + + udpSocket.on("error", (err) => { + logger.error(`Transcoder UDP error: ${err.message}`); + }); + + udpSocket.bind(0, () => { + const port = udpSocket.address().port; + + const args = [ + "fdsrc", + "fd=0", + "!", + "jpegdec", + "!", + "videoconvert", + "!", + "openh264enc", + "complexity=low", + "bitrate=300000", + "gop-size=15", + "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}`, + ]; + + gstProcess = spawn("gst-launch-1.0", args, { stdio: ["pipe", "ignore", "pipe"] }); + + let stderrOutput = ""; + gstProcess.stderr?.on("data", (data: Buffer) => { + const line = data.toString().trim(); + if (line && !line.includes("Redistribute latency")) { + logger.debug(`GStreamer: ${line}`); + stderrOutput += line + "\n"; + } + }); + + gstProcess.on("close", (code) => { + if (code !== 0) { + logger.warning(`GStreamer exited with code ${code}: ${stderrOutput.trim() || "(no error output)"}`); + } + gstProcess = null; + eventEmitter.emit("exit", code); + }); + + gstProcess.on("error", (err) => { + logger.error(`GStreamer spawn error: ${err.message}`); + eventEmitter.emit("error", err); + }); + + logger.info(`Transcoder ready, RTP on 127.0.0.1:${port}`); + eventEmitter.emit("ready"); + }); + + return { + eventEmitter, + writeJpeg: (jpeg: Buffer) => { + if (!gstProcess?.stdin || gstProcess.stdin.destroyed) return; + + if (stdinBackpressure) { + droppedFrames++; + if (droppedFrames % 30 === 1) { + logger.warning(`GStreamer stdin backpressure — dropped ${droppedFrames} frames`); + } + return; + } + + const ok = gstProcess.stdin.write(jpeg); + if (!ok) { + stdinBackpressure = true; + droppedFrames = 0; + gstProcess.stdin.once("drain", () => { + stdinBackpressure = false; + logger.debug("GStreamer stdin drained, resuming frame intake"); + }); + } + }, + close: () => { + if (gstProcess) { + gstProcess.stdin?.end(); + gstProcess.kill("SIGTERM"); + gstProcess = null; + } + try { + udpSocket.close(); + } catch (_) {} + }, + getSps: () => sps, + getPps: () => pps, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 4c1f0c3..83a0bbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { - "include": ["*.ts"], + "exclude": ["bin.ts"], "compilerOptions": { "target": "es6", "module": "es6", @@ -13,4 +13,3 @@ "sourceMap": false } } -