Compare commits

...

126 Commits

Author SHA1 Message Date
Alex X b5948cfb25 Update version to 1.9.14 2026-01-19 11:59:09 +03:00
Alex X c64fcc55a5 Update dependencies 2026-01-19 11:28:09 +03:00
Alex X fc22b20896 Fix ONVIF server support for Unifi Protect #1994 2026-01-18 22:47:04 +03:00
Alex X af819952e8 Improve ONVIF server support #1299 2026-01-18 21:51:05 +03:00
Alex X 159fb4675c Add starttimeout setting to exec source #1846 2026-01-18 16:07:46 +03:00
Alex X 54eafe9d0a Skip snapshot caching in case of error 2026-01-18 15:48:56 +03:00
Sergey Krashevich 38cc05c22d feat(jpeg): Add keyframe caching with expiration mechanism to JPEG http handler (#1155)
* feat(mjpeg): add keyframe caching with expiration and cleanup goroutine

* mjpeg: make keyframe cache duration and default usage configurable

* mjpeg: document and add config options for MJPEG snapshot caching

* mjpeg: fix errors after rebase

* Code refactoring for frame.jpeg cache #1155

---------

Co-authored-by: Alex X <alexey.khit@gmail.com>
2026-01-18 14:23:50 +03:00
Alex X 8c457710bd Merge pull request #2011 from seydx/wyze
Native Wyze camera support
2026-01-18 08:39:37 +03:00
Alex X 514188201a Move tutk dtls to separate package #2011 2026-01-18 08:38:38 +03:00
Alex X b65ee278cd Merge pull request #1991 from FIGIO55/onvif_info
Adding onvif name and hardware in info field of onvif discovery
2026-01-18 07:55:20 +03:00
Alex X 66225973ae Code refactoring for onvif device discovery #1991 2026-01-18 07:54:48 +03:00
seydx d40f6064d9 refactor dtls 2026-01-17 23:28:03 +01:00
seydx 9365fef7b3 move HL extraction to wyze client 2026-01-17 22:44:09 +01:00
seydx b220959e41 cleanup 2026-01-17 22:12:36 +01:00
seydx c493087876 fix intercom 2026-01-17 20:50:57 +01:00
seydx af90b4c12c update readme 2026-01-17 20:31:25 +01:00
seydx 679454aedb Merge branch 'master' into wyze 2026-01-17 20:31:05 +01:00
seydx 160695857e refactor 2026-01-17 20:22:08 +01:00
Alex X 6a5deecfcc Add multitrans source to readme 2026-01-17 21:53:37 +03:00
forrestsocool 572f07fcce Add two-way talk support for tp-link ipc camera (#1995)
* add tplink multitrans support

* 已经能成功播放音频了

* 初步能通讯了

* finish

* add tplink multitrans support

* 已经能成功播放音频了

* 初步能通讯了

* cleanup

* clean up

* Code refactoring for #1995

---------

Co-authored-by: Your Name <you@example.com>
Co-authored-by: Alex X <alexey.khit@gmail.com>
2026-01-17 21:39:49 +03:00
Aram Akhavan 9d1e4b11d7 Add EXPOSE to Dockerfile (#2010)
* Expose ports 1984 and 8554 in Dockerfile

* Update Dockerfile

* Update hardware.Dockerfile

* Expose additional ports in Rockchip Dockerfile

* Move docker expose ports to single line

---------

Co-authored-by: Alex X <alexey.khit@gmail.com>
2026-01-17 19:14:07 +03:00
Alex X f85cfdc214 Merge pull request #2019 from binary-person/patch-1
fix typo for inability to set channels for backchannel
2026-01-17 19:06:00 +03:00
Alex X 5780bf3720 Merge pull request #2023 from oeiber/patch-1
Update README with Doorbird support details
2026-01-17 18:41:28 +03:00
Alex X 338a3a6f03 Move doorbird docs to separate file 2026-01-17 18:40:18 +03:00
Alex X 6796bdabe2 Change header levels in readme 2026-01-17 18:30:06 +03:00
Alex X ebe454e3be Fix relative links in readme 2026-01-17 18:24:24 +03:00
seydx c03cd9f156 minor improvements 2026-01-17 12:09:38 +01:00
Alex X 1ec40f2fc3 Move schema.json to www/static 2026-01-17 13:57:05 +03:00
Alex X 790fdfbf7a Remove unnecessary codecs.html 2026-01-17 13:56:40 +03:00
Alex X 6d77b175d8 Move all JS libs to cdn.jsdelivr.net 2026-01-17 13:47:21 +03:00
seydx 8e6b8b32d6 Merge branch 'AlexxIT:master' into wyze 2026-01-17 09:40:49 +01:00
Alex X 425fcffbe1 Code refactoring for xiaomi source 2026-01-17 10:06:13 +03:00
Alex X 9a1eac8ef4 Add cache to xiaomi cloud logins 2026-01-17 09:41:41 +03:00
Alex X 4ffdeb06d0 Merge branch 'xiaomi'
# Conflicts:
#	pkg/xiaomi/miss/client.go
#	pkg/xiaomi/miss/cs2/conn.go
#	pkg/xiaomi/producer.go
2026-01-17 09:23:23 +03:00
Alex X 6b4eb8ffb6 Big rewrite tutk proto support 2026-01-17 09:05:54 +03:00
seydx cea524074a Merge branch 'wyze' of https://github.com/seydx/go2rtc into wyze 2026-01-15 22:52:04 +01:00
seydx 7498d0fba5 copy audio payload before processing in handleAudio function 2026-01-15 22:51:58 +01:00
seydx 50d9aab0d7 change pcm to pcml 2026-01-15 22:50:46 +01:00
seydx 3983ce3f4f Update Wyze Video Doorbell v2 details in README
Added model and version information for Wyze Video Doorbell v2.
2026-01-15 14:50:01 +01:00
seydx 0066da94f7 update wyze readme 2026-01-15 12:13:03 +01:00
seydx 4dbf53122e enable video and audio in buildK10002 again 2026-01-15 12:12:08 +01:00
seydx f96a074957 refactor timestamp handling 2026-01-15 01:25:35 +01:00
seydx dbd04cb972 refactor frame handling 2026-01-15 01:11:16 +01:00
seydx 0d035e5bce update ack hangling to improve streaming 2026-01-15 01:11:06 +01:00
seydx 7241759fea disable video and audio by default in buildK10002; start them later in probe 2026-01-15 01:10:41 +01:00
seydx b067c408c0 add missing codecs 2026-01-14 19:12:04 +01:00
seydx a99590823b allow to specify custom port 2026-01-14 19:11:53 +01:00
seydx 3bf2b316d7 Merge branch 'AlexxIT:master' into wyze 2026-01-13 21:50:10 +01:00
Alex X 2f43bfe5dc Fix two-way audio for cs2+tcp proto for xiaomi source 2026-01-13 21:26:01 +03:00
Alex X 59161fcef2 Improve cs2+udp proto for xiaomi source #2026 #2030 2026-01-13 21:25:30 +03:00
seydx 9ca9f96ea2 cleanup and update readme 2026-01-13 17:24:36 +01:00
seydx bc0c8d5577 use random session id in auth process 2026-01-13 16:48:41 +01:00
Alex X fd68107940 Add "bad conn" for debugging UDP 2026-01-13 12:15:48 +03:00
Alex X b19c081642 Improve cs2+udp proto for xiaomi source 2026-01-13 12:13:58 +03:00
seydx 039e916030 cleanup 2026-01-12 19:57:38 +01:00
seydx 3a587c9cee simplify SID generation 2026-01-12 13:56:52 +01:00
seydx 25e3125a89 Skip unsupported cameras (gwell based) 2026-01-12 11:22:25 +01:00
seydx 439dccf4bd cleanup 2026-01-12 11:03:53 +01:00
seydx 5fcb33c0cd Enhance video resolution handling by adding model-specific logic and updating subtype parsing 2026-01-12 11:00:28 +01:00
seydx c5311cdd94 Add keepalive command and sequence handling to new protocol 2026-01-12 10:34:05 +01:00
seydx 3dcb6dfc48 Merge branch 'AlexxIT:master' into wyze 2026-01-12 03:21:35 +01:00
seydx 406159cce5 refactor 2026-01-12 03:15:48 +01:00
seydx 659a042c42 Implement new authentication commands and improve PSK handling 2026-01-12 02:13:00 +01:00
Alex X e614513b97 Fix formats table in readme 2026-01-11 19:57:52 +03:00
Alex X f9f22cdd0b Add about OS versions to readme 2026-01-11 19:56:45 +03:00
Alex X dab9efb7d0 Merge pull request #2029 from Johnnybyzhang/master
xiaomi/cs2: fix TCP keepalive to match official Mi Home app
2026-01-11 19:55:23 +03:00
Alex X eb39b80883 Fix login to Xiaomi account with captcha #1985 2026-01-11 16:46:23 +03:00
Johnnybyzhang ff04a0d4b2 xiaomi/cs2: reduce TCP keepalive interval to 1 second
Based on PCAP analysis of official Mi Home app traffic:
- Official app sends PING every ~1 second
- Previous 5-second interval was too slow, causing connection resets

Simply reduce keepalive timeout from 5s to 1s.
2026-01-11 20:26:34 +08:00
Johnnybyzhang c4b32e3a0b xiaomi/cs2: fix TCP keepalive to match official Mi Home app
Based on PCAP analysis of official Mi Home app traffic, the keepalive
mechanism was incorrect:

Before (broken):
- Sent PING every 5s only when receiving data
- Responded to PING with PONG

After (fixed):
- Send PING every 1 second independently via dedicated goroutine
- Don't respond to PING with PONG (official app doesn't either)
- Both sides send PING bidirectionally as heartbeats

The official app sends 199 PING messages and 0 PONG messages in a
typical session. This fix matches that behavior.

Fixes connection resets after prolonged streaming sessions with
Xiaomi cameras using the CS2 P2P protocol.
2026-01-11 14:57:50 +08:00
seydx 58d8a86a92 cleanup 2026-01-10 10:46:26 +01:00
seydx 29f966f280 Fix intercom for new firmware 2026-01-10 03:13:41 +01:00
seydx cbaa147469 Update README.md for Wyze cameras 2026-01-10 02:44:27 +01:00
seydx c55fa87827 Update README.md to include details on NEW Protocol (0xCC51) for Wyze cameras 2026-01-10 02:44:07 +01:00
seydx 84e13d9d22 Add support for latest wyze firmware 2026-01-10 02:42:47 +01:00
oeiber c6733bf4f1 Update README with Doorbird support details
Added information about Doorbird device support and user permissions.
2026-01-07 22:20:40 +01:00
Alex X e960f90a97 Add formats and protocols to readme 2026-01-05 10:17:08 +03:00
Simon Cheng 3207f9e783 fix typo for inability to set channels for backchannel 2026-01-04 18:45:03 -05:00
seydx 263579fa01 cleanup 2026-01-03 16:12:37 +01:00
seydx 90c0b513e9 cleanup 2026-01-03 15:41:37 +01:00
seydx cfbba5a52c comments 2026-01-02 16:52:55 +01:00
seydx c74a39a30d cleanup 2026-01-02 16:51:23 +01:00
Alex X d4dc670cb5 Add support two-way audio for Dafang camera 2026-01-02 10:19:58 +03:00
seydx 4cff72c9a3 Refactor discovery and session setup logic 2026-01-02 02:22:14 +01:00
seydx f923487546 Improve error logging for video playback 2026-01-01 11:46:17 +01:00
seydx f47a041ece Improve error logging for video playback 2026-01-01 11:37:44 +01:00
Alex X e77210f916 Improve support tutk vendor for xiaomi source 2026-01-01 09:28:02 +03:00
Alex X 44da81774c Add recv/send counters to xiaomi source 2026-01-01 08:42:36 +03:00
seydx 7d4b3fe65b Merge branch 'AlexxIT:master' into wyze 2026-01-01 05:25:58 +01:00
seydx a42ab88dbd add wyze support 2026-01-01 05:24:45 +01:00
Alex X 4dae65a535 Fix audio sample rate for some xiaomi cameras #2006 2025-12-31 17:55:47 +03:00
Alex X a09e1b2f90 Update openapi 2025-12-27 17:50:40 +03:00
Alex X d183b99a44 Update GitHub Pages site link 2025-12-27 15:43:07 +03:00
Alex X 654e78b7c5 Add suggest button to config editor 2025-12-27 12:34:08 +03:00
Alex X 1cd5517026 Log all modules with custom loggers 2025-12-27 12:17:40 +03:00
Alex X 07fb78d661 Merge pull request #1980 from skrashevich/monaco-editor-141225
Replace ace editor to Monaco with config hints
2025-12-27 12:10:15 +03:00
Alex X 86f9f114b5 Code refactoring for #1980 2025-12-27 12:08:39 +03:00
Sergey Krashevich 7e38b4fe89 Add enum and pattern constraint checks to YAML linter
Enhances the YAML linter to validate scalar values against enum and pattern constraints defined in the schema. Adds utility functions for value comparison, schema constraint collection, and applies these checks during linting to provide more precise error messages for invalid values.
2025-12-27 12:00:47 +03:00
Sergey Krashevich 0fd2217bd2 Trigger suggestions for property completions
Adds logic to trigger the suggestion widget when completing a property if it expects a block or has value suggestions, improving the user experience in the config editor.
2025-12-27 10:26:15 +03:00
Sergey Krashevich 76bdc7e065 Refactor block scalar handling in config parser
Simplifies the logic for handling block scalar content by restructuring the conditional checks for blockScalarParentIndent. This improves readability and ensures correct processing of indented lines.
2025-12-27 09:56:52 +03:00
Sergey Krashevich 3bd433c950 Improve null checks and object handling in config.html
Replaces optional chaining with explicit null checks and more robust object property access throughout the file. Refactors destructuring and object spreading to use Object.assign for better compatibility. These changes improve code reliability and compatibility with older browsers or environments lacking support for newer JavaScript features.
2025-12-27 09:09:32 +03:00
Sergey Krashevich 4c50a2c00c Remove redundant min-height property from #config
Deleted an unnecessary 'min-height: 0;' CSS rule from the #config selector, as a fixed min-height is already set.
2025-12-27 08:50:00 +03:00
Sergey Krashevich 0e65bd05c4 Disable word-based suggestions in editor config
Updated the editor configuration in config.html to set wordBasedSuggestions to false and disable word suggestions in the suggestion widget. This change aims to refine the suggestion behavior for a more focused coding experience.
2025-12-27 08:49:26 +03:00
Alex X 05fd0c5cca Merge pull request #1956 from felipecrs/ffmpeg-timeout
Add `#timeout` param for ffmpeg source
2025-12-27 08:38:38 +03:00
Alex X a3a4276c15 Code refactoring for #1956 2025-12-27 08:36:40 +03:00
Sergey Krashevich 0f4607a070 Improve YAML language detection in config.html
Refactored the logic for detecting the YAML language in Monaco editor to handle cases where getLanguages may not be available or Monaco is not loaded. This increases robustness and prevents potential runtime errors.
2025-12-27 08:08:28 +03:00
Sergey Krashevich 11ad1129ed Add hover provider for YAML editor
Introduces a hover provider to the YAML editor using Monaco, displaying property descriptions from the schema when hovering over keys. This enhances the editor's usability by providing inline documentation for YAML properties.
2025-12-27 08:05:42 +03:00
Sergey Krashevich da0b19026c Improve YAML linter with indentation and block scalar checks
Adds detection for inconsistent indentation and unexpected indentation at the document root. Enhances block scalar handling by tracking parent indentation and skipping content lines accordingly. Also improves error reporting for missing map keys or list items.
2025-12-27 07:59:22 +03:00
Alex X c880c37d37 Merge pull request #1992 from FIGIO55/rtsp_onvif_params
Allow rtsp params (like #backchannel=0) from onvif sources
2025-12-26 23:06:30 +03:00
Alex X c4fb66a818 Code refactoring for #1992 2025-12-26 23:05:58 +03:00
Alex X 9b618f45d0 Rewrite webrtc listen logic #1890 #1892 2025-12-26 22:56:39 +03:00
Alex X 3fd1fe2622 Add net interfaces list to webrtc debug 2025-12-26 14:09:22 +03:00
Alex X 2f470fa518 Add support cs2+tcp protocol for xiaomi source 2025-12-26 12:53:52 +03:00
Alex X 212def9ceb Update app dev version format 2025-12-22 17:22:22 +03:00
Alex X 79698365bb Add fetch tags to GitHub actions 2025-12-22 12:24:00 +03:00
Alex X 5b1da84ae2 Add TUTK protocol to xiaomi source 2025-12-22 00:00:33 +03:00
Sergey Krashevich 247d4063ed Merge branch 'monaco-editor-141225' of github.com:skrashevich/go2rtc into monaco-editor-141225 2025-12-21 00:06:29 +03:00
Sergey Krashevich 7ada6d81eb Add YAML schema-based linting and validation
Integrates js-yaml for parsing and adds comprehensive YAML linting with schema-based type checking, duplicate key detection, and improved error reporting. Refactors and extends parsing utilities, enhances completion logic, and introduces a schema tools abstraction for type and property resolution.
2025-12-21 00:02:49 +03:00
Sergey Krashevich eda92afffe Merge branch 'AlexxIT:master' into monaco-editor-141225 2025-12-20 12:54:14 -08:00
Davide Figini ff19885790 Add rtsp params pass through from onvif sources 2025-12-19 15:32:01 +01:00
Davide Figini bbb9466845 Add onvif name and hardware in info field of onvif discovery 2025-12-19 15:12:18 +01:00
Alex X 079d404ed0 Set app version from git info 2025-12-18 19:02:10 +03:00
Alex X 753d6617ab Add support cateye devices to xiaomi source 2025-12-15 21:41:05 +03:00
Sergey Krashevich d041c89c5c schema improvements 2025-12-14 05:20:59 +03:00
Sergey Krashevich ce9138b354 update monaco to 0.55.1 2025-12-14 04:52:35 +03:00
Sergey Krashevich 1e5def35c9 monaco initial 2025-12-14 04:47:27 +03:00
Felipe Santos 31962181cb Add #timeout param for ffmpeg source 2025-11-25 16:37:08 -03:00
75 changed files with 10338 additions and 1418 deletions
+158 -146
View File
@@ -27,6 +27,20 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
- [two-way audio](#two-way-audio) for some cameras
- can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary)
**Supported Formats** - describes the communication API: authorization, encryption, command set, structure of media packets
- devices: `alsa` (Linux audio), `v4l2` (Linux video)
- files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav`
- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `yuv4mpegpipe`
- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `multitrans` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `wyze`, `xiaomi` (Mi Home)
- webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze`
- other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg`
**Supported Protocols** - describes the transport for data transmission
- public: `http`, `pipe`, `rtmp`, `rtsp`, `tcp`, `udp`, `webrtc`, `ws` (WebSocket)
- private: `cs2` (PPPP), `hap` and `hds` (HomeKit), `tutk` (P2P)
**Inspired by:**
- series of streaming projects from [@deepch](https://github.com/deepch)
@@ -38,7 +52,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
- creator of the project's logo [@v_novoseltsev](https://www.instagram.com/v_novoseltsev)
> [!CAUTION]
> There is NO existing website for go2rtc project other than this GitHub repository. The website go2rtc[.]com is in no way associated with the authors of this project.
> The official website of the project is this GitHub repository and go2rtc.org (hosted on GitHub Pages). The website go2rtc[.]com is in no way associated with the authors of this project.
---
@@ -50,37 +64,39 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
* [go2rtc: Dev version](#go2rtc-dev-version)
* [Configuration](#configuration)
* [Module: Streams](#module-streams)
* [Two way audio](#two-way-audio)
* [Source: RTSP](#source-rtsp)
* [Source: RTMP](#source-rtmp)
* [Source: HTTP](#source-http)
* [Source: ONVIF](#source-onvif)
* [Source: FFmpeg](#source-ffmpeg)
* [Source: FFmpeg Device](#source-ffmpeg-device)
* [Source: Exec](#source-exec)
* [Source: Echo](#source-echo)
* [Source: Expr](#source-expr)
* [Source: HomeKit](#source-homekit)
* [Source: Bubble](#source-bubble)
* [Source: DVRIP](#source-dvrip)
* [Source: Tapo](#source-tapo)
* [Source: Kasa](#source-kasa)
* [Source: Tuya](#source-tuya)
* [Source: Xiaomi](#source-xiaomi)
* [Source: GoPro](#source-gopro)
* [Source: Ivideon](#source-ivideon)
* [Source: Hass](#source-hass)
* [Source: ISAPI](#source-isapi)
* [Source: Nest](#source-nest)
* [Source: Ring](#source-ring)
* [Source: Roborock](#source-roborock)
* [Source: Doorbird](#source-doorbird)
* [Source: WebRTC](#source-webrtc)
* [Source: WebTorrent](#source-webtorrent)
* [Incoming sources](#incoming-sources)
* [Stream to camera](#stream-to-camera)
* [Publish stream](#publish-stream)
* [Preload stream](#preload-stream)
* [Two way audio](#two-way-audio)
* [Source: RTSP](#source-rtsp)
* [Source: RTMP](#source-rtmp)
* [Source: HTTP](#source-http)
* [Source: ONVIF](#source-onvif)
* [Source: FFmpeg](#source-ffmpeg)
* [Source: FFmpeg Device](#source-ffmpeg-device)
* [Source: Exec](#source-exec)
* [Source: Echo](#source-echo)
* [Source: Expr](#source-expr)
* [Source: HomeKit](#source-homekit)
* [Source: Bubble](#source-bubble)
* [Source: DVRIP](#source-dvrip)
* [Source: Tapo](#source-tapo)
* [Source: Kasa](#source-kasa)
* [Source: Multitrans](#source-multitrans)
* [Source: Tuya](#source-tuya)
* [Source: Xiaomi](#source-xiaomi)
* [Source: Wyze](#source-wyze)
* [Source: GoPro](#source-gopro)
* [Source: Ivideon](#source-ivideon)
* [Source: Hass](#source-hass)
* [Source: ISAPI](#source-isapi)
* [Source: Nest](#source-nest)
* [Source: Ring](#source-ring)
* [Source: Roborock](#source-roborock)
* [Source: Doorbird](#source-doorbird)
* [Source: WebRTC](#source-webrtc)
* [Source: WebTorrent](#source-webtorrent)
* [Incoming sources](#incoming-sources)
* [Stream to camera](#stream-to-camera)
* [Publish stream](#publish-stream)
* [Preload stream](#preload-stream)
* [Module: API](#module-api)
* [Module: RTSP](#module-rtsp)
* [Module: RTMP](#module-rtmp)
@@ -100,9 +116,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
* [Projects using go2rtc](#projects-using-go2rtc)
* [Camera experience](#cameras-experience)
* [TIPS](#tips)
* [FAQ](#faq)
## Fast start
# Fast start
1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or Home Assistant [Add-on](#go2rtc-home-assistant-add-on) or [Integration](#go2rtc-home-assistant-integration)
2. Open web interface: `http://localhost:1984/`
@@ -117,7 +132,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
- write your own [web interface](#module-api)
- integrate [web api](#module-api) into your smart home platform
### go2rtc: Binary
## go2rtc: Binary
Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/):
@@ -137,11 +152,13 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
### go2rtc: Docker
PS. The application is compiled with the latest versions of the Go language for maximum speed and security. Therefore, the [minimum OS versions](https://go.dev/wiki/MinimumRequirements) depend on the Go language.
## go2rtc: Docker
The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg) and [Python](#source-echo).
### go2rtc: Home Assistant Add-on
## go2rtc: Home Assistant Add-on
[![](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons)
@@ -150,11 +167,11 @@ The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc)
- go2rtc > Install > Start
2. Setup [Integration](#module-hass)
### go2rtc: Home Assistant Integration
## go2rtc: Home Assistant Integration
[WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any [Home Assistant installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional.
### go2rtc: Dev version
## go2rtc: Dev version
Latest, but maybe unstable version:
@@ -162,7 +179,7 @@ Latest, but maybe unstable version:
- Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions
- Hass Add-on: `go2rtc master` or `go2rtc master hardware` versions
## Configuration
# Configuration
- by default go2rtc will search `go2rtc.yaml` in the current work directory
- `api` server will start on default **1984 port** (TCP)
@@ -186,7 +203,7 @@ Available modules:
- [hass](#module-hass) - Home Assistant integration
- [log](#module-log) - logs config
### Module: Streams
## Module: Streams
**go2rtc** supports different stream source types. You can config one or multiple links of any type as a stream source.
@@ -218,10 +235,11 @@ Available source types:
- [doorbird](#source-doorbird) - Doorbird cameras with [two way audio](#two-way-audio) support
- [webrtc](#source-webrtc) - WebRTC/WHEP sources
- [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc
- [wyze](#source-wyze) - Wyze cameras with [two way audio](#two-way-audio) support
Read more about [incoming sources](#incoming-sources)
#### Two-way audio
## Two-way audio
Supported sources:
@@ -234,6 +252,7 @@ Supported sources:
- [Exec](#source-exec) audio on server
- [Ring](#source-ring) cameras
- [Tuya](#source-tuya) cameras
- [Wyze](#source-wyze) cameras
- [Xiaomi](#source-xiaomi) cameras
- [Any Browser](#incoming-browser) as IP-camera
@@ -241,7 +260,7 @@ Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. T
go2rtc also supports [play audio](#stream-to-camera) files and live streams on this cameras.
#### Source: RTSP
## Source: RTSP
```yaml
streams:
@@ -285,7 +304,7 @@ streams:
dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket
```
#### Source: RTMP
## Source: RTMP
You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module).
@@ -294,7 +313,7 @@ streams:
rtmp_stream: rtmp://192.168.1.123/live/camera1
```
#### Source: HTTP
## Source: HTTP
Support Content-Type:
@@ -325,7 +344,7 @@ streams:
**PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work.
#### Source: ONVIF
## Source: ONVIF
*[New in v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*
@@ -340,7 +359,7 @@ streams:
tapo1: onvif://admin:password@192.168.1.123:2020
```
#### Source: FFmpeg
## Source: FFmpeg
You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream.
@@ -370,16 +389,18 @@ streams:
rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90
```
All transcoding formats have [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`.
All transcoding formats have [built-in templates](internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`.
But you can override them via YAML config. You can also add your own formats to the config and use them with source params.
```yaml
ffmpeg:
bin: ffmpeg # path to ffmpeg binary
global: "-hide_banner"
timeout: 5 # default timeout in seconds for rtsp inputs
h264: "-codec:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 4.1"
mycodec: "-any args that supported by ffmpeg..."
myinput: "-fflags nobuffer -flags low_delay -timeout 5000000 -i {input}"
myinput: "-fflags nobuffer -flags low_delay -timeout {timeout} -i {input}"
myraw: "-ss 00:00:20"
```
@@ -389,16 +410,17 @@ ffmpeg:
- You can use `width` and/or `height` params, important with transcoding (ex. `#video=h264#width=1280`)
- You can use `drawtext` to add a timestamp (ex. `drawtext=x=2:y=2:fontsize=12:fontcolor=white:box=1:boxcolor=black`)
- This will greatly increase the CPU of the server, even with hardware acceleration
- You can use `timeout` param to set RTSP input timeout in seconds (ex. `#timeout=10`)
- You can use `raw` param for any additional FFmpeg arguments (ex. `#raw=-vf transpose=1`)
- You can use `input` param to override default input template (ex. `#input=rtsp/udp` will change RTSP transport from TCP to UDP+TCP)
- You can use raw input value (ex. `#input=-timeout 5000000 -i {input}`)
- You can use raw input value (ex. `#input=-timeout {timeout} -i {input}`)
- You can add your own input templates
Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration).
**PS.** It is recommended to check the available hardware in the WebUI add page.
#### Source: FFmpeg Device
## Source: FFmpeg Device
You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration.
@@ -419,7 +441,7 @@ streams:
**PS.** It is recommended to check the available devices in the WebUI add page.
#### Source: Exec
## Source: Exec
Exec source can run any external application and expect data from it. Two transports are supported - **pipe** (*from [v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*) and **RTSP**.
@@ -440,6 +462,7 @@ Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`):
- `killsignal` - signal which will be sent to stop the process (numeric form)
- `killtimeout` - time in seconds for forced termination with sigkill
- `backchannel` - enable backchannel for two-way audio
- `starttimeout` - time in seconds for waiting first byte from RTSP
```yaml
streams:
@@ -452,7 +475,7 @@ streams:
play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1
```
#### Source: Echo
## Source: Echo
Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams).
@@ -465,13 +488,15 @@ streams:
apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html
```
#### Source: Expr
## Source: Expr
*[New in v1.8.2](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.2)*
Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/expr/README.md)).
Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language.
#### Source: HomeKit
*[read more](internal/expr/README.md)*
## Source: HomeKit
**Important:**
@@ -504,7 +529,7 @@ RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g
**This source is in active development!** Tested only with [Aqara Camera Hub G3](https://www.aqara.com/eu/product/camera-hub-g3) (both EU and CN versions).
#### Source: Bubble
## Source: Bubble
*[New in v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*
@@ -518,7 +543,7 @@ streams:
camera1: bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0
```
#### Source: DVRIP
## Source: DVRIP
*[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)*
@@ -538,7 +563,7 @@ streams:
- dvrip://username:password@192.168.1.123:34567?backchannel=1
```
#### Source: EseeCloud
## Source: EseeCloud
*[New in v1.9.10](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10)*
@@ -547,7 +572,7 @@ streams:
camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12
```
#### Source: Tapo
## Source: Tapo
*[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)*
@@ -577,7 +602,7 @@ echo -n "cloud password" | md5 | awk '{print toupper($0)}'
echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}'
```
#### Source: Kasa
## Source: Kasa
*[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)*
@@ -593,25 +618,43 @@ streams:
Tested: KD110, KC200, KC401, KC420WS, EC71.
#### Source: Tuya
## Source: Multitrans
Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html).
*[read more](internal/multitrans/README.md)*
## Source: Tuya
*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)*
[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/tuya/README.md).
[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`.
#### Source: Xiaomi
*[read more](internal/tuya/README.md)*
## Source: Xiaomi
*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)*
This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/xiaomi/README.md).
This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem.
#### Source: GoPro
*[read more](internal/xiaomi/README.md)*
## Source: Wyze
This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no `docker-wyze-bridge` required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio.
*[read more](internal/wyze/README.md)*
## Source: GoPro
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)*
Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. [Read more](https://github.com/AlexxIT/go2rtc/tree/master/internal/gopro).
Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows.
#### Source: Ivideon
*[read more](internal/gopro/README.md)*
## Source: Ivideon
Support public cameras from the service [Ivideon](https://tv.ivideon.com/).
@@ -620,7 +663,7 @@ streams:
quailcam: ivideon:100-tu5dkUPct39cTp9oNEN2B6/0
```
#### Source: Hass
## Source: Hass
Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files:
@@ -656,7 +699,7 @@ streams:
By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others, can also be imported using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate).
#### Source: ISAPI
## Source: ISAPI
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -669,7 +712,7 @@ streams:
- isapi://admin:password@192.168.1.123:80/
```
#### Source: Nest
## Source: Nest
*[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)*
@@ -682,7 +725,7 @@ streams:
nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=***
```
#### Source: Ring
## Source: Ring
This source type support Ring cameras with [two way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras.
@@ -692,7 +735,7 @@ streams:
ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot
```
#### Source: Roborock
## Source: Roborock
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -706,22 +749,13 @@ Source supports loading Roborock credentials from Home Assistant [custom integra
If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, 456, 789) to the end of the `roborock` link.
#### Source: Doorbird
## Source: Doorbird
*[New in v1.9.11](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)*
This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio.
This source type supports Doorbird devices including MJPEG stream, audio stream as well as two-way audio.
*[read more](internal/doorbird/README.md)*
```yaml
streams:
doorbird1:
- rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream
- doorbird://admin:password@192.168.1.123?media=video # MJPEG stream
- doorbird://admin:password@192.168.1.123?media=audio # audio stream
- doorbird://admin:password@192.168.1.123 # two-way audio
```
#### Source: WebRTC
## Source: WebRTC
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -739,9 +773,9 @@ This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous W
Support connection to [OpenIPC](https://openipc.org/) cameras.
**wyze** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*)
**wyze (via docker-wyze-bridge)** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*)
Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use the [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials.
Legacy method to connect to [Wyze](https://www.wyze.com/) cameras using WebRTC protocol via [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge). For native P2P support without docker-wyze-bridge, see [Source: Wyze](#source-wyze).
**kinesis** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*)
@@ -763,7 +797,7 @@ streams:
**PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language.
#### Source: WebTorrent
## Source: WebTorrent
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -774,7 +808,7 @@ streams:
webtorrent1: webtorrent:?share=huofssuxaty00izc&pwd=k3l2j9djeg8v8r7e
```
#### Incoming sources
## Incoming sources
By default, go2rtc establishes a connection to the source when any client requests it. Go2rtc drops the connection to the source when it has no clients left.
@@ -803,7 +837,7 @@ By default, go2rtc establishes a connection to the source when any client reques
ffmpeg -re -i BigBuckBunny.mp4 -c copy -f mpegts http://localhost:1984/api/stream.ts?dst=camera1
```
#### Incoming: Browser
### Incoming: Browser
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -815,7 +849,7 @@ You can turn the browser of any PC or mobile into an IP camera with support for
4. Select `camera+microphone` or `display+speaker` option
5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](#module-webtorrent) technology (work over HTTPS by default)
#### Incoming: WebRTC/WHIP
### Incoming: WebRTC/WHIP
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -823,7 +857,7 @@ You can use **OBS Studio** or any other broadcast software with [WHIP](https://w
- Settings > Stream > Service: WHIP > http://192.168.1.123:1984/api/webrtc?dst=camera1
#### Stream to camera
## Stream to camera
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -845,7 +879,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com
- you can stop active playback by calling the API with the empty `src` parameter
- you will see one active producer and one active consumer in go2rtc WebUI info page during streaming
### Publish stream
## Publish stream
*[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)*
@@ -883,7 +917,7 @@ streams:
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
- **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key.
### Preload stream
## Preload stream
You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up.
@@ -903,13 +937,13 @@ streams:
- ffmpeg:camera3#video=h264#audio=opus#hardware
```
### Module: API
## Module: API
The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`.
**Important!** go2rtc passes requests from localhost and from Unix sockets without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server.
[API description](https://github.com/AlexxIT/go2rtc/tree/master/api).
[API description](api/README.md).
**Module config**
@@ -945,7 +979,7 @@ api:
- MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446)
- MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4
### Module: RTSP
## Module: RTSP
You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}`
@@ -968,7 +1002,7 @@ By default go2rtc provide RTSP-stream with only one first video and only one fir
Read more about [codecs filters](#codecs-filters).
### Module: RTMP
## Module: RTMP
*[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)*
@@ -981,7 +1015,7 @@ rtmp:
listen: ":1935" # by default - disabled!
```
### Module: WebRTC
## Module: WebRTC
In most cases, [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses a direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP.
It **can't pass** media data through your Nginx or Cloudflare or [Nabu Casa](https://www.nabucasa.com/) HTTP TCP connection!
@@ -1039,7 +1073,7 @@ webrtc:
credential: your_pass
```
### Module: HomeKit
## Module: HomeKit
*[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)*
@@ -1093,7 +1127,7 @@ homekit:
aqara1: # same stream ID from streams list
```
### Module: WebTorrent
## Module: WebTorrent
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -1117,13 +1151,15 @@ webtorrent:
src: rtsp-dahua1 # stream name from streams section
```
Link example: https://alexxit.github.io/go2rtc/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio
Link example: https://go2rtc.org/webtorrent/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio
### Module: ngrok
## Module: ngrok
With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/ngrok/README.md)).
With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address.
### Module: Hass
*[read more](internal/ngrok/README.md)*
## Module: Hass
The best and easiest way to use go2rtc inside Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom Lovelace card.
@@ -1161,7 +1197,7 @@ streams:
**PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card).
### Module: MP4
## Module: MP4
Provides several features:
@@ -1184,7 +1220,7 @@ Read more about [codecs filters](#codecs-filters).
**PS.** Rotate and scale params don't use transcoding and change video using metadata.
### Module: HLS
## Module: HLS
*[New in v1.1.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.1.0)*
@@ -1199,39 +1235,15 @@ API examples:
Read more about [codecs filters](#codecs-filters).
### Module: MJPEG
## Module: MJPEG
**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API.
- This module can provide and receive streams in MJPEG format.
- This module is also responsible for receiving snapshots in JPEG format.
- This module also supports streaming to the server console (terminal) in the **animated ASCII art** format.
You can receive an MJPEG stream in several ways:
*[read more](internal/mjpeg/README.md)*
- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras)
- some cameras have an HTTP link with [MJPEG stream](#source-http)
- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http)
- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg)
With this example, your stream will have both H264 and MJPEG codecs:
```yaml
streams:
camera1:
- rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0
- ffmpeg:camera1#video=mjpeg
```
API examples:
- MJPEG stream: `http://192.168.1.123:1984/api/stream.mjpeg?src=camera1`
- JPEG snapshots: `http://192.168.1.123:1984/api/frame.jpeg?src=camera1`
- You can use `width`/`w` and/or `height`/`h` params
- You can use `rotate` param with `90`, `180`, `270` or `-90` values
- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)
**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/mjpeg/README.md)):
[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M)
### Module: Log
## Module: Log
You can set different log levels for different modules.
@@ -1245,7 +1257,7 @@ log:
webrtc: fatal
```
## Security
# Security
> [!IMPORTANT]
> If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server.
@@ -1292,7 +1304,7 @@ If you need web interface protection without the Home Assistant add-on, you need
PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP.
## Codecs filters
# Codecs filters
go2rtc can automatically detect which codecs your device supports for [WebRTC](#module-webrtc) and [MSE](#module-mp4) technologies.
@@ -1315,7 +1327,7 @@ Some examples:
- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12)
- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players
## Codecs madness
# Codecs madness
`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers.
@@ -1356,7 +1368,7 @@ Some examples:
- AAC = MPEG4-GENERIC
- MP3 = MPEG-1 Audio Layer III or MPEG-2 Audio Layer III
## Built-in transcoding
# Built-in transcoding
There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support.
@@ -1385,7 +1397,7 @@ PCMU/xxx => PCMU/8000 => WebRTC
- FLAC codec not supported in an RTSP stream. If you are using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec.
- PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options.
## Codecs negotiation
# Codecs negotiation
For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser.
@@ -1414,7 +1426,7 @@ streams:
**PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine.
## Projects using go2rtc
# Projects using go2rtc
- [Home Assistant](https://www.home-assistant.io/) [2024.11+](https://www.home-assistant.io/integrations/go2rtc/) - top open-source smart home project
- [Frigate](https://frigate.video/) [0.12+](https://docs.frigate.video/guides/configuring_go2rtc/) - open-source NVR built around real-time AI object detection
@@ -1438,7 +1450,7 @@ streams:
- [Synology NAS](https://synocommunity.com/package/go2rtc)
- [Unraid](https://unraid.net/community/apps?q=go2rtc)
## Camera experience
# Camera experience
- [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients
- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP
@@ -1448,7 +1460,7 @@ streams:
- [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss?
- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss?
## TIPS
# TIPS
**Using apps for low RTSP delay**
+1 -1
View File
@@ -4,7 +4,7 @@ Fill free to make any API design proposals.
## HTTP API
Interactive [OpenAPI](https://alexxit.github.io/go2rtc/api/).
Interactive [OpenAPI](https://go2rtc.org/api/).
`www/stream.html` - universal viewer with support params in URL:
+687 -83
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -47,6 +47,7 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver
COPY --from=build /build/go2rtc /usr/local/bin/
EXPOSE 1984 8554 8555 8555/udp
ENTRYPOINT ["/sbin/tini", "--"]
VOLUME /config
WORKDIR /config
+1
View File
@@ -49,6 +49,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t
COPY --from=build /build/go2rtc /usr/local/bin/
EXPOSE 1984 8554 8555 8555/udp
ENTRYPOINT ["/usr/bin/tini", "--"]
VOLUME /config
WORKDIR /config
+1
View File
@@ -43,6 +43,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t
COPY --from=build /build/go2rtc /usr/local/bin/
ADD --chmod=755 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-8-no_extra_dump/ffmpeg /usr/local/bin
EXPOSE 1984 8554 8555 8555/udp
ENTRYPOINT ["/usr/bin/tini", "--"]
VOLUME /config
WORKDIR /config
+5
View File
@@ -0,0 +1,5 @@
# tutk_decoder
1. Wireshark > Select any packet > Follow > UDP Stream
2. Wireshark > File > Export Packet Dissections > As JSON > Displayed, Values
3. `tutk_decoder wireshark.json decoded.txt`
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"github.com/AlexxIT/go2rtc/pkg/tutk"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("Usage: tutk_decoder wireshark.json decoded.txt")
return
}
src, err := os.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer src.Close()
dst, err := os.Create(os.Args[2])
if err != nil {
log.Fatal(err)
}
defer dst.Close()
var items []item
if err = json.NewDecoder(src).Decode(&items); err != nil {
log.Fatal(err)
}
var b []byte
for _, v := range items {
if v.Source.Layers.Data.DataData == "" {
continue
}
s := strings.ReplaceAll(v.Source.Layers.Data.DataData, ":", "")
b, err = hex.DecodeString(s)
if err != nil {
log.Fatal(err)
}
tutk.ReverseTransCodePartial(b, b)
ts := v.Source.Layers.Frame.FrameTimeRelative
_, _ = fmt.Fprintf(dst, "%8s: %s -> %s [%4d] %x\n",
ts[:len(ts)-6],
v.Source.Layers.Ip.IpSrc, v.Source.Layers.Ip.IpDst,
len(b), b)
}
}
type item struct {
Source struct {
Layers struct {
Frame struct {
FrameTimeRelative string `json:"frame.time_relative"`
FrameNumber string `json:"frame.number"`
} `json:"frame"`
Ip struct {
IpSrc string `json:"ip.src"`
IpDst string `json:"ip.dst"`
} `json:"ip"`
Udp struct {
UdpSrcport string `json:"udp.srcport"`
UdpDstport string `json:"udp.dstport"`
} `json:"udp"`
Data struct {
DataData string `json:"data.data"`
DataLen string `json:"data.len"`
} `json:"data"`
} `json:"layers"`
} `json:"_source"`
}
+20 -18
View File
@@ -5,26 +5,27 @@ go 1.24.0
require (
github.com/asticode/go-astits v1.14.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/expr-lang/expr v1.17.6
github.com/expr-lang/expr v1.17.7
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-isatty v0.0.20
github.com/miekg/dns v1.1.69
github.com/pion/ice/v4 v4.1.0
github.com/pion/interceptor v0.1.42
github.com/miekg/dns v1.1.70
github.com/pion/dtls/v3 v3.0.10
github.com/pion/ice/v4 v4.2.0
github.com/pion/interceptor v0.1.43
github.com/pion/rtcp v1.2.16
github.com/pion/rtp v1.8.26
github.com/pion/sdp/v3 v3.0.16
github.com/pion/srtp/v3 v3.0.9
github.com/pion/stun/v3 v3.0.2
github.com/pion/webrtc/v4 v4.1.8
github.com/pion/rtp v1.10.0
github.com/pion/sdp/v3 v3.0.17
github.com/pion/srtp/v3 v3.0.10
github.com/pion/stun/v3 v3.1.1
github.com/pion/webrtc/v4 v4.2.3
github.com/rs/zerolog v1.34.0
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
github.com/stretchr/testify v1.11.1
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,18 +34,19 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.9 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/sctp v1.8.41 // indirect
github.com/pion/sctp v1.9.2 // indirect
github.com/pion/transport/v3 v3.1.1 // indirect
github.com/pion/turn/v4 v4.1.3 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
)
+41
View File
@@ -12,6 +12,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -32,14 +34,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU=
github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4=
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
github.com/pion/interceptor v0.1.43 h1:6hmRfnmjogSs300xfkR0JxYFZ9k5blTEvCD7wxEDuNQ=
github.com/pion/interceptor v0.1.43/go.mod h1:BSiC1qKIJt1XVr3l3xQ2GEmCFStk9tx8fwtCZxxgR7M=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
@@ -50,20 +62,36 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc=
github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w=
github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs=
github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY=
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk=
github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s=
github.com/pion/webrtc/v4 v4.2.3 h1:RtdWDnkenNQGxUrZqWa5gSkTm5ncsLg5d+zu0M4cXt4=
github.com/pion/webrtc/v4 v4.2.3/go.mod h1:7vsyFzRzaKP5IELUnj8zLcglPyIT6wWwqTppBZ1k6Kc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
@@ -88,10 +116,16 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -99,10 +133,17 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+11 -1
View File
@@ -103,10 +103,20 @@ func readRevisionTime() (revision, vcsTime string) {
vcsTime = setting.Value
case "vcs.modified":
if setting.Value == "true" {
revision = "mod." + revision
revision += ".dirty"
}
}
}
// Check version from -buildvcs info
// Format for tagged version : v1.9.13
// Format for modified code: v1.9.14-0.20251215184105-753d6617ab58+dirty
if info.Main.Version != "v"+Version {
// Format: 1.9.13+dev.753d661[.dirty]
// Compatible with "awesomeversion" and "packaging.version" from python.
// Version will be larger than the previous release, but smaller than the next release.
Version += "+dev." + revision
}
}
return
}
+2
View File
@@ -14,6 +14,8 @@ import (
var MemoryLog = newBuffer()
func GetLogger(module string) zerolog.Logger {
Logger.Trace().Str("module", module).Msgf("[log] init")
if s, ok := modules[module]; ok {
lvl, err := zerolog.ParseLevel(s)
if err == nil {
+21
View File
@@ -0,0 +1,21 @@
# Doorbird
*[added in v1.9.8](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)*
This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio.
It is recommended to create a sepearate user within your doorbird setup for go2rtc. Minimum permissions for the user are:
- Watch always
- API operator
## Configuration
```yaml
streams:
doorbird1:
- rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream
- doorbird://admin:password@192.168.1.123?media=video # MJPEG stream
- doorbird://admin:password@192.168.1.123?media=audio # audio stream
- doorbird://admin:password@192.168.1.123 # two-way audio
```
+13 -5
View File
@@ -88,6 +88,7 @@ func execHandle(rawURL string) (prod core.Producer, err error) {
}
if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) {
_ = cmd.Close()
return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0])
}
@@ -107,10 +108,17 @@ func execHandle(rawURL string) (prod core.Producer, err error) {
return pcm.NewBackchannel(cmd, query.Get("audio"))
}
var timeout time.Duration
if s := query.Get("starttimeout"); s != "" {
timeout = time.Duration(core.Atoi(s)) * time.Second
} else {
timeout = 30 * time.Second
}
if path == "" {
prod, err = handlePipe(rawURL, cmd)
} else {
prod, err = handleRTSP(rawURL, cmd, path)
prod, err = handleRTSP(rawURL, cmd, path, timeout)
}
if err != nil {
@@ -159,7 +167,7 @@ func handlePipe(source string, cmd *shell.Command) (core.Producer, error) {
return prod, nil
}
func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, error) {
func handleRTSP(source string, cmd *shell.Command, path string, timeout time.Duration) (core.Producer, error) {
if log.Trace().Enabled() {
cmd.Stdout = os.Stdout
}
@@ -185,11 +193,11 @@ func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer,
return nil, err
}
timeout := time.NewTimer(30 * time.Second)
defer timeout.Stop()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-timeout.C:
case <-timer.C:
// haven't received data from app in timeout
log.Error().Str("source", source).Msg("[exec] timeout")
return nil, errors.New("exec: timeout")
+14 -7
View File
@@ -58,15 +58,15 @@ func Init() {
}
var defaults = map[string]string{
"bin": "ffmpeg",
"global": "-hide_banner",
"bin": "ffmpeg",
"global": "-hide_banner",
"timeout": "5",
// inputs
"file": "-re -i {input}",
"http": "-fflags nobuffer -flags low_delay -i {input}",
"rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}",
"rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}",
"file": "-re -i {input}",
"http": "-fflags nobuffer -flags low_delay -i {input}",
"rtsp": "-fflags nobuffer -flags low_delay -timeout {timeout} -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}",
"rtsp/udp": "-fflags nobuffer -flags low_delay -timeout {timeout} -user_agent go2rtc/ffmpeg -i {input}",
// output
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
@@ -169,6 +169,13 @@ func inputTemplate(name, s string, query url.Values) string {
} else {
template = defaults[name]
}
if strings.Contains(template, "{timeout}") {
timeout := query.Get("timeout")
if timeout == "" {
timeout = defaults["timeout"]
}
template = strings.Replace(template, "{timeout}", timeout+"000000", 1)
}
return strings.Replace(template, "{input}", s, 1)
}
+5
View File
@@ -123,6 +123,11 @@ func TestParseArgsIpCam(t *testing.T) {
source: "rtmp://example.com#input=rtsp/udp",
expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`,
},
{
name: "[RTSP] custom timeout",
source: "rtsp://example.com#timeout=10",
expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 10000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
+42
View File
@@ -1,3 +1,45 @@
# MJPEG
**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API.
You can receive an MJPEG stream in several ways:
- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras)
- some cameras have an HTTP link with [MJPEG stream](#source-http)
- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http)
- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg)
With this example, your stream will have both H264 and MJPEG codecs:
```yaml
streams:
camera1:
- rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0
- ffmpeg:camera1#video=mjpeg
```
## API examples
**MJPEG stream**
```
http://192.168.1.123:1984/api/stream.mjpeg?src=camera1`
```
**JPEG snapshots**
```
http://192.168.1.123:1984/api/frame.jpeg?src=camera1
```
- You can use `width`/`w` and/or `height`/`h` params.
- You can use `rotate` param with `90`, `180`, `270` or `-90` values.
- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration).
- You can use `cache` param (`1m`, `10s`, etc.) to get a cached snapshot.
- The snapshot is cached only when requested with the `cache` parameter.
- A cached snapshot will be used if its time is not older than the time specified in the `cache` parameter.
- The `cache` parameter does not check the image sizes from the cache and those specified in the query.
## Stream as ASCII to Terminal
[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M)
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/internal/api"
@@ -36,12 +37,44 @@ func Init() {
var log zerolog.Logger
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
stream, _ := streams.GetOrPatch(r.URL.Query())
query := r.URL.Query()
stream, _ := streams.GetOrPatch(query)
if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound)
return
}
var b []byte
if s := query.Get("cache"); s != "" {
if timeout, err := time.ParseDuration(s); err == nil {
src := query.Get("src")
cacheMu.Lock()
entry, found := cache[src]
cacheMu.Unlock()
if found && time.Since(entry.timestamp) < timeout {
writeJPEGResponse(w, entry.payload)
return
}
defer func() {
if b == nil {
return
}
entry = cacheEntry{payload: b, timestamp: time.Now()}
cacheMu.Lock()
if cache == nil {
cache = map[string]cacheEntry{src: entry}
} else {
cache[src] = entry
}
cacheMu.Unlock()
}()
}
}
cons := magic.NewKeyframe()
cons.WithRequest(r)
@@ -52,7 +85,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
once := &core.OnceBuffer{} // init and first frame
_, _ = cons.WriteTo(once)
b := once.Buffer()
b = once.Buffer()
stream.RemoveConsumer(cons)
@@ -60,7 +93,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
case core.CodecH264, core.CodecH265:
ts := time.Now()
var err error
if b, err = ffmpeg.JPEGWithQuery(b, r.URL.Query()); err != nil {
if b, err = ffmpeg.JPEGWithQuery(b, query); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -69,6 +102,19 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
b = mjpeg.FixJPEG(b)
}
writeJPEGResponse(w, b)
}
var cache map[string]cacheEntry
var cacheMu sync.Mutex
// cacheEntry represents a cached keyframe with its timestamp
type cacheEntry struct {
payload []byte
timestamp time.Time
}
func writeJPEGResponse(w http.ResponseWriter, b []byte) {
h := w.Header()
h.Set("Content-Type", "image/jpeg")
h.Set("Content-Length", strconv.Itoa(len(b)))
+16
View File
@@ -0,0 +1,16 @@
# Multitrans
**added in v1.9.14** by [@forrestsocool](https://github.com/forrestsocool)
Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html).
## Configuration
```yaml
streams:
tplink_cam:
# video use standard RTSP
- rtsp://admin:admin@192.168.1.202:554/stream1
# two-way audio use MULTITRANS schema
- multitrans://admin:admin@192.168.1.202:554
```
+10
View File
@@ -0,0 +1,10 @@
package multitrans
import (
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/multitrans"
)
func Init() {
streams.HandleFunc("multitrans", multitrans.Dial)
}
+22 -14
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/AlexxIT/go2rtc/internal/api"
@@ -43,6 +44,11 @@ func streamOnvif(rawURL string) (core.Producer, error) {
return nil, err
}
// Append hash-based arguments to the retrieved URI
if i := strings.IndexByte(rawURL, '#'); i > 0 {
uri += rawURL[i:]
}
log.Debug().Msgf("[onvif] new uri=%s", uri)
if err = streams.Validate(uri); err != nil {
@@ -68,8 +74,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
log.Trace().Msgf("[onvif] server request %s %s:\n%s", r.Method, r.RequestURI, b)
switch operation {
case onvif.DeviceGetNetworkInterfaces, // important for Hass
case onvif.ServiceGetServiceCapabilities, // important for Hass
onvif.DeviceGetNetworkInterfaces, // important for Hass
onvif.DeviceGetSystemDateAndTime, // important for Hass
onvif.DeviceSetSystemDateAndTime, // return just OK
onvif.DeviceGetDiscoveryMode,
onvif.DeviceGetDNS,
onvif.DeviceGetHostname,
@@ -77,8 +85,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
onvif.DeviceGetNetworkProtocols,
onvif.DeviceGetNTP,
onvif.DeviceGetScopes,
onvif.MediaGetVideoEncoderConfiguration,
onvif.MediaGetVideoEncoderConfigurations,
onvif.MediaGetAudioEncoderConfigurations,
onvif.MediaGetVideoEncoderConfigurationOptions,
onvif.MediaGetAudioSources,
onvif.MediaGetAudioSourceConfigurations:
b = onvif.StaticResponse(operation)
@@ -94,11 +104,6 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
// important for Hass: SerialNumber (unique server ID)
b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host)
case onvif.ServiceGetServiceCapabilities:
// important for Hass
// TODO: check path links to media
b = onvif.GetMediaServiceCapabilitiesResponse()
case onvif.DeviceSystemReboot:
b = onvif.StaticResponse(operation)
@@ -128,8 +133,7 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
case onvif.MediaGetStreamUri:
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
host = r.Host // in case of Host without port
}
uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken")
@@ -160,21 +164,21 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) {
var items []*api.Source
if src == "" {
urls, err := onvif.DiscoveryStreamingURLs()
devices, err := onvif.DiscoveryStreamingDevices()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, rawURL := range urls {
u, err := url.Parse(rawURL)
for _, device := range devices {
u, err := url.Parse(device.URL)
if err != nil {
log.Warn().Str("url", rawURL).Msg("[onvif] broken")
log.Warn().Str("url", device.URL).Msg("[onvif] broken")
continue
}
if u.Scheme != "http" {
log.Warn().Str("url", rawURL).Msg("[onvif] unsupported")
log.Warn().Str("url", device.URL).Msg("[onvif] unsupported")
continue
}
@@ -185,7 +189,11 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) {
u.Path = ""
}
items = append(items, &api.Source{Name: u.Host, URL: u.String()})
items = append(items, &api.Source{
Name: u.Host,
URL: u.String(),
Info: device.Name + " " + device.Hardware,
})
}
} else {
client, err := onvif.NewClient(src)
+2
View File
@@ -198,6 +198,8 @@ func tcpHandler(conn *rtsp.Conn) {
{Name: core.CodecPCM, ClockRate: 8000},
{Name: core.CodecPCMA, ClockRate: 8000},
{Name: core.CodecPCMU, ClockRate: 8000},
{Name: core.CodecAAC, ClockRate: 8000},
{Name: core.CodecAAC, ClockRate: 16000},
},
})
}
+18 -3
View File
@@ -2,6 +2,7 @@ package webrtc
import (
"errors"
"net"
"strings"
"github.com/AlexxIT/go2rtc/internal/api"
@@ -33,7 +34,13 @@ func Init() {
log = app.GetLogger("webrtc")
filters = cfg.Mod.Filters
if log.Debug().Enabled() {
itfs, _ := net.Interfaces()
for _, itf := range itfs {
addrs, _ := itf.Addrs()
log.Debug().Msgf("[webrtc] interface %+v addrs %v", itf, addrs)
}
}
address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
for _, candidate := range cfg.Mod.Candidates {
@@ -50,10 +57,19 @@ func Init() {
}
}
webrtc.OnNewListener = func(ln any) {
switch ln := ln.(type) {
case *net.TCPListener:
log.Info().Stringer("addr", ln.Addr()).Msg("[webrtc] listen tcp")
case *net.UDPConn:
log.Info().Stringer("addr", ln.LocalAddr()).Msg("[webrtc] listen udp")
}
}
var err error
// create pionAPI with custom codecs list and custom network settings
serverAPI, err = webrtc.NewServerAPI(network, address, &filters)
serverAPI, err = webrtc.NewServerAPI(network, address, &cfg.Mod.Filters)
if err != nil {
log.Error().Err(err).Caller().Send()
return
@@ -63,7 +79,6 @@ func Init() {
clientAPI = serverAPI
if address != "" {
log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen")
clientAPI, _ = webrtc.NewAPI()
}
+106
View File
@@ -0,0 +1,106 @@
# Wyze
This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol without the Wyze app or SDK.
**Important:**
1. **Requires Wyze account**. You need to login once via the WebUI to load your cameras.
2. **Requires firmware with DTLS**. Only cameras with DTLS-enabled firmware are supported.
3. Internet access is only needed when loading cameras from your account. After that, all streaming is local P2P.
4. Connection to the camera is local only (direct P2P to camera IP).
**Features:**
- H.264 and H.265 video codec support
- AAC, G.711, PCM, and Opus audio codec support
- Two-way audio (intercom) support
- Resolution switching (HD/SD)
## Setup
1. Get your API Key from [Wyze Developer Portal](https://support.wyze.com/hc/en-us/articles/16129834216731)
2. Go to go2rtc WebUI > Add > Wyze
3. Enter your API ID, API Key, email, and password
4. Select cameras to add - stream URLs are generated automatically
**Example Config**
```yaml
wyze:
user@email.com:
api_id: "your-api-id"
api_key: "your-api-key"
password: "yourpassword" # or MD5 triple-hash with "md5:" prefix
streams:
wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&model=HL_CAM4&dtls=true
```
## Stream URL Format
The stream URL is automatically generated when you add cameras via the WebUI:
```
wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&model=[MODEL]&subtype=[hd|sd]&dtls=true
```
| Parameter | Description |
|-----------|-------------|
| `IP` | Camera's local IP address |
| `uid` | P2P identifier (20 chars) |
| `enr` | Encryption key for DTLS |
| `mac` | Device MAC address |
| `model` | Camera model (e.g., HL_CAM4) |
| `dtls` | Enable DTLS encryption (default: true) |
| `subtype` | Camera resolution: `hd` or `sd` (default: `hd`) |
## Configuration
### Resolution
You can change the camera's resolution using the `subtype` parameter:
```yaml
streams:
wyze_hd: wyze://...&subtype=hd
wyze_sd: wyze://...&subtype=sd
```
### Two-Way Audio
Two-way audio (intercom) is supported automatically. When a consumer sends audio to the stream, it will be transmitted to the camera's speaker.
## Camera Compatibility
| Name | Model | Firmware | Protocol | Encryption | Codecs |
|------|-------|----------|----------|------------|--------|
| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | h264, aac |
| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | h264, aac |
| Wyze Cam v3 Pro | | | TUTK | | |
| Wyze Cam v3 | WYZE_CAKP2JFUS | 4.36.14.3497 | TUTK | TransCode | h264, pcm |
| Wyze Cam v2 | WYZEC1-JZ | 4.9.9.3006 | TUTK | TransCode | h264, pcmu |
| Wyze Cam v1 | | | TUTK | | |
| Wyze Cam Pan v4 | | | Gwell* | | |
| Wyze Cam Pan v3 | | | TUTK | | |
| Wyze Cam Pan v2 | | | TUTK | | |
| Wyze Cam Pan v1 | | | TUTK | | |
| Wyze Cam OG | | | Gwell* | | |
| Wyze Cam OG Telephoto | | | Gwell* | | |
| Wyze Cam OG (2025) | | | Gwell* | | |
| Wyze Cam Outdoor v2 | | | TUTK | | |
| Wyze Cam Outdoor v1 | | | TUTK | | |
| Wyze Cam Floodlight Pro | | | ? | | |
| Wyze Cam Floodlight v2 | | | TUTK | | |
| Wyze Cam Floodlight | | | TUTK | | |
| Wyze Video Doorbell v2 | HL_DB2 | 4.51.3.4992 | TUTK | TransCode | h264, pcm |
| Wyze Video Doorbell v1 | | | TUTK | | |
| Wyze Video Doorbell Pro | | | ? | | |
| Wyze Battery Video Doorbell | | | ? | | |
| Wyze Duo Cam Doorbell | | | ? | | |
| Wyze Battery Cam Pro | | | ? | | |
| Wyze Solar Cam Pan | | | ? | | |
| Wyze Duo Cam Pan | | | ? | | |
| Wyze Window Cam | | | ? | | |
| Wyze Bulb Cam | | | ? | | |
_* Gwell based protocols are not yet supported._
+202
View File
@@ -0,0 +1,202 @@
package wyze
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/wyze"
)
type AccountConfig struct {
APIKey string `yaml:"api_key"`
APIID string `yaml:"api_id"`
Password string `yaml:"password"`
}
var accounts map[string]AccountConfig
func Init() {
var v struct {
Cfg map[string]AccountConfig `yaml:"wyze"`
}
app.LoadConfig(&v)
accounts = v.Cfg
log := app.GetLogger("wyze")
streams.HandleFunc("wyze", func(rawURL string) (core.Producer, error) {
log.Debug().Msgf("wyze: dial %s", rawURL)
return wyze.NewProducer(rawURL)
})
api.HandleFunc("api/wyze", apiWyze)
}
func getCloud(email string) (*wyze.Cloud, error) {
cfg, ok := accounts[email]
if !ok {
return nil, fmt.Errorf("wyze: account not found: %s", email)
}
if cfg.APIKey == "" || cfg.APIID == "" {
return nil, fmt.Errorf("wyze: api_key and api_id required for account: %s", email)
}
cloud := wyze.NewCloud(cfg.APIKey, cfg.APIID)
if err := cloud.Login(email, cfg.Password); err != nil {
return nil, err
}
return cloud, nil
}
func apiWyze(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
apiDeviceList(w, r)
case "POST":
apiAuth(w, r)
}
}
func apiDeviceList(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
email := query.Get("id")
if email == "" {
accountList := make([]string, 0, len(accounts))
for id := range accounts {
accountList = append(accountList, id)
}
api.ResponseJSON(w, accountList)
return
}
err := func() error {
cloud, err := getCloud(email)
if err != nil {
return err
}
cameras, err := cloud.GetCameraList()
if err != nil {
return err
}
var items []*api.Source
for _, cam := range cameras {
items = append(items, &api.Source{
Name: cam.Nickname,
Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP),
URL: buildStreamURL(cam),
})
}
api.ResponseSources(w, items)
return nil
}()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func apiAuth(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
email := r.Form.Get("email")
password := r.Form.Get("password")
apiKey := r.Form.Get("api_key")
apiID := r.Form.Get("api_id")
if email == "" || password == "" || apiKey == "" || apiID == "" {
http.Error(w, "email, password, api_key and api_id required", http.StatusBadRequest)
return
}
// Try to login
cloud := wyze.NewCloud(apiKey, apiID)
if err := cloud.Login(email, password); err != nil {
// Check for MFA error
var authErr *wyze.AuthError
if ok := isAuthError(err, &authErr); ok {
w.Header().Set("Content-Type", api.MimeJSON)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(authErr)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cfg := map[string]string{
"password": password,
"api_key": apiKey,
"api_id": apiID,
}
if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if accounts == nil {
accounts = make(map[string]AccountConfig)
}
accounts[email] = AccountConfig{
APIKey: apiKey,
APIID: apiID,
Password: password,
}
cameras, err := cloud.GetCameraList()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var items []*api.Source
for _, cam := range cameras {
items = append(items, &api.Source{
Name: cam.Nickname,
Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP),
URL: buildStreamURL(cam),
})
}
api.ResponseSources(w, items)
}
func buildStreamURL(cam *wyze.Camera) string {
query := url.Values{}
query.Set("uid", cam.P2PID)
query.Set("enr", cam.ENR)
query.Set("mac", cam.MAC)
query.Set("model", cam.ProductModel)
if cam.DTLS == 1 {
query.Set("dtls", "true")
}
return fmt.Sprintf("wyze://%s?%s", cam.IP, query.Encode())
}
func isAuthError(err error, target **wyze.AuthError) bool {
if e, ok := err.(*wyze.AuthError); ok {
*target = e
return true
}
return false
}
+20 -6
View File
@@ -1,11 +1,21 @@
# Xiaomi
**Added in v1.9.13. Improved in v1.9.14.**
This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem.
Since 2020, Xiaomi has introduced a unified protocol for cameras called `miss`. I think it means **Mi Secure Streaming**. Until this point, the camera protocols were in chaos. Almost every model had different authorization, encryption, command lists, and media packet formats.
Go2rtc support two formats: `xiaomi/mess` and `xiaomi/legacy`.
And multiple P2P protocols: `cs2+udp`, `cs2+tcp`, several versions of `tutk+udp`.
Almost all cameras in the `xiaomi/mess` format and the `cs2` protocol work well.
Older `xiaomi/legacy` format cameras may have support issues.
The `tutk` protocol is the worst thing that's ever happened to the P2P world. It works terribly.
**Important:**
1. **Not all cameras are supported**. There are several P2P protocol vendors in the Xiaomi ecosystem.
Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not supported.
1. **Not all cameras are supported**. The list of supported cameras is collected in [this issue](https://github.com/AlexxIT/go2rtc/issues/1982).
2. Each time you connect to the camera, you need internet access to obtain encryption keys.
3. Connection to the camera is local only.
@@ -21,7 +31,7 @@ Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not
1. Goto go2rtc WebUI > Add > Xiaomi > Login with username and password
2. Receive verification code by email or phone if required.
3. Complete the captcha if required.
4. If everything is OK, your account will be added and you can load cameras from it.
4. If everything is OK, your account will be added, and you can load cameras from it.
**Example**
@@ -35,16 +45,20 @@ streams:
## Configuration
You can change camera's quality: `subtype=hd/sd/auto`
Quality in the `miss` protocol is specified by a number from 0 to 5. Usually 0 means auto, 1 - sd, 2 - hd.
Go2rtc by default sets quality to 2. But some new cameras have HD quality at number 3.
Old cameras may have broken codec settings at number 3, so this number should not be set for all cameras.
You can change camera's quality: `subtype=hd/sd/auto/0-5`.
```yaml
streams:
xiaomi1: xiaomi://***&subtype=sd
```
You can use second channel for Dual cameras: `channel=1`
You can use second channel for Dual cameras: `channel=2`.
```yaml
streams:
xiaomi1: xiaomi://***&channel=1
xiaomi1: xiaomi://***&channel=2
```
+117 -33
View File
@@ -15,7 +15,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/xiaomi"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto"
)
func Init() {
@@ -50,49 +50,128 @@ func Init() {
}
var tokens map[string]string
var tokensMu sync.Mutex
var clouds map[string]*xiaomi.Cloud
var cloudsMu sync.Mutex
func getCloud(userID string) (*xiaomi.Cloud, error) {
tokensMu.Lock()
defer tokensMu.Unlock()
cloudsMu.Lock()
defer cloudsMu.Unlock()
token := tokens[userID]
cloud := xiaomi.NewCloud(AppXiaomiHome)
if err := cloud.LoginWithToken(userID, token); err != nil {
return nil, err
if cloud := clouds[userID]; cloud != nil {
return cloud, nil
}
cloud := xiaomi.NewCloud(AppXiaomiHome)
if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil {
return nil, err
}
if clouds == nil {
clouds = map[string]*xiaomi.Cloud{userID: cloud}
} else {
clouds[userID] = cloud
}
return cloud, nil
}
func getCameraURL(url *url.URL) (string, error) {
clientPublic, clientPrivate, err := miss.GenerateKey()
func cloudRequest(userID, region, apiURL, params string) ([]byte, error) {
cloud, err := getCloud(userID)
if err != nil {
return "", err
return nil, err
}
return cloud.Request(GetBaseURL(region), apiURL, params, nil)
}
func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) {
userID := user.Username()
region, _ := user.Password()
return cloudRequest(userID, region, apiURL, params)
}
func getCameraURL(url *url.URL) (string, error) {
model := url.Query().Get("model")
// It is not known which models need to be awakened.
// Probably all the doorbells and all the battery cameras.
if strings.Contains(model, ".cateye.") {
_ = wakeUpCamera(url)
}
// The getMissURL request has a fallback to getP2PURL.
// But for known models we can save one request to the cloud.
if xiaomi.IsLegacy(model) {
return getLegacyURL(url)
}
return getMissURL(url)
}
func getLegacyURL(url *url.URL) (string, error) {
query := url.Query()
params := fmt.Sprintf(
`{"app_pubkey":"%x","did":"%s","support_vendors":"CS2"}`,
clientPublic, query.Get("did"),
)
cloud, err := getCloud(url.User.Username())
clientPublic, clientPrivate, err := crypto.GenerateKey()
if err != nil {
return "", err
}
region, _ := url.User.Password()
params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic)
res, err := cloud.Request(GetBaseURL(region), "/v2/device/miss_get_vendor", params, nil)
userID := url.User.Username()
region, _ := url.User.Password()
res, err := cloudRequest(userID, region, "/device/devicepass", params)
if err != nil {
return "", err
}
var v struct {
UID string `json:"p2p_id"`
Password string `json:"password"`
PublicKey string `json:"p2p_dev_public_key"`
Sign string `json:"signForAppData"`
}
if err = json.Unmarshal(res, &v); err != nil {
return "", err
}
query.Set("uid", v.UID)
if v.Sign != "" {
query.Set("client_public", hex.EncodeToString(clientPublic))
query.Set("client_private", hex.EncodeToString(clientPrivate))
query.Set("device_public", v.PublicKey)
query.Set("sign", v.Sign)
} else {
query.Set("password", v.Password)
}
url.RawQuery = query.Encode()
return url.String(), nil
}
func getMissURL(url *url.URL) (string, error) {
clientPublic, clientPrivate, err := crypto.GenerateKey()
if err != nil {
return "", err
}
query := url.Query()
params := fmt.Sprintf(
`{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`,
clientPublic, query.Get("did"),
)
res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params)
if err != nil {
if strings.Contains(err.Error(), "no available vendor support") {
return getLegacyURL(url)
}
return "", err
}
var v struct {
Vendor struct {
VendorID byte `json:"vendor"`
ID byte `json:"vendor"`
Params struct {
UID string `json:"p2p_id"`
} `json:"vendor_params"`
} `json:"vendor"`
PublicKey string `json:"public_key"`
Sign string `json:"sign"`
@@ -105,7 +184,11 @@ func getCameraURL(url *url.URL) (string, error) {
query.Set("client_private", hex.EncodeToString(clientPrivate))
query.Set("device_public", v.PublicKey)
query.Set("sign", v.Sign)
query.Set("vendor", getVendorName(v.Vendor.VendorID))
query.Set("vendor", getVendorName(v.Vendor.ID))
if v.Vendor.ID == 1 {
query.Set("uid", v.Vendor.Params.UID)
}
url.RawQuery = query.Encode()
return url.String(), nil
@@ -125,6 +208,13 @@ func getVendorName(i byte) string {
return fmt.Sprintf("%d", i)
}
func wakeUpCamera(url *url.URL) error {
const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}`
did := url.Query().Get("did")
_, err := cloudUserRequest(url.User, "/home/rpc/"+did, params)
return err
}
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
@@ -139,26 +229,20 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) {
user := query.Get("id")
if user == "" {
tokensMu.Lock()
cloudsMu.Lock()
users := make([]string, 0, len(tokens))
for s := range tokens {
users = append(users, s)
}
tokensMu.Unlock()
cloudsMu.Unlock()
api.ResponseJSON(w, users)
return
}
err := func() error {
cloud, err := getCloud(user)
if err != nil {
return err
}
region := query.Get("region")
res, err := cloud.Request(GetBaseURL(region), "/v2/home/device_list_page", "{}", nil)
res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}")
if err != nil {
return err
}
@@ -173,7 +257,7 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) {
var items []*api.Source
for _, device := range v.List {
if !strings.Contains(device.Model, ".camera.") {
if !strings.Contains(device.Model, ".camera.") && !strings.Contains(device.Model, ".cateye.") {
continue
}
items = append(items, &api.Source{
@@ -232,13 +316,13 @@ func apiAuth(w http.ResponseWriter, r *http.Request) {
userID, token := auth.UserToken()
auth = nil
tokensMu.Lock()
cloudsMu.Lock()
if tokens == nil {
tokens = map[string]string{userID: token}
} else {
tokens[userID] = token
}
tokensMu.Unlock()
cloudsMu.Unlock()
err = app.PatchConfig([]string{"xiaomi", userID}, token)
}
+6 -1
View File
@@ -27,6 +27,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/mjpeg"
"github.com/AlexxIT/go2rtc/internal/mp4"
"github.com/AlexxIT/go2rtc/internal/mpegts"
"github.com/AlexxIT/go2rtc/internal/multitrans"
"github.com/AlexxIT/go2rtc/internal/nest"
"github.com/AlexxIT/go2rtc/internal/ngrok"
"github.com/AlexxIT/go2rtc/internal/onvif"
@@ -43,13 +44,15 @@ import (
"github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/internal/webtorrent"
"github.com/AlexxIT/go2rtc/internal/wyoming"
"github.com/AlexxIT/go2rtc/internal/wyze"
"github.com/AlexxIT/go2rtc/internal/xiaomi"
"github.com/AlexxIT/go2rtc/internal/yandex"
"github.com/AlexxIT/go2rtc/pkg/shell"
)
func main() {
app.Version = "1.9.13"
// version will be set later from -buildvcs info, this used only as fallback
app.Version = "1.9.14"
type module struct {
name string
@@ -94,11 +97,13 @@ func main() {
{"isapi", isapi.Init},
{"ivideon", ivideon.Init},
{"mpegts", mpegts.Init},
{"multitrans", multitrans.Init},
{"nest", nest.Init},
{"ring", ring.Init},
{"roborock", roborock.Init},
{"tapo", tapo.Init},
{"tuya", tuya.Init},
{"wyze", wyze.Init},
{"xiaomi", xiaomi.Init},
{"yandex", yandex.Init},
// Helper modules
+60 -46
View File
@@ -10,35 +10,49 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent
- Codecs can be incoming - **Recevers codecs**
- Codecs can be outgoing (two way audio) - **Senders codecs**
| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example |
|--------------|------------------|-------------------|------------------------------|--------------------|---------------|
| adts | http,tcp,pipe | http | aac | | `http:` |
| alsa | pipe | | | pcm | `alsa:` |
| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` |
| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` |
| flv | http,tcp,pipe | http | h264,aac | | `http:` |
| gopro | http+udp | | TODO | | `gopro:` |
| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` |
| hls/mpegts | http | | h264,h265,aac,opus | | `http:` |
| homekit | homekit+udp | | h264,eld* | | `homekit:` |
| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` |
| ivideon | ws | | h264 | | `ivideon:` |
| kasa | http | | h264,pcm_mulaw | | `kasa:` |
| h264 | http,tcp,pipe | http | h264 | | `http:` |
| hevc | http,tcp,pipe | http | hevc | | `http:` |
| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` |
| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` |
| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` |
| nest/webrtc | http+udp | | TODO | | `nest:` |
| roborock | mqtt+udp | | h264,opus | opus | `roborock:` |
| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` |
| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` |
| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` |
| tapo | http | | h264,pcma | pcm_alaw | `tapo:` |
| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` |
| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` |
| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` |
| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` |
| Group | Format | Protocols | Ingress | Recevers codecs | Senders codecs | Example |
|------------|--------------|-----------------|---------|---------------------------------|---------------------|---------------|
| Devices | alsa | pipe | | | pcm | `alsa:` |
| Devices | v4l2 | pipe | | | | `v4l2:` |
| Files | adts | http, tcp, pipe | http | aac | | `http:` |
| Files | flv | http, tcp, pipe | http | h264, aac | | `http:` |
| Files | h264 | http, tcp, pipe | http | h264 | | `http:` |
| Files | hevc | http, tcp, pipe | http | hevc | | `http:` |
| Files | hls | http | | h264, h265, aac, opus | | `http:` |
| Files | mjpeg | http, tcp, pipe | http | mjpeg | | `http:` |
| Files | mpegts | http, tcp, pipe | http | h264, hevc, aac, opus | | `http:` |
| Files | wav | http, tcp, pipe | http | pcm_alaw, pcm_mulaw | | `http:` |
| Net (pub) | mpjpeg | http, tcp, pipe | http | mjpeg | | `http:` |
| Net (pub) | onvif | rtsp | | | | `onvif:` |
| Net (pub) | rtmp | rtmp | rtmp | h264, aac | | `rtmp:` |
| Net (pub) | rtsp | rtsp, ws | rtsp | h264, hevc, aac, pcm*, opus | pcm*, opus | `rtsp:` |
| Net (pub) | webrtc* | webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw | `webrtc:` |
| Net (pub) | yuv4mpegpipe | http, tcp, pipe | http | rawvideo | | `http:` |
| Net (priv) | bubble | http | | h264, hevc, pcm_alaw | | `bubble:` |
| Net (priv) | doorbird | http | | | | `doorbird:` |
| Net (priv) | dvrip | tcp | | h264, hevc, pcm_alaw, pcm_mulaw | pcm_alaw | `dvrip:` |
| Net (priv) | eseecloud | http | | | | `eseecloud:` |
| Net (priv) | gopro | udp | | TODO | | `gopro:` |
| Net (priv) | hass | webrtc | | TODO | | `hass:` |
| Net (priv) | homekit | hap | | h264, eld* | | `homekit:` |
| Net (priv) | isapi | http | | | pcm_alaw, pcm_mulaw | `isapi:` |
| Net (priv) | kasa | http | | h264, pcm_mulaw | | `kasa:` |
| Net (priv) | nest | rtsp, webrtc | | TODO | | `nest:` |
| Net (priv) | ring | webrtc | | | | `ring:` |
| Net (priv) | roborock | webrtc | | h264, opus | opus | `roborock:` |
| Net (priv) | tapo | http | | h264, pcma | pcm_alaw | `tapo:` |
| Net (priv) | tuya | webrtc | | | | `tuya:` |
| Net (priv) | vigi | http | | | | `vigi:` |
| Net (priv) | webtorrent | webrtc | TODO | TODO | TODO | `webtorrent:` |
| Net (priv) | xiaomi* | cs2, tutk | | | | `xiaomi:` |
| Services | flussonic | ws | | | | `flussonic:` |
| Services | ivideon | ws | | h264 | | `ivideon:` |
| Services | yandex | webrtc | | | | `yandex:` |
| Other | echo | * | | | | `echo:` |
| Other | exec | pipe, rtsp | | | | `exec:` |
| Other | expr | * | | | | `expr:` |
| Other | ffmpeg | pipe, rtsp | | | | `ffmpeg:` |
| Other | stdin | pipe | | | pcm_alaw, pcm_mulaw | `stdin:` |
- **eld** - rare variant of aac codec
- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le
@@ -46,23 +60,23 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent
## Consumers (output)
| Format | Protocol | Send codecs | Recv codecs | Example |
|--------------|-------------|------------------------------|-------------------------|---------------------------------------|
| adts | http | aac | | `GET /api/stream.adts` |
| ascii | http | mjpeg | | `GET /api/stream.ascii` |
| flv | http | h264,aac | | `GET /api/stream.flv` |
| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` |
| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` |
| homekit | homekit+udp | h264,opus | | Apple HomeKit app |
| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` |
| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` |
| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` |
| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` |
| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` |
| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` |
| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` |
| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` |
| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` |
| Format | Protocol | Send codecs | Recv codecs | Example |
|--------------|----------|---------------------------------|---------------------------|---------------------------------------|
| adts | http | aac | | `GET /api/stream.adts` |
| ascii | http | mjpeg | | `GET /api/stream.ascii` |
| flv | http | h264, aac | | `GET /api/stream.flv` |
| hls/mpegts | http | h264, hevc, aac | | `GET /api/stream.m3u8` |
| hls/fmp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.m3u8?mp4` |
| homekit | hap | h264, opus | | Apple HomeKit app |
| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` |
| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` |
| mp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.mp4` |
| mse/fmp4 | ws | h264, hevc, aac, pcm*, opus | | `{"type":"mse"}` -> `/api/ws` |
| mpegts | http | h264, hevc, aac | | `GET /api/stream.ts` |
| rtmp | rtmp | h264, aac | | `rtmp://localhost:1935/{stream_name}` |
| rtsp | rtsp | h264, hevc, aac, pcm*, opus | | `rtsp://localhost:8554/{stream_name}` |
| webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw, opus | `{"type":"webrtc"}` -> `/api/ws` |
| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` |
- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le
+7
View File
@@ -10,6 +10,13 @@ import (
const ADTSHeaderSize = 7
func ADTSHeaderLen(b []byte) int {
if HasCRC(b) {
return 9 // 7 bytes header + 2 bytes CRC
}
return ADTSHeaderSize
}
func IsADTS(b []byte) bool {
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
// A 12 Syncword, all bits must be set to 1.
+1 -1
View File
@@ -277,7 +277,7 @@ func ParseCodecString(s string) *Codec {
codec.ClockRate = uint32(Atoi(ss[1]))
}
if len(ss) >= 3 {
codec.Channels = uint8(Atoi(ss[1]))
codec.Channels = uint8(Atoi(ss[2]))
}
return &codec
+47
View File
@@ -0,0 +1,47 @@
package debug
import (
"bytes"
"math/rand"
"net"
)
type badConn struct {
net.Conn
delay int
buf []byte
}
func NewBadConn(conn net.Conn) net.Conn {
return &badConn{Conn: conn}
}
const (
missChance = 0.05
delayChance = 0.1
)
func (c *badConn) Read(b []byte) (n int, err error) {
if rand.Float32() < missChance {
if _, err = c.Conn.Read(b); err != nil {
return
}
//log.Printf("bad conn: miss")
}
if c.delay > 0 {
if c.delay--; c.delay == 0 {
n = copy(b, c.buf)
return
}
} else if rand.Float32() < delayChance {
if n, err = c.Conn.Read(b); err != nil {
return
}
c.delay = 1 + rand.Intn(5)
c.buf = bytes.Clone(b[:n])
//log.Printf("bad conn: delay %d", c.delay)
}
return c.Conn.Read(b)
}
+11
View File
@@ -17,3 +17,14 @@ func TestDecodeSPS(t *testing.T) {
require.Equal(t, uint16(5120), sps.Width())
require.Equal(t, uint16(1440), sps.Height())
}
func TestDecodeSPS2(t *testing.T) {
s := "QgEBIUAAAAMAkAAAAwAAAwCWoAUCAWlnpbkShc1AQIC4QAAAAwBAAAAFFEn/eEAOpgAV+V8IBBA="
b, err := base64.StdEncoding.DecodeString(s)
require.Nil(t, err)
sps := DecodeSPS(b)
require.NotNil(t, sps)
require.Equal(t, uint16(640), sps.Width())
require.Equal(t, uint16(360), sps.Height())
}
+203
View File
@@ -0,0 +1,203 @@
package multitrans
import (
"bufio"
"bytes"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/google/uuid"
"github.com/pion/rtp"
)
type Client struct {
core.Connection
conn net.Conn
rd *bufio.Reader
closed core.Waiter
}
func Dial(rawURL string) (core.Producer, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if u.Port() == "" {
u.Host += ":554"
}
conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout)
if err != nil {
return nil, err
}
c := &Client{
conn: conn,
rd: bufio.NewReader(conn),
}
if err = c.handshake(u); err != nil {
_ = conn.Close()
return nil, err
}
c.Connection = core.Connection{
ID: core.NewID(),
FormatName: "multitrans",
Protocol: "rtsp",
RemoteAddr: conn.RemoteAddr().String(),
Source: rawURL,
Medias: []*core.Media{
{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}},
},
},
Transport: conn,
}
return c, nil
}
func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
sender := core.NewSender(media, track.Codec)
sender.Handler = func(packet *rtp.Packet) {
clone := rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: packet.Marker,
PayloadType: 8,
SequenceNumber: packet.SequenceNumber,
Timestamp: packet.Timestamp,
SSRC: packet.SSRC,
},
Payload: packet.Payload,
}
// Encapsulate in RTSP Interleaved Frame (Channel 1)
// $ + Channel(1 byte) + Length(2 bytes) + Packet
size := 12 + len(clone.Payload)
b := make([]byte, 4+size)
b[0] = '$'
b[1] = 1 // Channel 1 for audio
b[2] = byte(size >> 8)
b[3] = byte(size)
if _, err := clone.MarshalTo(b[4:]); err != nil {
return
}
if _, err := c.conn.Write(b); err != nil {
return
}
}
sender.HandleRTP(track)
c.Senders = append(c.Senders, sender)
return nil
}
func (c *Client) handshake(u *url.URL) error {
// Step 1: Get Challenge
uid := uuid.New().String()
uri := fmt.Sprintf("rtsp://%s/multitrans", u.Host)
data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 0\r\nX-Client-UUID: %s\r\n\r\n", uri, uid)
if _, err := c.conn.Write([]byte(data)); err != nil {
return err
}
res, err := tcp.ReadResponse(c.rd)
if err != nil {
return err
}
if res.StatusCode != http.StatusUnauthorized {
return errors.New("multitrans: expected 401, got " + res.Status)
}
auth := res.Header.Get("WWW-Authenticate")
realm := tcp.Between(auth, `realm="`, `"`)
nonce := tcp.Between(auth, `nonce="`, `"`)
// Step 2: Send Auth
user := u.User.Username()
pass, _ := u.User.Password()
ha1 := tcp.HexMD5(user, realm, pass)
ha2 := tcp.HexMD5("MULTITRANS", uri)
response := tcp.HexMD5(ha1, nonce, ha2)
authHeader := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
user, realm, nonce, uri, response)
data = fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 1\r\nAuthorization: %s\r\nX-Client-UUID: %s\r\n\r\n",
uri, authHeader, uid)
if _, err = c.conn.Write([]byte(data)); err != nil {
return err
}
res, err = tcp.ReadResponse(c.rd)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return errors.New("multitrans: auth failed: " + res.Status)
}
// Session: 7116520596809429228
session := res.Header.Get("Session")
if session == "" {
return errors.New("multitrans: no session")
}
return c.openTalkChannel(uri, session)
}
func (c *Client) openTalkChannel(uri, session string) error {
payload := `{"type":"request","seq":0,"params":{"method":"get","talk":{"mode":"full_duplex"}}}`
data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 2\r\nSession: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s",
uri, session, len(payload), payload)
if _, err := c.conn.Write([]byte(data)); err != nil {
return err
}
res, err := tcp.ReadResponse(c.rd)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return errors.New("multitrans: talkback failed: " + res.Status)
}
// Python checks for "error_code":0 in body.
if !bytes.Contains(res.Body, []byte(`"error_code":0`)) {
return fmt.Errorf("multitrans: talkback error: %s", string(res.Body))
}
return nil
}
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
return nil, core.ErrCantGetTrack
}
func (c *Client) Start() error {
_ = c.closed.Wait()
return nil
}
func (c *Client) Stop() error {
c.closed.Done(nil)
return c.Connection.Stop()
}
+4 -10
View File
@@ -15,14 +15,9 @@ type Envelope struct {
}
const (
prefix1 = `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
`
prefix2 = `<s:Body>
`
suffix = `
</s:Body>
</s:Envelope>`
prefix1 = `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">`
prefix2 = `<s:Body>`
suffix = `</s:Body></s:Envelope>`
)
func NewEnvelope() *Envelope {
@@ -54,8 +49,7 @@ func NewEnvelopeWithUser(user *url.Userinfo) *Envelope {
<wsu:Created xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">%s</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>
</s:Header>
`,
</s:Header>`,
user.Username(),
base64.StdEncoding.EncodeToString(h.Sum(nil)),
base64.StdEncoding.EncodeToString([]byte(nonce)),
+30 -11
View File
@@ -12,6 +12,12 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core"
)
type DiscoveryDevice struct {
URL string
Name string
Hardware string
}
func FindTagValue(b []byte, tag string) string {
re := regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`)
m := re.FindSubmatch(b)
@@ -27,7 +33,8 @@ func UUID() string {
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
}
func DiscoveryStreamingURLs() ([]string, error) {
// DiscoveryStreamingDevices return list of tuple (onvif_url, name, hardware)
func DiscoveryStreamingDevices() ([]DiscoveryDevice, error) {
conn, err := net.ListenUDP("udp4", nil)
if err != nil {
return nil, err
@@ -61,11 +68,9 @@ func DiscoveryStreamingURLs() ([]string, error) {
return nil, err
}
if err = conn.SetReadDeadline(time.Now().Add(time.Second * 3)); err != nil {
return nil, err
}
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var urls []string
var devices []DiscoveryDevice
b := make([]byte, 8192)
for {
@@ -81,21 +86,35 @@ func DiscoveryStreamingURLs() ([]string, error) {
continue
}
url := FindTagValue(b[:n], "XAddrs")
if url == "" {
device := DiscoveryDevice{
URL: FindTagValue(b[:n], "XAddrs"),
}
if device.URL == "" {
continue
}
// fix some buggy cameras
// <wsdd:XAddrs>http://0.0.0.0:8080/onvif/device_service</wsdd:XAddrs>
if strings.HasPrefix(url, "http://0.0.0.0") {
url = "http://" + addr.IP.String() + url[14:]
if s, ok := strings.CutPrefix(device.URL, "http://0.0.0.0"); ok {
device.URL = "http://" + addr.IP.String() + s
}
urls = append(urls, url)
// try to find the camera name and model (hardware)
scopes := FindTagValue(b[:n], "Scopes")
device.Name = findScope(scopes, "onvif://www.onvif.org/name/")
device.Hardware = findScope(scopes, "onvif://www.onvif.org/hardware/")
devices = append(devices, device)
}
return urls, nil
return devices, nil
}
func findScope(s, prefix string) string {
s = core.Between(s, prefix, " ")
s, _ = url.QueryUnescape(s)
return s
}
func atoi(s string) int {
+109 -86
View File
@@ -21,21 +21,24 @@ const (
DeviceGetScopes = "GetScopes"
DeviceGetServices = "GetServices"
DeviceGetSystemDateAndTime = "GetSystemDateAndTime"
DeviceSetSystemDateAndTime = "SetSystemDateAndTime"
DeviceSystemReboot = "SystemReboot"
)
const (
MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations"
MediaGetAudioSources = "GetAudioSources"
MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations"
MediaGetProfile = "GetProfile"
MediaGetProfiles = "GetProfiles"
MediaGetSnapshotUri = "GetSnapshotUri"
MediaGetStreamUri = "GetStreamUri"
MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations"
MediaGetVideoSources = "GetVideoSources"
MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration"
MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations"
MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations"
MediaGetAudioSources = "GetAudioSources"
MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations"
MediaGetProfile = "GetProfile"
MediaGetProfiles = "GetProfiles"
MediaGetSnapshotUri = "GetSnapshotUri"
MediaGetStreamUri = "GetStreamUri"
MediaGetVideoEncoderConfiguration = "GetVideoEncoderConfiguration"
MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations"
MediaGetVideoEncoderConfigurationOptions = "GetVideoEncoderConfigurationOptions"
MediaGetVideoSources = "GetVideoSources"
MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration"
MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations"
)
func GetRequestAction(b []byte) string {
@@ -54,13 +57,13 @@ func GetRequestAction(b []byte) string {
func GetCapabilitiesResponse(host string) []byte {
e := NewEnvelope()
e.Append(`<tds:GetCapabilitiesResponse>
e.Appendf(`<tds:GetCapabilitiesResponse>
<tds:Capabilities>
<tt:Device>
<tt:XAddr>http://`, host, `/onvif/device_service</tt:XAddr>
<tt:XAddr>http://%s/onvif/device_service</tt:XAddr>
</tt:Device>
<tt:Media>
<tt:XAddr>http://`, host, `/onvif/media_service</tt:XAddr>
<tt:XAddr>http://%s/onvif/media_service</tt:XAddr>
<tt:StreamingCapabilities>
<tt:RTPMulticast>false</tt:RTPMulticast>
<tt:RTP_TCP>false</tt:RTP_TCP>
@@ -68,24 +71,24 @@ func GetCapabilitiesResponse(host string) []byte {
</tt:StreamingCapabilities>
</tt:Media>
</tds:Capabilities>
</tds:GetCapabilitiesResponse>`)
</tds:GetCapabilitiesResponse>`, host, host)
return e.Bytes()
}
func GetServicesResponse(host string) []byte {
e := NewEnvelope()
e.Append(`<tds:GetServicesResponse>
e.Appendf(`<tds:GetServicesResponse>
<tds:Service>
<tds:Namespace>http://www.onvif.org/ver10/device/wsdl</tds:Namespace>
<tds:XAddr>http://`, host, `/onvif/device_service</tds:XAddr>
<tds:XAddr>http://%s/onvif/device_service</tds:XAddr>
<tds:Version><tt:Major>2</tt:Major><tt:Minor>5</tt:Minor></tds:Version>
</tds:Service>
<tds:Service>
<tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace>
<tds:XAddr>http://`, host, `/onvif/media_service</tds:XAddr>
<tds:XAddr>http://%s/onvif/media_service</tds:XAddr>
<tds:Version><tt:Major>2</tt:Major><tt:Minor>5</tt:Minor></tds:Version>
</tds:Service>
</tds:GetServicesResponse>`)
</tds:GetServicesResponse>`, host, host)
return e.Bytes()
}
@@ -120,30 +123,19 @@ func GetSystemDateAndTimeResponse() []byte {
func GetDeviceInformationResponse(manuf, model, firmware, serial string) []byte {
e := NewEnvelope()
e.Append(`<tds:GetDeviceInformationResponse>
<tds:Manufacturer>`, manuf, `</tds:Manufacturer>
<tds:Model>`, model, `</tds:Model>
<tds:FirmwareVersion>`, firmware, `</tds:FirmwareVersion>
<tds:SerialNumber>`, serial, `</tds:SerialNumber>
e.Appendf(`<tds:GetDeviceInformationResponse>
<tds:Manufacturer>%s</tds:Manufacturer>
<tds:Model>%s</tds:Model>
<tds:FirmwareVersion>%s</tds:FirmwareVersion>
<tds:SerialNumber>%s</tds:SerialNumber>
<tds:HardwareId>1.00</tds:HardwareId>
</tds:GetDeviceInformationResponse>`)
return e.Bytes()
}
func GetMediaServiceCapabilitiesResponse() []byte {
e := NewEnvelope()
e.Append(`<trt:GetServiceCapabilitiesResponse>
<trt:Capabilities SnapshotUri="true" Rotation="false" VideoSourceMode="false" OSD="false" TemporaryOSDText="false" EXICompression="false">
<trt:StreamingCapabilities RTPMulticast="false" RTP_TCP="false" RTP_RTSP_TCP="true" NonAggregateControl="false" NoRTSPStreaming="false" />
</trt:Capabilities>
</trt:GetServiceCapabilitiesResponse>`)
</tds:GetDeviceInformationResponse>`, manuf, model, firmware, serial)
return e.Bytes()
}
func GetProfilesResponse(names []string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetProfilesResponse>
`)
e.Append(`<trt:GetProfilesResponse>`)
for _, name := range names {
appendProfile(e, "Profiles", name)
}
@@ -153,38 +145,40 @@ func GetProfilesResponse(names []string) []byte {
func GetProfileResponse(name string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetProfileResponse>
`)
e.Append(`<trt:GetProfileResponse>`)
appendProfile(e, "Profile", name)
e.Append(`</trt:GetProfileResponse>`)
return e.Bytes()
}
func appendProfile(e *Envelope, tag, name string) {
// empty `RateControl` important for UniFi Protect
e.Append(`<trt:`, tag, ` token="`, name, `" fixed="true">
<tt:Name>`, name, `</tt:Name>
<tt:VideoSourceConfiguration token="`, name, `">
<tt:Name>VSC</tt:Name>
<tt:SourceToken>`, name, `</tt:SourceToken>
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
</tt:VideoSourceConfiguration>
<tt:VideoEncoderConfiguration token="vec">
<tt:Name>VEC</tt:Name>
<tt:Encoding>H264</tt:Encoding>
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
<tt:RateControl />
</tt:VideoEncoderConfiguration>
</trt:`, tag, `>
`)
// go2rtc name = ONVIF Profile Name = ONVIF Profile token
e.Appendf(`<trt:%s token="%s" fixed="true">`, tag, name)
e.Appendf(`<tt:Name>%s</tt:Name>`, name)
appendVideoSourceConfiguration(e, "VideoSourceConfiguration", name)
appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration")
e.Appendf(`</trt:%s>`, tag)
}
func GetVideoSourcesResponse(names []string) []byte {
// go2rtc name = ONVIF VideoSource token
e := NewEnvelope()
e.Append(`<trt:GetVideoSourcesResponse>`)
for _, name := range names {
e.Appendf(`<trt:VideoSources token="%s">
<tt:Framerate>30.000000</tt:Framerate>
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
</trt:VideoSources>`, name)
}
e.Append(`</trt:GetVideoSourcesResponse>`)
return e.Bytes()
}
func GetVideoSourceConfigurationsResponse(names []string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetVideoSourceConfigurationsResponse>
`)
e.Append(`<trt:GetVideoSourceConfigurationsResponse>`)
for _, name := range names {
appendProfile(e, "Configurations", name)
appendVideoSourceConfiguration(e, "Configurations", name)
}
e.Append(`</trt:GetVideoSourceConfigurationsResponse>`)
return e.Bytes()
@@ -192,46 +186,60 @@ func GetVideoSourceConfigurationsResponse(names []string) []byte {
func GetVideoSourceConfigurationResponse(name string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetVideoSourceConfigurationResponse>
`)
e.Append(`<trt:GetVideoSourceConfigurationResponse>`)
appendVideoSourceConfiguration(e, "Configuration", name)
e.Append(`</trt:GetVideoSourceConfigurationResponse>`)
return e.Bytes()
}
func appendVideoSourceConfiguration(e *Envelope, tag, name string) {
e.Append(`<trt:`, tag, ` token="`, name, `" fixed="true">
// go2rtc name = ONVIF VideoSourceConfiguration token
e.Appendf(`<tt:%s token="%s" fixed="true">
<tt:Name>VSC</tt:Name>
<tt:SourceToken>`, name, `</tt:SourceToken>
<tt:SourceToken>%s</tt:SourceToken>
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
</trt:`, tag, `>
`)
</tt:%s>`, tag, name, name, tag)
}
func GetVideoSourcesResponse(names []string) []byte {
func GetVideoEncoderConfigurationsResponse() []byte {
e := NewEnvelope()
e.Append(`<trt:GetVideoSourcesResponse>
`)
for _, name := range names {
e.Append(`<trt:VideoSources token="`, name, `">
<tt:Framerate>30.000000</tt:Framerate>
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
</trt:VideoSources>
`)
}
e.Append(`</trt:GetVideoSourcesResponse>`)
e.Append(`<trt:GetVideoEncoderConfigurationsResponse>`)
appendVideoEncoderConfiguration(e, "VideoEncoderConfigurations")
e.Append(`</trt:GetVideoEncoderConfigurationsResponse>`)
return e.Bytes()
}
func GetVideoEncoderConfigurationResponse() []byte {
e := NewEnvelope()
e.Append(`<trt:GetVideoEncoderConfigurationResponse>`)
appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration")
e.Append(`</trt:GetVideoEncoderConfigurationResponse>`)
return e.Bytes()
}
func appendVideoEncoderConfiguration(e *Envelope, tag string) {
// empty `RateControl` important for UniFi Protect
e.Appendf(`<tt:%s token="vec">
<tt:Name>VEC</tt:Name>
<tt:UseCount>1</tt:UseCount>
<tt:Encoding>H264</tt:Encoding>
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
<tt:Quality>0</tt:Quality>
<tt:RateControl><tt:FrameRateLimit>30</tt:FrameRateLimit><tt:EncodingInterval>1</tt:EncodingInterval><tt:BitrateLimit>8192</tt:BitrateLimit></tt:RateControl>
<tt:H264><tt:GovLength>10</tt:GovLength><tt:H264Profile>Main</tt:H264Profile></tt:H264>
<tt:SessionTimeout>PT10S</tt:SessionTimeout>
</tt:%s>`, tag, tag)
}
func GetStreamUriResponse(uri string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetStreamUriResponse><trt:MediaUri><tt:Uri>`, uri, `</tt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`)
e.Appendf(`<trt:GetStreamUriResponse><trt:MediaUri><tt:Uri>%s</tt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`, uri)
return e.Bytes()
}
func GetSnapshotUriResponse(uri string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetSnapshotUriResponse><trt:MediaUri><tt:Uri>`, uri, `</tt:Uri></trt:MediaUri></trt:GetSnapshotUriResponse>`)
e.Appendf(`<trt:GetSnapshotUriResponse><trt:MediaUri><tt:Uri>%s</tt:Uri></trt:MediaUri></trt:GetSnapshotUriResponse>`, uri)
return e.Bytes()
}
@@ -239,6 +247,10 @@ func StaticResponse(operation string) []byte {
switch operation {
case DeviceGetSystemDateAndTime:
return GetSystemDateAndTimeResponse()
case MediaGetVideoEncoderConfiguration:
return GetVideoEncoderConfigurationResponse()
case MediaGetVideoEncoderConfigurations:
return GetVideoEncoderConfigurationsResponse()
}
e := NewEnvelope()
@@ -247,11 +259,18 @@ func StaticResponse(operation string) []byte {
}
var responses = map[string]string{
ServiceGetServiceCapabilities: `<trt:GetServiceCapabilitiesResponse>
<trt:Capabilities SnapshotUri="true" Rotation="false" VideoSourceMode="false" OSD="false" TemporaryOSDText="false" EXICompression="false">
<trt:StreamingCapabilities RTPMulticast="false" RTP_TCP="false" RTP_RTSP_TCP="true" NonAggregateControl="false" NoRTSPStreaming="false" />
</trt:Capabilities>
</trt:GetServiceCapabilitiesResponse>`,
DeviceGetDiscoveryMode: `<tds:GetDiscoveryModeResponse><tds:DiscoveryMode>Discoverable</tds:DiscoveryMode></tds:GetDiscoveryModeResponse>`,
DeviceGetDNS: `<tds:GetDNSResponse><tds:DNSInformation /></tds:GetDNSResponse>`,
DeviceGetHostname: `<tds:GetHostnameResponse><tds:HostnameInformation /></tds:GetHostnameResponse>`,
DeviceGetNetworkDefaultGateway: `<tds:GetNetworkDefaultGatewayResponse><tds:NetworkGateway /></tds:GetNetworkDefaultGatewayResponse>`,
DeviceGetNTP: `<tds:GetNTPResponse><tds:NTPInformation /></tds:GetNTPResponse>`,
DeviceSetSystemDateAndTime: `<tds:SetSystemDateAndTimeResponse />`,
DeviceSystemReboot: `<tds:SystemRebootResponse><tds:Message>OK</tds:Message></tds:SystemRebootResponse>`,
DeviceGetNetworkInterfaces: `<tds:GetNetworkInterfacesResponse />`,
@@ -263,16 +282,20 @@ var responses = map[string]string{
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/type/Network_Video_Transmitter</tt:ScopeItem></tds:Scopes>
</tds:GetScopesResponse>`,
MediaGetVideoEncoderConfigurations: `<trt:GetVideoEncoderConfigurationsResponse>
<tt:VideoEncoderConfiguration token="vec">
<tt:Name>VEC</tt:Name>
<tt:Encoding>H264</tt:Encoding>
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
<tt:RateControl />
</tt:VideoEncoderConfiguration>
</trt:GetVideoEncoderConfigurationsResponse>`,
MediaGetAudioEncoderConfigurations: `<trt:GetAudioEncoderConfigurationsResponse />`,
MediaGetAudioSources: `<trt:GetAudioSourcesResponse />`,
MediaGetAudioSourceConfigurations: `<trt:GetAudioSourceConfigurationsResponse />`,
MediaGetVideoEncoderConfigurationOptions: `<trt:GetVideoEncoderConfigurationOptionsResponse>
<trt:Options>
<tt:QualityRange><tt:Min>1</tt:Min><tt:Max>6</tt:Max></tt:QualityRange>
<tt:H264>
<tt:ResolutionsAvailable><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:ResolutionsAvailable>
<tt:GovLengthRange><tt:Min>0</tt:Min><tt:Max>100</tt:Max></tt:GovLengthRange>
<tt:FrameRateRange><tt:Min>1</tt:Min><tt:Max>30</tt:Max></tt:FrameRateRange>
<tt:EncodingIntervalRange><tt:Min>1</tt:Min><tt:Max>100</tt:Max></tt:EncodingIntervalRange>
<tt:H264ProfilesSupported>Main</tt:H264ProfilesSupported>
</tt:H264>
</trt:Options>
</trt:GetVideoEncoderConfigurationOptionsResponse>`,
}
+59
View File
@@ -0,0 +1,59 @@
package tutk
// https://github.com/seydx/tutk_wyze#11-codec-reference
const (
CodecMPEG4 byte = 0x4C
CodecH263 byte = 0x4D
CodecH264 byte = 0x4E
CodecMJPEG byte = 0x4F
CodecH265 byte = 0x50
)
const (
CodecAACRaw byte = 0x86
CodecAACADTS byte = 0x87
CodecAACLATM byte = 0x88
CodecPCMU byte = 0x89
CodecPCMA byte = 0x8A
CodecADPCM byte = 0x8B
CodecPCML byte = 0x8C
CodecSPEEX byte = 0x8D
CodecMP3 byte = 0x8E
CodecG726 byte = 0x8F
CodecAACAlt byte = 0x90
CodecOpus byte = 0x92
)
var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000}
func GetSampleRateIndex(sampleRate uint32) uint8 {
for i, rate := range sampleRates {
if rate == sampleRate {
return uint8(i)
}
}
return 3 // default 16kHz
}
func GetSamplesPerFrame(codecID byte) uint32 {
switch codecID {
case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt:
return 1024
case CodecPCMU, CodecPCMA, CodecPCML, CodecADPCM, CodecSPEEX, CodecG726:
return 160
case CodecMP3:
return 1152
case CodecOpus:
return 960
default:
return 1024
}
}
func IsVideoCodec(id byte) bool {
return id >= CodecMPEG4 && id <= CodecH265
}
func IsAudioCodec(id byte) bool {
return id >= CodecAACRaw && id <= CodecOpus
}
+264
View File
@@ -0,0 +1,264 @@
package tutk
import (
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
)
func Dial(host, uid, username, password string) (*Conn, error) {
addr, err := net.ResolveUDPAddr("udp", host)
if err != nil {
// Default port for listening incoming LAN connections.
// Important. It's not using for real connection.
addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761}
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
c := &Conn{UDPConn: udpConn, addr: addr}
sid := GenSessionID()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
if addr.Port != 10001 {
err = c.connectDirect(uid, sid)
} else {
err = c.connectRemote(uid, sid)
}
if err != nil {
_ = c.Close()
return nil, err
}
if c.ver[0] >= 25 {
c.session = NewSession25(c, sid)
} else {
c.session = NewSession16(c, sid)
}
if err = c.clientStart(username, password); err != nil {
_ = c.Close()
return nil, err
}
go c.worker()
return c, nil
}
type Conn struct {
*net.UDPConn
addr *net.UDPAddr
session Session
ver []byte
err error
cmdMu sync.Mutex
cmdAck func()
}
// Read overwrite net.Conn
func (c *Conn) Read(buf []byte) (n int, err error) {
for {
var addr *net.UDPAddr
if n, addr, err = c.UDPConn.ReadFromUDP(buf); err != nil {
return 0, err
}
if string(c.addr.IP) != string(addr.IP) || n < 16 {
continue // skip messages from another IP
}
if c.addr.Port != addr.Port {
c.addr.Port = addr.Port
}
ReverseTransCodePartial(buf, buf[:n])
//log.Printf("<- %x", buf[:n])
return n, nil
}
}
// Write overwrite net.Conn
func (c *Conn) Write(b []byte) (n int, err error) {
//log.Printf("-> %x", b)
return c.UDPConn.WriteToUDP(TransCodePartial(nil, b), c.addr)
}
// RemoteAddr overwrite net.Conn
func (c *Conn) RemoteAddr() net.Addr {
return c.addr
}
func (c *Conn) Protocol() string {
return "tutk+udp"
}
func (c *Conn) Version() string {
if len(c.ver) == 1 {
return fmt.Sprintf("TUTK/%d", c.ver[0])
}
return fmt.Sprintf("TUTK/%d SDK %d.%d.%d.%d", c.ver[0], c.ver[1], c.ver[2], c.ver[3], c.ver[4])
}
func (c *Conn) ReadCommand() (ctrlType uint32, ctrlData []byte, err error) {
return c.session.RecvIOCtrl()
}
func (c *Conn) WriteCommand(ctrlType uint32, ctrlData []byte) error {
c.cmdMu.Lock()
defer c.cmdMu.Unlock()
var repeat atomic.Int32
repeat.Store(5)
timeout := time.NewTicker(time.Second)
defer timeout.Stop()
c.cmdAck = func() {
repeat.Store(0)
timeout.Reset(1)
}
buf := c.session.SendIOCtrl(ctrlType, ctrlData)
for {
if err := c.session.SessionWrite(0, buf); err != nil {
return err
}
<-timeout.C
r := repeat.Add(-1)
if r < 0 {
return nil
}
if r == 0 {
return fmt.Errorf("%s: can't send command %d", "tutk", ctrlType)
}
}
}
func (c *Conn) ReadPacket() (hdr, payload []byte, err error) {
return c.session.RecvFrameData()
}
func (c *Conn) WritePacket(hdr, payload []byte) error {
buf := c.session.SendFrameData(hdr, payload)
return c.session.SessionWrite(1, buf)
}
func (c *Conn) Error() error {
if c.err != nil {
return c.err
}
return io.EOF
}
func (c *Conn) worker() {
defer c.session.Close()
buf := make([]byte, 1200)
for {
n, err := c.Read(buf)
if err != nil {
c.err = fmt.Errorf("%s: %w", "tutk", err)
return
}
switch c.handleMsg(buf[:n]) {
case msgUnknown:
fmt.Printf("tutk: unknown msg: %x\n", buf[:n])
case msgError:
return
case msgCommandAck:
if c.cmdAck != nil {
c.cmdAck()
}
}
}
}
const (
msgUnknown = iota
msgError
msgPing
msgUnknownPing
msgClientStart
msgClientStart2
msgClientStartAck2
msgCommand
msgCommandAck
msgCounters
msgMediaChunk
msgMediaFrame
msgMediaReorder
msgMediaLost
msgCh5
msgUnknown0007 // time sync without data?
msgUnknown0008 // time sync with data?
msgUnknown0010
msgUnknown0013
msgUnknown0900
msgUnknown0a08
msgUnknownCh1c
msgDafang0012
)
func (c *Conn) handleMsg(msg []byte) int {
// off sample
// 0 0402 tutk magic
// 2 120a tutk version (120a, 190a...)
// 4 0800 msg size = len(b)-16
// 6 0000 channel seq
// 8 28041200 msg type
// 14 0100 channel (not all msg)
// 28 0700 msg data (not all msg)
switch msg[8] {
case 0x08:
switch ch := msg[14]; ch {
case 0, 1:
return c.session.SessionRead(ch, msg[28:])
case 5:
if len(msg) == 48 {
_, _ = c.Write(msgAckCh5(msg))
return msgCh5
}
case 0x1c:
return msgUnknownCh1c
}
case 0x18:
return msgUnknownPing
case 0x28:
if len(msg) == 24 {
_, _ = c.Write(msgAckPing(msg))
return msgPing
}
}
return msgUnknown
}
func msgAckPing(msg []byte) []byte {
// <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0
// -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0
msg[8] = 0x27
msg[10] = 0x21
return msg
}
func msgAckCh5(msg []byte) []byte {
// <- [48] 0402190a 20000400 07042100 7ecc05000c0000007ecc93c456c2561f 5a97c2f101050000000000000000000000010000
// -> [48] 0402190a 20000400 08041200 7ecc05000c0000007ecc93c456c2561f 5a97c2f141050000000000000000000000010000
msg[8] = 0x07
msg[10] = 0x21
msg[32] = 0x41
return msg
}
+279
View File
@@ -0,0 +1,279 @@
package tutk
import (
"encoding/binary"
"math/bits"
)
// I'd like to say hello to Charlie. Your name is forever etched into the history of streaming software.
const charlie = "Charlie is the designer of P2P!!"
func ReverseTransCodePartial(dst, src []byte) []byte {
n := len(src)
tmp := make([]byte, n)
if len(dst) < n {
dst = make([]byte, n)
}
src16 := src
tmp16 := tmp
dst16 := dst
for ; n >= 16; n -= 16 {
for i := 0; i != 16; i += 4 {
x := binary.LittleEndian.Uint32(src16[i:])
binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3))
}
swap(dst16, tmp16, 16)
for i := 0; i != 16; i++ {
tmp16[i] = dst16[i] ^ charlie[i]
}
for i := 0; i != 16; i += 4 {
x := binary.LittleEndian.Uint32(tmp16[i:])
binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1))
}
tmp16 = tmp16[16:]
dst16 = dst16[16:]
src16 = src16[16:]
}
swap(tmp16, src16, n)
for i := 0; i < n; i++ {
dst16[i] = tmp16[i] ^ charlie[i]
}
return dst
}
func ReverseTransCodeBlob(src []byte) []byte {
if len(src) < 16 {
return ReverseTransCodePartial(nil, src)
}
dst := make([]byte, len(src))
header := ReverseTransCodePartial(nil, src[:16])
copy(dst, header)
if len(src) > 16 {
if dst[3]&1 != 0 { // Partial encryption (check decrypted header)
remaining := len(src) - 16
decryptLen := min(remaining, 48)
if decryptLen > 0 {
decrypted := ReverseTransCodePartial(nil, src[16:16+decryptLen])
copy(dst[16:], decrypted)
}
if remaining > 48 {
copy(dst[64:], src[64:])
}
} else { // Full decryption
decrypted := ReverseTransCodePartial(nil, src[16:])
copy(dst[16:], decrypted)
}
}
return dst
}
func TransCodePartial(dst, src []byte) []byte {
n := len(src)
tmp := make([]byte, n)
if len(dst) < n {
dst = make([]byte, n)
}
src16 := src
tmp16 := tmp
dst16 := dst
for ; n >= 16; n -= 16 {
for i := 0; i != 16; i += 4 {
x := binary.LittleEndian.Uint32(src16[i:])
binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1))
}
for i := 0; i != 16; i++ {
dst16[i] = tmp16[i] ^ charlie[i]
}
swap(tmp16, dst16, 16)
for i := 0; i != 16; i += 4 {
x := binary.LittleEndian.Uint32(tmp16[i:])
binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3))
}
tmp16 = tmp16[16:]
dst16 = dst16[16:]
src16 = src16[16:]
}
for i := 0; i < n; i++ {
tmp16[i] = src16[i] ^ charlie[i]
}
swap(dst16, tmp16, n)
return dst
}
func TransCodeBlob(src []byte) []byte {
if len(src) < 16 {
return TransCodePartial(nil, src)
}
dst := make([]byte, len(src))
header := TransCodePartial(nil, src[:16])
copy(dst, header)
if len(src) > 16 {
if src[3]&1 != 0 { // Partial encryption
remaining := len(src) - 16
encryptLen := min(remaining, 48)
if encryptLen > 0 {
encrypted := TransCodePartial(nil, src[16:16+encryptLen])
copy(dst[16:], encrypted)
}
if remaining > 48 {
copy(dst[64:], src[64:])
}
} else { // Full encryption
encrypted := TransCodePartial(nil, src[16:])
copy(dst[16:], encrypted)
}
}
return dst
}
func swap(dst, src []byte, n int) {
switch n {
case 2:
_, _ = src[1], dst[1]
dst[0] = src[1]
dst[1] = src[0]
return
case 4:
_, _ = src[3], dst[3]
dst[0] = src[2]
dst[1] = src[3]
dst[2] = src[0]
dst[3] = src[1]
return
case 8:
_, _ = src[7], dst[7]
dst[0] = src[7]
dst[1] = src[4]
dst[2] = src[3]
dst[3] = src[2]
dst[4] = src[1]
dst[5] = src[6]
dst[6] = src[5]
dst[7] = src[0]
return
case 16:
_, _ = src[15], dst[15]
dst[0] = src[11]
dst[1] = src[9]
dst[2] = src[8]
dst[3] = src[15]
dst[4] = src[13]
dst[5] = src[10]
dst[6] = src[12]
dst[7] = src[14]
dst[8] = src[2]
dst[9] = src[1]
dst[10] = src[5]
dst[11] = src[0]
dst[12] = src[6]
dst[13] = src[4]
dst[14] = src[7]
dst[15] = src[3]
return
}
copy(dst, src[:n])
}
const delta = 0x9e3779b9
func XXTEADecrypt(dst, src, key []byte) {
const n = int8(4) // support only 16 bytes src
var w, k [n]uint32
for i := int8(0); i < n; i++ {
w[i] = binary.LittleEndian.Uint32(src)
k[i] = binary.LittleEndian.Uint32(key)
src = src[4:]
key = key[4:]
}
rounds := 52/n + 6
sum := uint32(rounds) * delta
for ; rounds > 0; rounds-- {
w0 := w[0]
i2 := int8((sum >> 2) & 3)
for i := n - 1; i >= 0; i-- {
wi := w[(i-1)&3]
ki := k[i^i2]
t1 := (w0 ^ sum) + (wi ^ ki)
t2 := (wi >> 5) ^ (w0 << 2)
t3 := (w0 >> 3) ^ (wi << 4)
w[i] -= t1 ^ (t2 + t3)
w0 = w[i]
}
sum -= delta
}
for _, i := range w {
binary.LittleEndian.PutUint32(dst, i)
dst = dst[4:]
}
}
func XXTEADecryptVar(data, key []byte) []byte {
if len(data) < 8 || len(key) < 16 {
return nil
}
k := make([]uint32, 4)
for i := range 4 {
k[i] = binary.LittleEndian.Uint32(key[i*4:])
}
n := max(len(data)/4, 2)
v := make([]uint32, n)
for i := 0; i < len(data)/4; i++ {
v[i] = binary.LittleEndian.Uint32(data[i*4:])
}
rounds := 6 + 52/n
sum := uint32(rounds) * delta
y := v[0]
for rounds > 0 {
e := (sum >> 2) & 3
for p := n - 1; p > 0; p-- {
z := v[p-1]
v[p] -= xxteaMX(sum, y, z, p, e, k)
y = v[p]
}
z := v[n-1]
v[0] -= xxteaMX(sum, y, z, 0, e, k)
y = v[0]
sum -= delta
rounds--
}
result := make([]byte, n*4)
for i := range n {
binary.LittleEndian.PutUint32(result[i*4:], v[i])
}
return result[:len(data)]
}
func xxteaMX(sum, y, z uint32, p int, e uint32, k []uint32) uint32 {
return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z))
}
+14
View File
@@ -0,0 +1,14 @@
package tutk
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestXXTEADecrypt(t *testing.T) {
buf := []byte("WERhJxb87WF3zgPa")
key := []byte("GAgDiwVPg2E4GMke")
XXTEADecrypt(buf, buf, key)
require.Equal(t, "\xc4\xa6\x2c\xa1\x10\x64\x17\xa5\xda\x02\xe1\x62\xa5\xf0\x62\x71", string(buf))
}
+35
View File
@@ -0,0 +1,35 @@
package dtls
import (
"crypto/sha256"
"encoding/base64"
"strings"
)
func CalculateAuthKey(enr, mac string) []byte {
data := enr + strings.ToUpper(mac)
hash := sha256.Sum256([]byte(data))
b64 := base64.StdEncoding.EncodeToString(hash[:6])
b64 = strings.ReplaceAll(b64, "+", "Z")
b64 = strings.ReplaceAll(b64, "/", "9")
b64 = strings.ReplaceAll(b64, "=", "A")
return []byte(b64)
}
func DerivePSK(enr string) []byte {
// DerivePSK derives the DTLS PSK from ENR
// TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR)
// contains a 0x00 byte, the PSK is truncated at that position.
hash := sha256.Sum256([]byte(enr))
pskLen := 32
for i := range 32 {
if hash[i] == 0x00 {
pskLen = i
break
}
}
psk := make([]byte, 32)
copy(psk[:pskLen], hash[:pskLen])
return psk
}
+218
View File
@@ -0,0 +1,218 @@
package dtls
import (
"crypto/cipher"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"hash"
"sync/atomic"
"github.com/pion/dtls/v3"
"github.com/pion/dtls/v3/pkg/crypto/clientcertificate"
"github.com/pion/dtls/v3/pkg/crypto/prf"
"github.com/pion/dtls/v3/pkg/protocol"
"github.com/pion/dtls/v3/pkg/protocol/recordlayer"
"golang.org/x/crypto/chacha20poly1305"
)
const CipherSuiteID_CCAC dtls.CipherSuiteID = 0xCCAC
const (
chachaTagLength = 16
chachaNonceLength = 12
)
var (
errDecryptPacket = &protocol.TemporaryError{Err: errors.New("failed to decrypt packet")}
errCipherSuiteNotInit = &protocol.TemporaryError{Err: errors.New("CipherSuite not initialized")}
)
type ChaCha20Poly1305Cipher struct {
localCipher, remoteCipher cipher.AEAD
localWriteIV, remoteWriteIV []byte
}
func NewChaCha20Poly1305Cipher(localKey, localWriteIV, remoteKey, remoteWriteIV []byte) (*ChaCha20Poly1305Cipher, error) {
localCipher, err := chacha20poly1305.New(localKey)
if err != nil {
return nil, err
}
remoteCipher, err := chacha20poly1305.New(remoteKey)
if err != nil {
return nil, err
}
return &ChaCha20Poly1305Cipher{
localCipher: localCipher,
localWriteIV: localWriteIV,
remoteCipher: remoteCipher,
remoteWriteIV: remoteWriteIV,
}, nil
}
func generateAEADAdditionalData(h *recordlayer.Header, payloadLen int) []byte {
var additionalData [13]byte
binary.BigEndian.PutUint64(additionalData[:], h.SequenceNumber)
binary.BigEndian.PutUint16(additionalData[:], h.Epoch)
additionalData[8] = byte(h.ContentType)
additionalData[9] = h.Version.Major
additionalData[10] = h.Version.Minor
binary.BigEndian.PutUint16(additionalData[11:], uint16(payloadLen))
return additionalData[:]
}
func computeNonce(iv []byte, epoch uint16, sequenceNumber uint64) []byte {
nonce := make([]byte, chachaNonceLength)
binary.BigEndian.PutUint64(nonce[4:], sequenceNumber)
binary.BigEndian.PutUint16(nonce[4:], epoch)
for i := range chachaNonceLength {
nonce[i] ^= iv[i]
}
return nonce
}
func (c *ChaCha20Poly1305Cipher) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) {
payload := raw[pkt.Header.Size():]
raw = raw[:pkt.Header.Size()]
nonce := computeNonce(c.localWriteIV, pkt.Header.Epoch, pkt.Header.SequenceNumber)
additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
encryptedPayload := c.localCipher.Seal(nil, nonce, payload, additionalData)
r := make([]byte, len(raw)+len(encryptedPayload))
copy(r, raw)
copy(r[len(raw):], encryptedPayload)
binary.BigEndian.PutUint16(r[pkt.Header.Size()-2:], uint16(len(r)-pkt.Header.Size()))
return r, nil
}
func (c *ChaCha20Poly1305Cipher) Decrypt(header recordlayer.Header, in []byte) ([]byte, error) {
err := header.Unmarshal(in)
switch {
case err != nil:
return nil, err
case header.ContentType == protocol.ContentTypeChangeCipherSpec:
return in, nil
case len(in) <= header.Size()+chachaTagLength:
return nil, fmt.Errorf("ciphertext too short: %d <= %d", len(in), header.Size()+chachaTagLength)
}
nonce := computeNonce(c.remoteWriteIV, header.Epoch, header.SequenceNumber)
out := in[header.Size():]
additionalData := generateAEADAdditionalData(&header, len(out)-chachaTagLength)
out, err = c.remoteCipher.Open(out[:0], nonce, out, additionalData)
if err != nil {
return nil, fmt.Errorf("%w: %v", errDecryptPacket, err)
}
return append(in[:header.Size()], out...), nil
}
type TLSEcdhePskWithChacha20Poly1305Sha256 struct {
aead atomic.Value
}
func NewTLSEcdhePskWithChacha20Poly1305Sha256() *TLSEcdhePskWithChacha20Poly1305Sha256 {
return &TLSEcdhePskWithChacha20Poly1305Sha256{}
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) CertificateType() clientcertificate.Type {
return clientcertificate.Type(0)
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) KeyExchangeAlgorithm() dtls.CipherSuiteKeyExchangeAlgorithm {
return dtls.CipherSuiteKeyExchangeAlgorithmPsk | dtls.CipherSuiteKeyExchangeAlgorithmEcdhe
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ECC() bool {
return true
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ID() dtls.CipherSuiteID {
return CipherSuiteID_CCAC
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) String() string {
return "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256"
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) HashFunc() func() hash.Hash {
return sha256.New
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) AuthenticationType() dtls.CipherSuiteAuthenticationType {
return dtls.CipherSuiteAuthenticationTypePreSharedKey
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) IsInitialized() bool {
return c.aead.Load() != nil
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Init(masterSecret, clientRandom, serverRandom []byte, isClient bool) error {
const (
prfMacLen = 0
prfKeyLen = 32
prfIvLen = 12
)
keys, err := prf.GenerateEncryptionKeys(
masterSecret, clientRandom, serverRandom,
prfMacLen, prfKeyLen, prfIvLen,
c.HashFunc(),
)
if err != nil {
return err
}
var aead *ChaCha20Poly1305Cipher
if isClient {
aead, err = NewChaCha20Poly1305Cipher(
keys.ClientWriteKey, keys.ClientWriteIV,
keys.ServerWriteKey, keys.ServerWriteIV,
)
} else {
aead, err = NewChaCha20Poly1305Cipher(
keys.ServerWriteKey, keys.ServerWriteIV,
keys.ClientWriteKey, keys.ClientWriteIV,
)
}
if err != nil {
return err
}
c.aead.Store(aead)
return nil
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) {
aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher)
if !ok {
return nil, fmt.Errorf("%w: unable to encrypt", errCipherSuiteNotInit)
}
return aead.Encrypt(pkt, raw)
}
func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Decrypt(h recordlayer.Header, raw []byte) ([]byte, error) {
aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher)
if !ok {
return nil, fmt.Errorf("%w: unable to decrypt", errCipherSuiteNotInit)
}
return aead.Decrypt(h, raw)
}
func CustomCipherSuites() []dtls.CipherSuite {
return []dtls.CipherSuite{
NewTLSEcdhePskWithChacha20Poly1305Sha256(),
}
}
+987
View File
@@ -0,0 +1,987 @@
package dtls
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/pion/dtls/v3"
)
const (
magicCC51 = "\x51\xcc" // (wyze specific?)
sdkVersion42 = "\x01\x01\x02\x04" // 4.2.1.1
sdkVersion43 = "\x00\x08\x03\x04" // 4.3.8.0
)
const (
cmdDiscoReq uint16 = 0x0601
cmdDiscoRes uint16 = 0x0602
cmdSessionReq uint16 = 0x0402
cmdSessionRes uint16 = 0x0404
cmdDataTX uint16 = 0x0407
cmdDataRX uint16 = 0x0408
cmdKeepaliveReq uint16 = 0x0427
cmdKeepaliveRes uint16 = 0x0428
headerSize = 16
discoBodySize = 72
discoSize = headerSize + discoBodySize
sessionBody = 36
sessionSize = headerSize + sessionBody
)
const (
cmdDiscoCC51 uint16 = 0x1002
cmdKeepaliveCC51 uint16 = 0x1202
cmdDTLSCC51 uint16 = 0x1502
payloadSizeCC51 uint16 = 0x0028
packetSizeCC51 = 52
headerSizeCC51 = 28
authSizeCC51 = 20
keepaliveSizeCC51 = 48
)
const (
magicAVLoginResp uint16 = 0x2100
magicIOCtrl uint16 = 0x7000
magicChannelMsg uint16 = 0x1000
magicACK uint16 = 0x0009
magicAVLogin1 uint16 = 0x0000
magicAVLogin2 uint16 = 0x2000
)
const (
protoVersion uint16 = 0x000c
defaultCaps uint32 = 0x001f07fb
)
const (
iotcChannelMain = 0 // Main AV (we = DTLS Client)
iotcChannelBack = 1 // Backchannel (we = DTLS Server)
)
type DTLSConn struct {
conn *net.UDPConn
addr *net.UDPAddr
frames *tutk.FrameHandler
err error
verbose bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
// DTLS
clientConn *dtls.Conn
serverConn *dtls.Conn
clientBuf chan []byte
serverBuf chan []byte
rawCmd chan []byte
// Identity
uid string
authKey string
enr string
psk []byte
// Session
sid []byte
ticket uint16
hasTwoWayStreaming bool
// Protocol
isCC51 bool
seq uint16
seqCmd uint16
avSeq uint32
kaSeq uint32
audioSeq uint32
audioFrameNo uint32
// Ack
ackFlags uint16
rxSeqStart uint16
rxSeqEnd uint16
rxSeqInit bool
cmdAck func()
}
func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*DTLSConn, error) {
udp, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
_ = udp.SetReadBuffer(2 * 1024 * 1024)
ctx, cancel := context.WithCancel(context.Background())
psk := DerivePSK(enr)
if port == 0 {
port = 32761
}
c := &DTLSConn{
conn: udp,
addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port},
uid: uid,
authKey: authKey,
enr: enr,
psk: psk,
verbose: verbose,
ctx: ctx,
cancel: cancel,
rxSeqStart: 0xffff,
rxSeqEnd: 0xffff,
}
if err = c.discovery(); err != nil {
_ = c.Close()
return nil, err
}
c.clientBuf = make(chan []byte, 64)
c.serverBuf = make(chan []byte, 64)
c.rawCmd = make(chan []byte, 16)
c.frames = tutk.NewFrameHandler(c.verbose)
c.wg.Add(1)
go c.reader()
if err = c.connect(); err != nil {
_ = c.Close()
return nil, err
}
c.wg.Add(1)
go c.worker()
return c, nil
}
func (c *DTLSConn) AVClientStart(timeout time.Duration) error {
randomID := tutk.GenSessionID()
pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID)
pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID)
pkt2[20]++ // pkt2 has randomID incremented by 1
if _, err := c.clientConn.Write(pkt1); err != nil {
return fmt.Errorf("av login 1 failed: %w", err)
}
time.Sleep(10 * time.Millisecond)
if _, err := c.clientConn.Write(pkt2); err != nil {
return fmt.Errorf("av login 2 failed: %w", err)
}
// Wait for response
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case data, ok := <-c.rawCmd:
if !ok {
return io.EOF
}
if len(data) >= 32 && binary.LittleEndian.Uint16(data) == magicAVLoginResp {
c.hasTwoWayStreaming = data[31] == 1
ack := c.msgACK()
c.clientConn.Write(ack)
// Start ACK sender for continuous streaming
c.wg.Add(1)
go func() {
defer c.wg.Done()
ackTicker := time.NewTicker(100 * time.Millisecond)
defer ackTicker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ackTicker.C:
if c.clientConn != nil {
ack := c.msgACK()
c.clientConn.Write(ack)
}
}
}
}()
return nil
}
case <-timer.C:
return context.DeadlineExceeded
}
}
}
func (c *DTLSConn) AVServStart() error {
conn, err := NewDTLSServer(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf, c.psk)
if err != nil {
return fmt.Errorf("dtls: server handshake failed: %w", err)
}
if c.verbose {
fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack)
fmt.Printf("[SERVER] Waiting for AV Login request from camera...\n")
}
// Wait for AV Login request from camera
buf := make([]byte, 1024)
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
go conn.Close()
return fmt.Errorf("read av login: %w", err)
}
if c.verbose {
fmt.Printf("[SERVER] AV Login request len=%d data:\n%s", n, hexDump(buf[:n]))
}
if n < 24 {
go conn.Close()
return fmt.Errorf("av login too short: %d bytes", n)
}
checksum := binary.LittleEndian.Uint32(buf[20:])
resp := c.msgAVLoginResponse(checksum)
if c.verbose {
fmt.Printf("[SERVER] Sending AV Login response: %d bytes\n", len(resp))
}
if _, err = conn.Write(resp); err != nil {
go conn.Close()
return fmt.Errorf("write av login response: %w", err)
}
if c.verbose {
fmt.Printf("[SERVER] AV Login response sent, waiting for possible resend...\n")
}
// Camera may resend, respond again
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
if n, _ = conn.Read(buf); n > 0 {
if c.verbose {
fmt.Printf("[SERVER] Received AV Login resend: %d bytes\n", n)
}
conn.Write(resp)
}
conn.SetReadDeadline(time.Time{})
if c.verbose {
fmt.Printf("[SERVER] AV Login complete, ready for two way streaming\n")
}
c.mu.Lock()
c.serverConn = conn
c.mu.Unlock()
return nil
}
func (c *DTLSConn) AVServStop() error {
c.mu.Lock()
serverConn := c.serverConn
c.serverConn = nil
// Reset audio TX state
c.audioSeq = 0
c.audioFrameNo = 0
c.mu.Unlock()
if serverConn == nil {
return nil
}
go serverConn.Close()
return nil
}
func (c *DTLSConn) AVRecvFrameData() (*tutk.Packet, error) {
select {
case pkt, ok := <-c.frames.Recv():
if !ok {
return nil, c.Error()
}
return pkt, nil
case <-c.ctx.Done():
return nil, c.Error()
}
}
func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error {
c.mu.Lock()
conn := c.serverConn
if conn == nil {
c.mu.Unlock()
return fmt.Errorf("av server not ready")
}
frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels)
c.mu.Unlock()
n, err := conn.Write(frame)
if c.verbose {
if err != nil {
fmt.Printf("[SERVER TX] DTLS Write ERROR: %v\n", err)
} else {
fmt.Printf("[SERVER TX] len=%d, data:\n%s", n, hexDump(frame))
}
}
return err
}
func (c *DTLSConn) Write(data []byte) error {
if c.isCC51 {
_, err := c.conn.WriteToUDP(data, c.addr)
return err
}
_, err := c.conn.WriteToUDP(tutk.TransCodeBlob(data), c.addr)
return err
}
func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error {
var frame []byte
if c.isCC51 {
frame = c.msgTxDataCC51(payload, channel)
} else {
frame = c.msgTxData(payload, channel)
}
return c.Write(frame)
}
func (c *DTLSConn) WriteIOCtrl(payload []byte) error {
_, err := c.conn.Write(c.msgIOCtrl(payload))
return err
}
func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) {
var t *time.Timer
t = time.AfterFunc(1, func() {
if err := c.Write(req); err == nil && t != nil {
t.Reset(time.Second)
}
})
defer t.Stop()
_ = c.conn.SetDeadline(time.Now().Add(5 * time.Second))
defer c.conn.SetDeadline(time.Time{})
buf := make([]byte, 2048)
for {
n, addr, err := c.conn.ReadFromUDP(buf)
if err != nil {
return nil, err
}
if string(addr.IP) != string(c.addr.IP) || n < 16 {
continue
}
var res []byte
if c.isCC51 {
res = buf[:n]
} else {
res = tutk.ReverseTransCodeBlob(buf[:n])
}
if ok(res) {
c.addr.Port = addr.Port
return res, nil
}
}
}
func (c *DTLSConn) WriteAndWaitIOCtrl(payload []byte, match func([]byte) bool, timeout time.Duration) ([]byte, error) {
frame := c.msgIOCtrl(payload)
var t *time.Timer
t = time.AfterFunc(1, func() {
c.mu.RLock()
conn := c.clientConn
c.mu.RUnlock()
if conn != nil {
if _, err := conn.Write(frame); err == nil && t != nil {
t.Reset(time.Second)
}
}
})
defer t.Stop()
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case data, ok := <-c.rawCmd:
if !ok {
return nil, io.EOF
}
ack := c.msgACK()
c.clientConn.Write(ack)
if match(data) {
return data, nil
}
case <-timer.C:
return nil, fmt.Errorf("timeout waiting for response")
}
}
}
func (c *DTLSConn) HasTwoWayStreaming() bool {
return c.hasTwoWayStreaming
}
func (c *DTLSConn) IsBackchannelReady() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.serverConn != nil
}
func (c *DTLSConn) RemoteAddr() *net.UDPAddr {
return c.addr
}
func (c *DTLSConn) LocalAddr() *net.UDPAddr {
return c.conn.LocalAddr().(*net.UDPAddr)
}
func (c *DTLSConn) SetDeadline(t time.Time) error {
return c.conn.SetDeadline(t)
}
func (c *DTLSConn) Close() error {
c.cancel()
c.mu.Lock()
if conn := c.serverConn; conn != nil {
c.serverConn = nil
go conn.Close()
}
if conn := c.clientConn; conn != nil {
c.clientConn = nil
go conn.Close()
}
if c.frames != nil {
c.frames.Close()
}
c.mu.Unlock()
c.wg.Wait()
return c.conn.Close()
}
func (c *DTLSConn) Error() error {
if c.err != nil {
return c.err
}
return io.EOF
}
func (c *DTLSConn) discovery() error {
c.sid = tutk.GenSessionID()
pktIOTC := tutk.TransCodeBlob(c.msgDisco(1))
pktCC51 := c.msgDiscoCC51(0, 0, false)
buf := make([]byte, 2048)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
c.conn.WriteToUDP(pktIOTC, c.addr)
c.conn.WriteToUDP(pktCC51, c.addr)
c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
n, addr, err := c.conn.ReadFromUDP(buf)
if err != nil {
continue
}
if !addr.IP.Equal(c.addr.IP) {
continue
}
// CC51 protocol
if n >= packetSizeCC51 && string(buf[:2]) == magicCC51 {
if binary.LittleEndian.Uint16(buf[4:]) == cmdDiscoCC51 {
c.addr, c.isCC51, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:])
if n >= 24 {
copy(c.sid, buf[16:24])
}
return c.discoDoneCC51()
}
continue
}
// IOTC Protocol (Basis)
data := tutk.ReverseTransCodeBlob(buf[:n])
if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes {
c.addr, c.isCC51 = addr, false
return c.discoDone()
}
}
return fmt.Errorf("discovery timeout")
}
func (c *DTLSConn) discoDone() error {
c.Write(c.msgDisco(2))
time.Sleep(100 * time.Millisecond)
_, err := c.WriteAndWait(c.msgSession(), func(res []byte) bool {
return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == cmdSessionRes
})
return err
}
func (c *DTLSConn) discoDoneCC51() error {
_, err := c.WriteAndWait(c.msgDiscoCC51(2, c.ticket, false), func(res []byte) bool {
if len(res) < packetSizeCC51 || string(res[:2]) != magicCC51 {
return false
}
cmd := binary.LittleEndian.Uint16(res[4:])
dir := binary.LittleEndian.Uint16(res[8:])
seq := binary.LittleEndian.Uint16(res[12:])
return cmd == cmdDiscoCC51 && dir == 0xFFFF && seq == 3
})
return err
}
func (c *DTLSConn) connect() error {
conn, err := NewDTLSClient(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf, c.psk)
if err != nil {
return fmt.Errorf("dtls: client handshake failed: %w", err)
}
c.mu.Lock()
c.clientConn = conn
c.mu.Unlock()
if c.verbose {
fmt.Printf("[DTLS] Client handshake complete on channel %d\n", iotcChannelMain)
}
return nil
}
func (c *DTLSConn) worker() {
defer c.wg.Done()
buf := make([]byte, 2048)
for {
select {
case <-c.ctx.Done():
return
default:
}
n, err := c.clientConn.Read(buf)
if err != nil {
c.err = err
return
}
if n < 2 {
continue
}
data := buf[:n]
magic := binary.LittleEndian.Uint16(data)
if c.verbose {
fmt.Printf("[DTLS RX] magic=0x%04x len=%d\n", magic, n)
}
switch magic {
case magicAVLoginResp:
c.queue(c.rawCmd, data)
case magicIOCtrl, magicChannelMsg:
c.queue(c.rawCmd, data)
case protoVersion:
// Seq-Tracking
if len(data) >= 8 {
seq := binary.LittleEndian.Uint16(data[4:])
if !c.rxSeqInit {
c.rxSeqInit = true
}
if seq > c.rxSeqEnd || c.rxSeqEnd == 0xffff {
c.rxSeqEnd = seq
}
}
c.queue(c.rawCmd, data)
case magicACK:
c.mu.RLock()
ack := c.cmdAck
c.mu.RUnlock()
if ack != nil {
ack()
}
default:
channel := data[0]
if channel == tutk.ChannelAudio || channel == tutk.ChannelIVideo || channel == tutk.ChannelPVideo {
c.frames.Handle(data)
}
}
}
}
func (c *DTLSConn) reader() {
defer c.wg.Done()
buf := make([]byte, 2048)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
n, addr, err := c.conn.ReadFromUDP(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
return
}
if !addr.IP.Equal(c.addr.IP) {
if c.verbose {
fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String())
}
continue
}
if addr.Port != c.addr.Port {
c.addr.Port = addr.Port
}
// CC51 Protocol
if c.isCC51 && n >= 12 && string(buf[:2]) == magicCC51 {
cmd := binary.LittleEndian.Uint16(buf[4:])
switch cmd {
case cmdKeepaliveCC51:
if n >= keepaliveSizeCC51 {
_ = c.Write(c.msgKeepaliveCC51())
}
case cmdDTLSCC51:
if n >= headerSizeCC51+authSizeCC51 {
ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8)
dtlsData := buf[headerSizeCC51 : n-authSizeCC51]
switch ch {
case iotcChannelMain:
c.queue(c.clientBuf, dtlsData)
case iotcChannelBack:
c.queue(c.serverBuf, dtlsData)
}
}
}
continue
}
// IOTC Protocol (Basis)
data := tutk.ReverseTransCodeBlob(buf[:n])
if len(data) < 16 {
continue
}
switch binary.LittleEndian.Uint16(data[8:]) {
case cmdKeepaliveRes:
if len(data) > 24 {
_ = c.Write(c.msgKeepalive(data[16:]))
}
case cmdDataRX:
if len(data) > 28 {
ch := data[14]
switch ch {
case iotcChannelMain:
c.queue(c.clientBuf, data[28:])
case iotcChannelBack:
c.queue(c.serverBuf, data[28:])
}
}
}
}
}
func (c *DTLSConn) queue(ch chan []byte, data []byte) {
b := make([]byte, len(data))
copy(b, data)
select {
case ch <- b:
default:
select {
case <-ch:
default:
}
ch <- b
}
}
func (c *DTLSConn) msgDisco(stage byte) []byte {
b := make([]byte, discoSize)
copy(b, "\x04\x02\x1a\x02") // marker + mode
binary.LittleEndian.PutUint16(b[4:], discoBodySize) // body size
binary.LittleEndian.PutUint16(b[8:], cmdDiscoReq) // 0x0601
binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags
body := b[headerSize:]
copy(body[:20], c.uid)
copy(body[36:], sdkVersion42) // SDK 4.2.1.1
copy(body[40:], c.sid)
body[48] = stage
if stage == 1 && len(c.authKey) > 0 {
copy(body[58:], c.authKey)
}
return b
}
func (c *DTLSConn) msgDiscoCC51(seq, ticket uint16, isResponse bool) []byte {
b := make([]byte, packetSizeCC51)
copy(b[:2], magicCC51)
binary.LittleEndian.PutUint16(b[4:], cmdDiscoCC51) // 0x1002
binary.LittleEndian.PutUint16(b[6:], payloadSizeCC51) // 40 bytes
if isResponse {
binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response
}
binary.LittleEndian.PutUint16(b[12:], seq)
binary.LittleEndian.PutUint16(b[14:], ticket)
copy(b[16:24], c.sid)
copy(b[24:28], sdkVersion43) // SDK 4.3.8.0
b[28] = 0x1d // unknown field (capability/build flag?)
h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...))
h.Write(b[:32])
copy(b[32:52], h.Sum(nil))
return b
}
func (c *DTLSConn) msgKeepaliveCC51() []byte {
c.kaSeq += 2
b := make([]byte, keepaliveSizeCC51)
copy(b[:2], magicCC51)
binary.LittleEndian.PutUint16(b[4:], cmdKeepaliveCC51) // 0x1202
binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload
binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter
copy(b[20:28], c.sid) // session ID
h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...))
h.Write(b[:28])
copy(b[28:48], h.Sum(nil))
return b
}
func (c *DTLSConn) msgSession() []byte {
b := make([]byte, sessionSize)
copy(b, "\x04\x02\x1a\x02") // marker + mode
binary.LittleEndian.PutUint16(b[4:], sessionBody) // body size
binary.LittleEndian.PutUint16(b[8:], cmdSessionReq) // 0x0402
binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags
body := b[headerSize:]
copy(body[:20], c.uid)
copy(body[20:], c.sid)
binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix()))
return b
}
func (c *DTLSConn) msgAVLogin(magic uint16, size int, flags uint16, randomID []byte) []byte {
b := make([]byte, size)
binary.LittleEndian.PutUint16(b, magic)
binary.LittleEndian.PutUint16(b[2:], protoVersion)
binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size
binary.LittleEndian.PutUint16(b[18:], flags)
copy(b[20:], randomID[:4])
copy(b[24:], "admin") // username
copy(b[280:], c.enr) // password/ENR
binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ?
binary.LittleEndian.PutUint32(b[552:], defaultCaps) // capabilities
return b
}
func (c *DTLSConn) msgAVLoginResponse(checksum uint32) []byte {
b := make([]byte, 60)
binary.LittleEndian.PutUint16(b, 0x2100) // magic
binary.LittleEndian.PutUint16(b[2:], 0x000c) // version
b[4] = 0x10 // success
binary.LittleEndian.PutUint32(b[16:], 0x24) // payload size
binary.LittleEndian.PutUint32(b[20:], checksum) // echo checksum
b[29] = 0x01 // enable flag
b[31] = 0x01 // two-way streaming
binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config
binary.LittleEndian.PutUint32(b[40:], defaultCaps)
binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info
binary.LittleEndian.PutUint16(b[56:], 0x0002)
return b
}
func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, sampleRate uint32, channels uint8) []byte {
c.audioSeq++
c.audioFrameNo++
prevFrame := uint32(0)
if c.audioFrameNo > 1 {
prevFrame = c.audioFrameNo - 1
}
totalPayload := len(payload) + 16 // payload + frameinfo
b := make([]byte, 36+totalPayload)
// Outer header (36 bytes)
b[0] = tutk.ChannelAudio // 0x03
b[1] = tutk.FrameTypeStartAlt // 0x09
binary.LittleEndian.PutUint16(b[2:], protoVersion)
binary.LittleEndian.PutUint32(b[4:], c.audioSeq)
binary.LittleEndian.PutUint32(b[8:], timestampUS)
if c.audioFrameNo == 1 {
binary.LittleEndian.PutUint32(b[12:], 0x00000001)
} else {
binary.LittleEndian.PutUint32(b[12:], 0x00100001)
}
// Inner header
b[16] = tutk.ChannelAudio
b[17] = tutk.FrameTypeEndSingle
binary.LittleEndian.PutUint16(b[18:], uint16(prevFrame))
binary.LittleEndian.PutUint16(b[20:], 0x0001) // pkt_total
binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags
binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload))
binary.LittleEndian.PutUint32(b[28:], prevFrame)
binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo)
copy(b[36:], payload) // Payload + FrameInfo
fi := b[36+len(payload):]
fi[0] = codec // Codec ID (low byte)
fi[1] = 0 // Codec ID (high byte, unused)
// Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo
srIdx := tutk.GetSampleRateIndex(sampleRate)
fi[2] = (srIdx << 2) | 0x02 // 16-bit always set
if channels == 2 {
fi[2] |= 0x01
}
fi[4] = 1 // online
binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*tutk.GetSamplesPerFrame(codec)*1000/sampleRate)
return b
}
func (c *DTLSConn) msgTxData(payload []byte, channel byte) []byte {
bodySize := 12 + len(payload)
b := make([]byte, 16+bodySize)
copy(b, "\x04\x02\x1a\x0b") // marker + mode=data
binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size
binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence
c.seq++
binary.LittleEndian.PutUint16(b[8:], cmdDataTX) // 0x0407
binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags
copy(b[12:], c.sid[:2]) // rid[0:2]
b[14] = channel // channel
b[15] = 0x01 // marker
binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const
copy(b[20:], c.sid[:8]) // rid
copy(b[28:], payload)
return b
}
func (c *DTLSConn) msgTxDataCC51(payload []byte, channel byte) []byte {
payloadSize := uint16(16 + len(payload) + authSizeCC51)
b := make([]byte, headerSizeCC51+len(payload)+authSizeCC51)
copy(b[:2], magicCC51)
binary.LittleEndian.PutUint16(b[4:], cmdDTLSCC51) // 0x1502
binary.LittleEndian.PutUint16(b[6:], payloadSize)
binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte
binary.LittleEndian.PutUint16(b[14:], c.ticket)
copy(b[16:24], c.sid)
binary.LittleEndian.PutUint32(b[24:], 1) // const
copy(b[headerSizeCC51:], payload)
h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...))
h.Write(b[:headerSizeCC51])
copy(b[headerSizeCC51+len(payload):], h.Sum(nil))
return b
}
func (c *DTLSConn) msgACK() []byte {
c.ackFlags++
b := make([]byte, 24)
binary.LittleEndian.PutUint16(b[0:], magicACK) // 0x0009
binary.LittleEndian.PutUint16(b[2:], protoVersion) // 0x000c
binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq
c.avSeq++
binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked)
binary.LittleEndian.PutUint16(b[10:], c.rxSeqEnd) // RX end (highest received)
if c.rxSeqInit {
c.rxSeqStart = c.rxSeqEnd
}
binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // AckFlags
binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // AckCounter
ts := uint32(time.Now().UnixMilli() & 0xFFFF)
binary.LittleEndian.PutUint16(b[20:], uint16(ts)) // Timestamp
return b
}
func (c *DTLSConn) msgKeepalive(incoming []byte) []byte {
b := make([]byte, 24)
copy(b, "\x04\x02\x1a\x0a") // marker + mode
binary.LittleEndian.PutUint16(b[4:], 8) // body size
binary.LittleEndian.PutUint16(b[8:], cmdKeepaliveReq) // 0x0427
binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags
if len(incoming) >= 8 {
copy(b[16:], incoming[:8]) // echo payload
}
return b
}
func (c *DTLSConn) msgIOCtrl(payload []byte) []byte {
b := make([]byte, 40+len(payload))
binary.LittleEndian.PutUint16(b, protoVersion) // magic
binary.LittleEndian.PutUint16(b[2:], protoVersion) // version
binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq
c.avSeq++
binary.LittleEndian.PutUint16(b[16:], magicIOCtrl) // 0x7000
binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel
binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq
binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size
binary.LittleEndian.PutUint32(b[28:], uint32(c.seqCmd)) // flag
b[37] = 0x01
copy(b[40:], payload)
c.seqCmd++
return b
}
func hexDump(data []byte) string {
const maxBytes = 650
totalLen := len(data)
truncated := totalLen > maxBytes
if truncated {
data = data[:maxBytes]
}
var result string
for i := 0; i < len(data); i += 16 {
end := min(i+16, len(data))
line := fmt.Sprintf(" %04x:", i)
for j := i; j < end; j++ {
line += fmt.Sprintf(" %02x", data[j])
}
result += line + "\n"
}
if truncated {
result += fmt.Sprintf(" ... (truncated, showing %d of %d bytes)\n", maxBytes, totalLen)
}
return result
}
+146
View File
@@ -0,0 +1,146 @@
package dtls
import (
"context"
"net"
"sync"
"time"
"github.com/pion/dtls/v3"
)
func NewDTLSClient(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) {
return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, false)
}
func NewDTLSServer(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) {
return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, true)
}
func dialDTLS(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte, isServer bool) (*dtls.Conn, error) {
adapter := &channelAdapter{
ctx: ctx,
channel: channel,
addr: addr,
writeFn: writeFn,
readChan: readChan,
}
var conn *dtls.Conn
var err error
if isServer {
conn, err = dtls.Server(adapter, addr, buildDTLSConfig(psk, true))
} else {
conn, err = dtls.Client(adapter, addr, buildDTLSConfig(psk, false))
}
if err != nil {
return nil, err
}
timeout := 5 * time.Second
adapter.SetReadDeadline(time.Now().Add(timeout))
hsCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := conn.HandshakeContext(hsCtx); err != nil {
go conn.Close()
return nil, err
}
adapter.SetReadDeadline(time.Time{})
return conn, nil
}
func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config {
config := &dtls.Config{
PSK: func(hint []byte) ([]byte, error) {
return psk, nil
},
PSKIdentityHint: []byte("AUTHPWD_admin"),
InsecureSkipVerify: true,
InsecureSkipVerifyHello: true,
MTU: 1200,
FlightInterval: 300 * time.Millisecond,
ExtendedMasterSecret: dtls.DisableExtendedMasterSecret,
}
if isServer {
config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256}
} else {
config.CustomCipherSuites = CustomCipherSuites
}
return config
}
type channelAdapter struct {
ctx context.Context
channel uint8
writeFn func([]byte, uint8) error
readChan chan []byte
addr net.Addr
mu sync.Mutex
readDeadline time.Time
}
func (a *channelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
a.mu.Lock()
deadline := a.readDeadline
a.mu.Unlock()
if !deadline.IsZero() {
timeout := time.Until(deadline)
if timeout <= 0 {
return 0, nil, &timeoutError{}
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case data := <-a.readChan:
return copy(p, data), a.addr, nil
case <-timer.C:
return 0, nil, &timeoutError{}
case <-a.ctx.Done():
return 0, nil, net.ErrClosed
}
}
select {
case data := <-a.readChan:
return copy(p, data), a.addr, nil
case <-a.ctx.Done():
return 0, nil, net.ErrClosed
}
}
func (a *channelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) {
if err := a.writeFn(p, a.channel); err != nil {
return 0, err
}
return len(p), nil
}
func (a *channelAdapter) Close() error { return nil }
func (a *channelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} }
func (a *channelAdapter) SetDeadline(t time.Time) error {
a.mu.Lock()
a.readDeadline = t
a.mu.Unlock()
return nil
}
func (a *channelAdapter) SetReadDeadline(t time.Time) error {
a.mu.Lock()
a.readDeadline = t
a.mu.Unlock()
return nil
}
func (a *channelAdapter) SetWriteDeadline(time.Time) error { return nil }
type timeoutError struct{}
func (e *timeoutError) Error() string { return "i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }
+571
View File
@@ -0,0 +1,571 @@
package tutk
import (
"encoding/binary"
"encoding/hex"
"fmt"
"sync"
"github.com/AlexxIT/go2rtc/pkg/aac"
)
const (
FrameTypeStart uint8 = 0x08 // Extended start (36-byte header)
FrameTypeStartAlt uint8 = 0x09 // StartAlt (36-byte header)
FrameTypeCont uint8 = 0x00 // Continuation (28-byte header)
FrameTypeContAlt uint8 = 0x04 // Continuation alt
FrameTypeEndSingle uint8 = 0x01 // Single-packet frame (28-byte)
FrameTypeEndMulti uint8 = 0x05 // Multi-packet end (28-byte)
FrameTypeEndExt uint8 = 0x0d // Extended end (36-byte)
)
const (
ChannelIVideo uint8 = 0x05
ChannelAudio uint8 = 0x03
ChannelPVideo uint8 = 0x07
)
const frameInfoSize = 40
// FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet)
// Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero)
//
// Offset Size Field
// 0-1 2 CodecID - 0x4E=H264, 0x7B=H265, 0x90=AAC_WYZE
// 2 1 Flags - Video: 1=Keyframe, 0=P-frame | Audio: sample rate/bits/channels
// 3 1 CamIndex - Camera index
// 4 1 OnlineNum - Online number
// 5 1 FPS - Framerate (e.g. 20)
// 6 1 ResTier - Video: 1=Low(360P), 4=High(HD/2K) | Audio: 0
// 7 1 Bitrate - Video: 30=360P, 100=HD, 200=2K | Audio: 1
// 8-11 4 Timestamp - Timestamp (increases ~50000/frame for 20fps video)
// 12-15 4 SessionID - Session marker (constant per stream)
// 16-19 4 PayloadSize - Frame payload size in bytes
// 20-23 4 FrameNo - Global frame number
// 24-35 12 DeviceID - MAC address (ASCII) - video only
// 36-39 4 Padding - Always 0 - video only
type FrameInfo struct {
CodecID byte // 0 (only low byte used)
Flags uint8 // 2
CamIndex uint8 // 3
OnlineNum uint8 // 4
FPS uint8 // 5: Framerate
ResTier uint8 // 6: Resolution tier (1=Low, 4=High)
Bitrate uint8 // 7: Bitrate index (30=360P, 100=HD, 200=2K)
Timestamp uint32 // 8-11: Timestamp
SessionID uint32 // 12-15: Session marker (constant)
PayloadSize uint32 // 16-19: Payload size
FrameNo uint32 // 20-23: Frame number
}
func (fi *FrameInfo) IsKeyframe() bool {
return fi.Flags == 0x01
}
func (fi *FrameInfo) SampleRate() uint32 {
idx := (fi.Flags >> 2) & 0x0F
if idx < uint8(len(sampleRates)) {
return sampleRates[idx]
}
return 16000
}
func (fi *FrameInfo) Channels() uint8 {
if fi.Flags&0x01 == 1 {
return 2
}
return 1
}
func ParseFrameInfo(data []byte) *FrameInfo {
if len(data) < frameInfoSize {
return nil
}
offset := len(data) - frameInfoSize
fi := data[offset:]
return &FrameInfo{
CodecID: fi[0],
Flags: fi[2],
CamIndex: fi[3],
OnlineNum: fi[4],
FPS: fi[5],
ResTier: fi[6],
Bitrate: fi[7],
Timestamp: binary.LittleEndian.Uint32(fi[8:]),
SessionID: binary.LittleEndian.Uint32(fi[12:]),
PayloadSize: binary.LittleEndian.Uint32(fi[16:]),
FrameNo: binary.LittleEndian.Uint32(fi[20:]),
}
}
type Packet struct {
Channel uint8
Codec byte
Timestamp uint32
Payload []byte
IsKeyframe bool
FrameNo uint32
SampleRate uint32
Channels uint8
}
type PacketHeader struct {
Channel byte
FrameType byte
HeaderSize int
FrameNo uint32
PktIdx uint16
PktTotal uint16
PayloadSize uint16
HasFrameInfo bool
}
func ParsePacketHeader(data []byte) *PacketHeader {
if len(data) < 28 {
return nil
}
frameType := data[1]
hdr := &PacketHeader{
Channel: data[0],
FrameType: frameType,
}
switch frameType {
case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt:
hdr.HeaderSize = 36
default:
hdr.HeaderSize = 28
}
if len(data) < hdr.HeaderSize {
return nil
}
if hdr.HeaderSize == 28 {
hdr.PktTotal = binary.LittleEndian.Uint16(data[12:])
pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:])
hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:])
hdr.FrameNo = binary.LittleEndian.Uint32(data[24:])
if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) {
hdr.HasFrameInfo = true
if hdr.PktTotal > 0 {
hdr.PktIdx = hdr.PktTotal - 1
}
} else {
hdr.PktIdx = pktIdxOrMarker
}
} else {
hdr.PktTotal = binary.LittleEndian.Uint16(data[20:])
pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:])
hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:])
hdr.FrameNo = binary.LittleEndian.Uint32(data[32:])
if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) {
hdr.HasFrameInfo = true
if hdr.PktTotal > 0 {
hdr.PktIdx = hdr.PktTotal - 1
}
} else {
hdr.PktIdx = pktIdxOrMarker
}
}
return hdr
}
func IsStartFrame(frameType uint8) bool {
return frameType == FrameTypeStart || frameType == FrameTypeStartAlt
}
func IsEndFrame(frameType uint8) bool {
return frameType == FrameTypeEndSingle ||
frameType == FrameTypeEndMulti ||
frameType == FrameTypeEndExt
}
func IsContinuationFrame(frameType uint8) bool {
return frameType == FrameTypeCont || frameType == FrameTypeContAlt
}
type channelState struct {
frameNo uint32 // current frame being assembled
pktTotal uint16 // expected total packets
waitSeq uint16 // next expected packet index (0, 1, 2, ...)
waitData []byte // accumulated payload data
frameInfo *FrameInfo // frame info (from end packet)
hasStarted bool // received first packet of frame
lastPktIdx uint16 // last received packet index (for OOO detection)
}
func (cs *channelState) reset() {
cs.frameNo = 0
cs.pktTotal = 0
cs.waitSeq = 0
cs.waitData = cs.waitData[:0]
cs.frameInfo = nil
cs.hasStarted = false
cs.lastPktIdx = 0
}
const tsWrapPeriod uint32 = 1000000
type tsTracker struct {
lastRawTS uint32
accumUS uint64
firstTS bool
}
func (t *tsTracker) update(rawTS uint32) uint64 {
if !t.firstTS {
t.firstTS = true
t.lastRawTS = rawTS
return 0
}
var delta uint32
if rawTS >= t.lastRawTS {
delta = rawTS - t.lastRawTS
} else {
// Wrapped: delta = (wrap - last) + new
delta = (tsWrapPeriod - t.lastRawTS) + rawTS
}
t.accumUS += uint64(delta)
t.lastRawTS = rawTS
return t.accumUS
}
type FrameHandler struct {
channels map[byte]*channelState
videoTS tsTracker
audioTS tsTracker
output chan *Packet
verbose bool
closed bool
closeMu sync.Mutex
}
func NewFrameHandler(verbose bool) *FrameHandler {
return &FrameHandler{
channels: make(map[byte]*channelState),
output: make(chan *Packet, 128),
verbose: verbose,
}
}
func (h *FrameHandler) Recv() <-chan *Packet {
return h.output
}
func (h *FrameHandler) Close() {
h.closeMu.Lock()
defer h.closeMu.Unlock()
if h.closed {
return
}
h.closed = true
close(h.output)
}
func (h *FrameHandler) Handle(data []byte) {
hdr := ParsePacketHeader(data)
if hdr == nil {
return
}
payload, fi := h.extractPayload(data, hdr.Channel)
if payload == nil {
return
}
if h.verbose {
fiStr := ""
if hdr.HasFrameInfo {
fiStr = " +FI"
}
fmt.Printf("[RX] ch=0x%02x type=0x%02x #%d pkt=%d/%d data=%dB%s\n",
hdr.Channel, hdr.FrameType,
hdr.FrameNo, hdr.PktIdx, hdr.PktTotal, len(payload), fiStr)
}
switch hdr.Channel {
case ChannelAudio:
h.handleAudio(payload, fi)
case ChannelIVideo, ChannelPVideo:
h.handleVideo(hdr.Channel, hdr, payload, fi)
}
}
func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) {
if len(data) < 2 {
return nil, nil
}
frameType := data[1]
headerSize := 28
fiSize := 0
switch frameType {
case FrameTypeStart:
headerSize = 36
case FrameTypeStartAlt:
headerSize = 36
if len(data) >= 22 {
pktTotal := binary.LittleEndian.Uint16(data[20:])
if pktTotal == 1 {
fiSize = frameInfoSize
}
}
case FrameTypeCont, FrameTypeContAlt:
headerSize = 28
case FrameTypeEndSingle, FrameTypeEndMulti:
headerSize = 28
fiSize = frameInfoSize
case FrameTypeEndExt:
headerSize = 36
fiSize = frameInfoSize
default:
headerSize = 28
}
if len(data) < headerSize {
return nil, nil
}
if fiSize == 0 {
return data[headerSize:], nil
}
if len(data) < headerSize+fiSize {
return data[headerSize:], nil
}
fi := ParseFrameInfo(data)
validCodec := false
switch channel {
case ChannelIVideo, ChannelPVideo:
validCodec = IsVideoCodec(fi.CodecID)
case ChannelAudio:
validCodec = IsAudioCodec(fi.CodecID)
}
if validCodec {
payload := data[headerSize : len(data)-fiSize]
return payload, fi
}
return data[headerSize:], nil
}
func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) {
cs := h.channels[channel]
if cs == nil {
cs = &channelState{}
h.channels[channel] = cs
}
// New frame number - reset and start fresh
if hdr.FrameNo != cs.frameNo {
// Check if previous frame was incomplete
if cs.hasStarted && cs.waitSeq < cs.pktTotal {
fmt.Printf("[DROP] ch=0x%02x #%d INCOMPLETE: got %d/%d pkts\n",
channel, cs.frameNo, cs.waitSeq, cs.pktTotal)
}
cs.reset()
cs.frameNo = hdr.FrameNo
cs.pktTotal = hdr.PktTotal
}
// If packet index doesn't match expected, reset (data loss)
if hdr.PktIdx != cs.waitSeq {
fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n",
channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx)
cs.reset()
return
}
// First packet - mark as started
if cs.waitSeq == 0 {
cs.hasStarted = true
}
cs.waitData = append(cs.waitData, payload...)
cs.waitSeq++
// Store frame info if present
if fi != nil {
cs.frameInfo = fi
}
// Check if frame is complete
if cs.waitSeq != cs.pktTotal || cs.frameInfo == nil {
return
}
fi = cs.frameInfo
defer cs.reset()
if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize {
fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n",
channel, cs.frameNo, fi.PayloadSize, len(cs.waitData))
return
}
if len(cs.waitData) == 0 {
return
}
accumUS := h.videoTS.update(fi.Timestamp)
rtpTS := uint32(accumUS * 90000 / 1000000)
pkt := &Packet{
Channel: channel,
Payload: append([]byte{}, cs.waitData...),
Codec: fi.CodecID,
Timestamp: rtpTS,
IsKeyframe: fi.IsKeyframe(),
FrameNo: fi.FrameNo,
}
if h.verbose {
frameType := "P"
if fi.IsKeyframe() {
frameType = "KEY"
}
fmt.Printf("[OK] ch=0x%02x #%d codec=0x%02x %s size=%d\n",
channel, fi.FrameNo, fi.CodecID, frameType, len(pkt.Payload))
fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x [3]=%d [4]=%d\n",
fi.CodecID, fi.Flags, fi.CamIndex, fi.OnlineNum)
fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n",
fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp)
fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n",
fi.SessionID, fi.PayloadSize, fi.FrameNo)
fmt.Printf(" rtp_ts=%d accum_us=%d\n", rtpTS, accumUS)
fmt.Printf(" hex: %s\n", dumpHex(fi))
}
h.queue(pkt)
}
func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) {
if len(payload) == 0 || fi == nil {
return
}
var sampleRate uint32
var channels uint8
switch fi.CodecID {
case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt:
sampleRate, channels = parseAudioParams(payload, fi)
default:
sampleRate = fi.SampleRate()
channels = fi.Channels()
}
accumUS := h.audioTS.update(fi.Timestamp)
rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000)
payloadCopy := make([]byte, len(payload))
copy(payloadCopy, payload)
pkt := &Packet{
Channel: ChannelAudio,
Payload: payloadCopy,
Codec: fi.CodecID,
Timestamp: rtpTS,
SampleRate: sampleRate,
Channels: channels,
FrameNo: fi.FrameNo,
}
if h.verbose {
bits := 8
if fi.Flags&0x02 != 0 {
bits = 16
}
fmt.Printf("[OK] Audio #%d codec=0x%02x size=%d\n",
fi.FrameNo, fi.CodecID, len(payload))
fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x(%dHz/%dbit/%dch)\n",
fi.CodecID, fi.Flags, sampleRate, bits, channels)
fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n",
fi.Timestamp, fi.SessionID, rtpTS)
fmt.Printf(" hex: %s\n", dumpHex(fi))
}
h.queue(pkt)
}
func (h *FrameHandler) queue(pkt *Packet) {
h.closeMu.Lock()
defer h.closeMu.Unlock()
if h.closed {
return
}
select {
case h.output <- pkt:
default:
// Queue full - drop oldest
select {
case <-h.output:
default:
}
select {
case h.output <- pkt:
default:
// Queue still full, drop this packet
}
}
}
func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) {
if aac.IsADTS(payload) {
codec := aac.ADTSToCodec(payload)
if codec != nil {
return codec.ClockRate, codec.Channels
}
}
if fi != nil {
return fi.SampleRate(), fi.Channels()
}
return 16000, 1
}
func dumpHex(fi *FrameInfo) string {
b := make([]byte, frameInfoSize)
b[0] = fi.CodecID
b[1] = 0 // High byte (unused)
b[2] = fi.Flags
b[3] = fi.CamIndex
b[4] = fi.OnlineNum
b[5] = fi.FPS
b[6] = fi.ResTier
b[7] = fi.Bitrate
binary.LittleEndian.PutUint32(b[8:], fi.Timestamp)
binary.LittleEndian.PutUint32(b[12:], fi.SessionID)
binary.LittleEndian.PutUint32(b[16:], fi.PayloadSize)
binary.LittleEndian.PutUint32(b[20:], fi.FrameNo)
// Bytes 24-39 are DeviceID and Padding (not stored in struct)
hexStr := hex.EncodeToString(b)
formatted := ""
for i := 0; i < len(hexStr); i += 2 {
if i > 0 {
formatted += " "
}
formatted += hexStr[i : i+2]
}
return formatted
}
+71
View File
@@ -0,0 +1,71 @@
package tutk
import (
"encoding/binary"
"time"
)
func GenSessionID() []byte {
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano()))
return b
}
func ICAM(cmd uint32, args ...byte) []byte {
// 0 4943414d ICAM
// 4 d807ff00 command
// 8 00000000000000
// 15 02 args count
// 16 00000000000000
// 23 0101 args
n := byte(len(args))
b := make([]byte, 23+n)
copy(b, "ICAM")
binary.LittleEndian.PutUint32(b[4:], cmd)
b[15] = n
copy(b[23:], args)
return b
}
func HL(cmdID uint16, payload []byte) []byte {
// 0-1 "HL" magic
// 2 version (typically 5)
// 3 reserved
// 4-5 cmdID command ID (uint16 LE)
// 6-7 payloadLen payload length (uint16 LE)
// 8-15 reserved
// 16+ payload
const headerSize = 16
const version = 5
b := make([]byte, headerSize+len(payload))
copy(b, "HL")
b[2] = version
binary.LittleEndian.PutUint16(b[4:], cmdID)
binary.LittleEndian.PutUint16(b[6:], uint16(len(payload)))
copy(b[headerSize:], payload)
return b
}
func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) {
if len(data) < 16 || data[0] != 'H' || data[1] != 'L' {
return 0, nil, false
}
cmdID = binary.LittleEndian.Uint16(data[4:])
payloadLen := binary.LittleEndian.Uint16(data[6:])
if len(data) >= 16+int(payloadLen) {
payload = data[16 : 16+payloadLen]
} else if len(data) > 16 {
payload = data[16:]
}
return cmdID, payload, true
}
func FindHL(data []byte, offset int) []byte {
for i := offset; i+16 <= len(data); i++ {
if data[i] == 'H' && data[i+1] == 'L' {
return data[i:]
}
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package tutk
import (
"bytes"
"encoding/binary"
"net"
"time"
)
func (c *Conn) connectDirect(uid string, sid []byte) error {
res, err := writeAndWait(
c, func(res []byte) bool { return bytes.Index(res, []byte("\x02\x06\x12\x00")) == 8 },
ConnectByUID(stageBroadcast, uid, sid),
)
if err != nil {
return err
}
n := len(res) // should be 200
c.ver = []byte{res[2], res[n-13], res[n-14], res[n-15], res[n-16]}
_, err = c.Write(ConnectByUID(stageDirect, uid, sid))
return err
}
func (c *Conn) connectRemote(uid string, sid []byte) error {
res, err := writeAndWait(
c, func(res []byte) bool { return bytes.Index(res, []byte("\x01\x03\x43")) == 8 },
ConnectByUID(stageGetRemoteIP, uid, sid),
)
if err != nil {
return err
}
// Read real IP from cloud server response.
// Important ot use net.IPv4 because slice will be 16 bytes.
c.addr.IP = net.IPv4(res[40], res[41], res[42], res[43])
c.addr.Port = int(binary.BigEndian.Uint16(res[38:]))
res, err = writeAndWait(
c, func(res []byte) bool { return bytes.Index(res, []byte("\x04\x04\x33")) == 8 },
ConnectByUID(stageRemoteAck, uid, sid),
)
if err != nil {
return err
}
if len(res) == 52 {
c.ver = []byte{res[2], res[51], res[50], res[49], res[48]}
} else {
c.ver = []byte{res[2]}
}
_, err = c.Write(ConnectByUID(stageRemoteOK, uid, sid))
return err
}
func (c *Conn) clientStart(username, password string) error {
_, err := writeAndWait(
c, func(res []byte) bool {
return len(res) >= 84 && res[28] == 0 && (res[29] == 0x14 || res[29] == 0x21)
},
c.session.ClientStart(0, username, password),
c.session.ClientStart(1, username, password),
)
return err
}
func writeAndWait(conn net.Conn, ok func(res []byte) bool, req ...[]byte) ([]byte, error) {
var t *time.Timer
t = time.AfterFunc(1, func() {
for _, b := range req {
if _, err := conn.Write(b); err != nil {
return
}
}
if t != nil {
t.Reset(time.Second)
}
})
defer t.Stop()
buf := make([]byte, 1200)
for {
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
if ok(buf[:n]) {
return buf[:n], nil
}
}
}
const (
magic = "\x04\x02\x19" // include version 0x19
sdkVersion = "\x06\x00\x03\x03" // 3.3.0.6
)
const (
stageBroadcast = iota + 1
stageDirect
stageGetPublicIP
stageGetRemoteIP
stageRemoteReq
stageRemoteAck
stageRemoteOK
)
func ConnectByUID(stage byte, uid string, sid8 []byte) []byte {
var b []byte
switch stage {
case stageBroadcast, stageDirect:
b = make([]byte, 68)
copy(b[8:], "\x01\x06\x21")
copy(b[52:], sdkVersion)
copy(b[56:], sid8)
b[64] = stage // 1 or 2
case stageGetPublicIP:
b = make([]byte, 54)
copy(b[8:], "\x07\x10\x18")
case stageGetRemoteIP:
b = make([]byte, 112)
copy(b[8:], "\x03\x02\x34")
copy(b[100:], sid8)
b[108] = stageDirect
case stageRemoteReq:
b = make([]byte, 52)
copy(b[8:], "\x01\x04\x33")
copy(b[36:], sid8)
copy(b[48:], sdkVersion)
case stageRemoteAck:
b = make([]byte, 44)
copy(b[8:], "\x02\x04\x33")
copy(b[36:], sid8)
case stageRemoteOK:
b = make([]byte, 52)
copy(b[8:], "\x04\x04\x33")
copy(b[36:], sid8)
copy(b[48:], sdkVersion)
}
copy(b, magic)
b[3] = 0x02 // connection stage
binary.LittleEndian.PutUint16(b[4:], uint16(len(b))-16)
copy(b[16:], uid)
return b
}
+378
View File
@@ -0,0 +1,378 @@
package tutk
import (
"bytes"
"encoding/binary"
"io"
"net"
"time"
)
type Session interface {
Close() error
ClientStart(i byte, username, password string) []byte
SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte
SendFrameData(frameInfo, frameData []byte) []byte
RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error)
RecvFrameData() (frameInfo, frameData []byte, err error)
SessionRead(chID byte, buf []byte) int
SessionWrite(chID byte, buf []byte) error
}
func NewSession16(conn net.Conn, sid8 []byte) *Session16 {
sid16 := make([]byte, 16)
copy(sid16[8:], sid8)
copy(sid16, sid8[:2])
sid16[4] = 0x0c
return &Session16{
conn: conn,
sid16: sid16,
rawCmd: make(chan []byte, 10),
rawPkt: make(chan [2][]byte, 100),
}
}
type Session16 struct {
conn net.Conn
sid16 []byte
rawCmd chan []byte
rawPkt chan [2][]byte
seqSendCh0 uint16
seqSendCh1 uint16
seqSendCmd1 uint16
seqSendAud uint16
waitSeq uint16
waitSize int
waitData []byte
}
func (s *Session16) Close() error {
close(s.rawCmd)
close(s.rawPkt)
return nil
}
func (s *Session16) Msg(size uint16) []byte {
b := make([]byte, size)
copy(b, magic)
b[3] = 0x0a // connected stage
binary.LittleEndian.PutUint16(b[4:], size-16)
copy(b[8:], "\x07\x04\x21") // client request
copy(b[12:], s.sid16)
return b
}
const (
msgHhrSize = 28
cmdHdrSize = 24
)
func (s *Session16) ClientStart(i byte, username, password string) []byte {
const size = 566 + 32
msg := s.Msg(size)
// 0 00000b0000000000000000000000000022020000fcfc7284
// 24 4d69737300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
// 281 636c69656e740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
// 538 0100000004000000fb071f000000000000000000000003000000000001000000
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x00\x0b\x00")
binary.LittleEndian.PutUint16(cmd[16:], size-52)
if i == 0 {
cmd[18] = 1
} else {
cmd[1] = 0x20
}
binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli()))
// important values for some cameras (not for df3)
data := cmd[cmdHdrSize:]
copy(data, username)
copy(data[257:], password)
// 0100000004000000fb071f000000000000000000000003000000000001000000
cfg := data[257+257:]
//cfg[0] = 1 // 0 - simple proto, 1 - complex proto with "0Cxx" commands
cfg[4] = 4
copy(cfg[8:], "\xfb\x07\x1f\x00")
cfg[22] = 3
//cfg[28] = 1 // unknown
return msg
}
func (s *Session16) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte {
dataSize := 4 + uint16(len(ctrlData))
msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize)
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x70\x0b\x00")
s.seqSendCmd1++ // start from 1, important!
binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1)
binary.LittleEndian.PutUint16(cmd[16:], dataSize)
binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli()))
data := cmd[cmdHdrSize:]
binary.LittleEndian.PutUint32(data, ctrlType)
copy(data[4:], ctrlData)
return msg
}
func (s *Session16) SendFrameData(frameInfo, frameData []byte) []byte {
// -> 01030b001d0000008802000000002800b0020bf501000000 ... 4f4455412000000088020000030400001d000000000000000bf51f7a9b0100000000000000000000
n := uint16(len(frameData))
dataSize := n + 8 + 32
msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize)
// 0 01030b00 command + version
// 4 1d000000 seq
// 8 8802 media size (648)
// 10 00000000
// 14 2800 tail (pkt header) size?
// 16 b002 size (648 + 8 + 32)
// 18 0bf5 random msg id (unixms)
// 20 01000000 fixed
cmd := msg[msgHhrSize:]
copy(cmd, "\x01\x03\x0b\x00")
binary.LittleEndian.PutUint16(cmd[4:], s.seqSendAud)
s.seqSendAud++
binary.LittleEndian.PutUint16(cmd[8:], n)
cmd[14] = 0x28 // important!
binary.LittleEndian.PutUint16(cmd[16:], dataSize)
binary.LittleEndian.PutUint16(cmd[18:], uint16(time.Now().UnixMilli()))
cmd[20] = 1
data := cmd[cmdHdrSize:]
copy(data, frameData)
copy(data[n:], "ODUA\x20\x00\x00\x00")
copy(data[n+8:], frameInfo)
return msg
}
func (s *Session16) RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) {
buf, ok := <-s.rawCmd
if !ok {
return 0, nil, io.EOF
}
return binary.LittleEndian.Uint32(buf), buf[4:], nil
}
func (s *Session16) RecvFrameData() (frameInfo, frameData []byte, err error) {
buf, ok := <-s.rawPkt
if !ok {
return nil, nil, io.EOF
}
return buf[0], buf[1], nil
}
func (s *Session16) SessionRead(chID byte, cmd []byte) int {
if chID != 0 {
return s.handleCh1(cmd)
}
// 0 01030800 command + version
// 4 00000000 frame num
// 8 ac880100 total size
// 12 6200 chunk seq
// 14 2000 tail (pkt header) size
// 16 cc00 size
// 18 0000
// 20 01000000 fixed
switch cmd[0] {
case 0x01:
var packetData [2][]byte
switch cmd[1] {
case 0x03:
seq := binary.LittleEndian.Uint16(cmd[12:])
if seq != s.waitSeq {
s.waitSeq = 0
return msgMediaLost
}
if seq == 0 {
s.waitData = s.waitData[:0]
payloadSize := binary.LittleEndian.Uint32(cmd[8:])
hdrSize := binary.LittleEndian.Uint16(cmd[14:])
s.waitSize = int(hdrSize) + int(payloadSize)
}
s.waitData = append(s.waitData, cmd[24:]...)
if n := len(s.waitData); n < s.waitSize {
s.waitSeq++
return msgMediaChunk
}
s.waitSeq = 0
payloadSize := binary.LittleEndian.Uint32(cmd[8:])
packetData[0] = bytes.Clone(s.waitData[payloadSize:])
packetData[1] = bytes.Clone(s.waitData[:payloadSize])
case 0x04:
data := cmd[24:]
hdrSize := binary.LittleEndian.Uint16(cmd[14:])
packetData[0] = bytes.Clone(data[:hdrSize])
packetData[1] = bytes.Clone(data[hdrSize:])
default:
return msgUnknown
}
select {
case s.rawPkt <- packetData:
default:
return msgError
}
return msgMediaFrame
case 0x00:
switch cmd[1] {
case 0x70:
_ = s.SessionWrite(0, s.msgAck0070(cmd))
select {
case s.rawCmd <- append([]byte{}, cmd[24:]...):
default:
}
return msgCommand
case 0x12:
_ = s.SessionWrite(0, s.msgAck0012(cmd))
return msgDafang0012
case 0x71:
return msgCommandAck
}
}
return msgUnknown
}
func (s *Session16) msgAck0070(msg28 []byte) []byte {
// <- 00700800010000000000000000000000340000007625a02f ...
// -> 00710800010000000000000000000000000000007625a02f
msg := s.Msg(msgHhrSize + cmdHdrSize)
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x71")
copy(cmd[2:], msg28[2:6]) // same version and seq
copy(cmd[20:], msg28[20:24]) // same msg random
return msg
}
func (s *Session16) msgAck0012(msg28 []byte) []byte {
// <- 001208000000000000000000000000000c00000000000000 020000000100000001000000
// -> 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000
const dataSize = 20
msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize)
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x13\x0b\x00")
cmd[16] = dataSize
data := cmd[cmdHdrSize:]
copy(data, msg28[cmdHdrSize:])
return msg
}
func (s *Session16) handleCh1(cmd []byte) int {
// Channel 1 used for two-way audio. It's important:
// - answer on 0000 command with exact config response (can't set simple proto)
// - send 0012 command at start
// - respond on every 0008 command for smooth playback
switch cid := string(cmd[:2]); cid {
case "\x00\x00": // client start
_ = s.SessionWrite(1, s.msgAck0000(cmd))
_ = s.SessionWrite(1, s.msg0012())
return msgClientStart
case "\x00\x07": // time sync without data
_ = s.SessionWrite(1, s.msgAck0007(cmd))
return msgUnknown0007
case "\x00\x08": // time sync with data
_ = s.SessionWrite(1, s.msgAck0008(cmd))
return msgUnknown0008
case "\x00\x13": // ack for 0012
return msgUnknown0013
}
return msgUnknown
}
func (s *Session16) msgAck0000(msg28 []byte) []byte {
// <- 000008000000000000000000000000001a0200004f47c714 ... 00000000000000000100000004000000fb071f00000000000000000000000300
// -> 00140b00000000000000000000000000200000004f47c714 00000000000000000100000004000000fb071f00000000000000000000000300
const cmdDataSize = 32
msg := s.Msg(msgHhrSize + cmdHdrSize + cmdDataSize)
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x14\x0b\x00")
cmd[16] = cmdDataSize
copy(cmd[20:], msg28[20:24]) // request id (random)
// Important to answer with same data.
data := cmd[cmdHdrSize:]
copy(data, msg28[len(msg28)-32:])
return msg
}
func (s *Session16) msg0012() []byte {
// -> 00120b000000000000000000000000000c00000000000000020000000100000001000000
const dataSize = 12
msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize)
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x12\x0b\x00")
cmd[16] = dataSize
data := cmd[cmdHdrSize:]
data[0] = 2
data[4] = 1
data[9] = 1
return msg
}
func (s *Session16) msgAck0007(msg28 []byte) []byte {
// <- 000708000000000000000000000000000c00000001000000000000001c551f7a00000000
// -> 010a0b00000000000000000000000000000000000100000000000000
msg := s.Msg(msgHhrSize + 28)
cmd := msg[msgHhrSize:]
copy(cmd, "\x01\x0a\x0b\x00")
cmd[20] = 1
return msg
}
func (s *Session16) msgAck0008(msg28 []byte) []byte {
// <- 000808000000000000000000000000000000f9f0010000000200000050f31f7a
// -> 01090b0000000000000000000000000000000000010000000200000050f31f7a
msg := s.Msg(msgHhrSize + 28)
cmd := msg[msgHhrSize:]
copy(cmd, "\x01\x09\x0b\x00")
copy(cmd[20:], msg28[20:])
return msg
}
func (s *Session16) SessionWrite(chID byte, buf []byte) error {
switch chID {
case 0:
binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh0)
s.seqSendCh0++
case 1:
binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh1)
s.seqSendCh1++
buf[14] = 1 // channel
}
_, err := s.conn.Write(buf)
return err
}
+337
View File
@@ -0,0 +1,337 @@
package tutk
import (
"bytes"
"encoding/binary"
"net"
"time"
)
func NewSession25(conn net.Conn, sid []byte) *Session25 {
return &Session25{
Session16: NewSession16(conn, sid),
rb: NewReorderBuffer(5),
}
}
type Session25 struct {
*Session16
rb *ReorderBuffer
seqSendCmd2 uint16
seqSendCnt uint16
seqRecvPkt0 uint16
seqRecvPkt1 uint16
seqRecvCmd2 uint16
}
const cmdHdrSize25 = 28
func (s *Session25) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte {
size := msgHhrSize + cmdHdrSize25 + 4 + uint16(len(ctrlData))
msg := s.Msg(size)
// 0 0070 command
// 2 0b00 version
// 4 1000 seq
// 6 0076 ???
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x70\x0b\x00")
binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1)
s.seqSendCmd1++
// 8 0070 command (second time)
// 10 0300 seq
// 12 0100 chunks count
// 14 0000 chunk seq (starts from 0)
// 16 5500 size
// 18 0000 random msg id (always 0)
// 20 03000000 seq (second time)
// 24 00000000
// 28 01010000 ctrlType
cmd[9] = 0x70
cmd[12] = 1
binary.LittleEndian.PutUint16(cmd[16:], size-52)
binary.LittleEndian.PutUint16(cmd[10:], s.seqSendCmd2)
binary.LittleEndian.PutUint16(cmd[20:], s.seqSendCmd2)
s.seqSendCmd2++
data := cmd[28:]
binary.LittleEndian.PutUint32(data, ctrlType)
copy(data[4:], ctrlData)
return msg
}
func (s *Session25) SendFrameData(frameInfo, frameData []byte) []byte {
return nil
}
func (s *Session25) SessionRead(chID byte, cmd []byte) (res int) {
if chID != 0 {
return s.handleCh1(cmd)
}
switch cmd[0] {
case 0x03, 0x05, 0x07:
for i := 0; cmd != nil; i++ {
res = s.handleChunk(cmd, i == 0)
cmd = s.rb.Pop()
}
return
case 0x00:
_ = s.SessionWrite(0, s.msgAckCounters())
s.seqRecvCmd2 = binary.LittleEndian.Uint16(cmd[2:])
switch cmd[1] {
case 0x10:
return msgUnknown0010 // unknown
case 0x21:
return msgClientStartAck2
case 0x70:
select {
case s.rawCmd <- cmd[28:]:
default:
}
return msgCommand // cmd from camera
case 0x71:
return msgCommandAck
}
case 0x09:
// off sample
// 0 09000b00 cmd1
// 4 0d000000 seqCmd1
// 12 0000 seqRecvCmd2
seq := binary.LittleEndian.Uint16(cmd[12:])
if s.seqSendCmd1 > seq {
return msgCommandAck
}
return msgCounters
case 0x0a:
// seq sample
// 0 0a080b00
// 4 03000000
// 8 e2043200
// 12 01000000
_ = s.SessionWrite(0, s.msgAck0A08(cmd))
return msgUnknown0a08
}
return msgUnknown
}
func (s *Session25) handleChunk(cmd []byte, checkSeq bool) int {
var cmd2 []byte
flags := cmd[1]
if flags&0b1000 == 0 {
// off sample
// 0 0700 command
// 2 0b00 version
// 4 2700 seq
// 6 0000 ???
// 8 0700 command (second time)
// 10 1400 seq
// 12 1300 chunks count per this frame
// 14 1100 chunk seq, starts from 0 (0x20 for last chunk)
// 16 0004 frame data size
// 18 0000 random msg id (always 0)
// 20 02000000 previous frame seq, starts from 0
// 24 03000000 current frame seq, starts from 1
cmd2 = cmd[8:]
} else {
// off sample
// 0 070d0b00
// 4 30000000
// 8 5c965500 ???
// 12 ffff0000 ???
// 16 0701 fixed command
// 18 190001002000a802000006000000070000000
cmd2 = cmd[16:]
}
seq := binary.LittleEndian.Uint16(cmd2[2:])
if checkSeq {
if s.rb.Check(seq) {
s.rb.Next()
} else {
s.rb.Push(seq, cmd)
return msgMediaReorder
}
}
// Check if this is first chunk for frame.
// Handle protocol bug "0x20 chunk seq for last chunk" and sometimes
// "0x20 chunk seq for first chunk if only one chunk".
if binary.LittleEndian.Uint16(cmd2[6:]) == 0 || binary.LittleEndian.Uint16(cmd2[4:]) == 1 {
s.waitData = s.waitData[:0]
s.waitSeq = seq
} else if seq != s.waitSeq {
return msgMediaLost
}
s.waitData = append(s.waitData, cmd2[20:]...)
if flags&0b0001 == 0 {
s.waitSeq++
return msgMediaChunk
}
s.seqRecvPkt1 = seq
_ = s.SessionWrite(0, s.msgAckCounters())
n := len(s.waitData) - 32
packetData := [2][]byte{bytes.Clone(s.waitData[n:]), bytes.Clone(s.waitData[:n])}
select {
case s.rawPkt <- packetData:
default:
return msgError
}
return msgMediaFrame
}
func (s *Session25) msgAckCounters() []byte {
msg := s.Msg(msgHhrSize + cmdHdrSize)
// off sample
// 0 09000b00 cmd1
// 4 2700 seqCmd1
// 6 0000
// 8 1300 seqRecvPkt0
// 10 2600 seqRecvPkt1
// 12 0400 seqRecvCmd2
// 14 00000000
// 18 1400 seqSendCnt
// 20 d91a random
// 22 0000
cmd := msg[msgHhrSize:]
copy(cmd, "\x09\x00\x0b\x00")
binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1)
s.seqSendCmd1++
// seqRecvPkt0 stores previous value of seqRecvPkt1
// don't understand why this needs
binary.LittleEndian.PutUint16(cmd[8:], s.seqRecvPkt0)
s.seqRecvPkt0 = s.seqRecvPkt1
binary.LittleEndian.PutUint16(cmd[10:], s.seqRecvPkt1)
binary.LittleEndian.PutUint16(cmd[12:], s.seqRecvCmd2)
binary.LittleEndian.PutUint16(cmd[18:], s.seqSendCnt)
s.seqSendCnt++
binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixMilli()))
return msg
}
func (s *Session25) handleCh1(cmd []byte) int {
switch cid := string(cmd[:2]); cid {
case "\x00\x00": // client start
return msgClientStart
case "\x00\x07": // time sync without data
_ = s.SessionWrite(1, s.msgAck0007(cmd))
return msgUnknown0007
case "\x00\x20": // client start2
_ = s.SessionWrite(1, s.msgAck0020(cmd))
return msgClientStart2
case "\x09\x00":
return msgUnknown0900
case "\x0a\x08":
return msgUnknown0a08
}
return msgUnknown
}
func (s *Session25) msgAck0020(msg28 []byte) []byte {
const cmdDataSize = 36
msg := s.Msg(msgHhrSize + cmdHdrSize25 + cmdDataSize)
cmd := msg[msgHhrSize:]
copy(cmd, "\x00\x21\x0b\x00")
cmd[16] = cmdDataSize
copy(cmd[20:], msg28[20:24]) // request id (random)
// 0 00000000
// 4 00010001
// 8 01000000
// 12 04000000
// 16 fb071f00
// 20 00000000
// 24 00000000
// 28 00000300
// 32 01000000
data := cmd[cmdHdrSize25:]
data[5] = 1
data[7] = 1
data[8] = 1
data[12] = 4
copy(data[16:], "\xfb\x07\x1f\x00")
data[30] = 3
data[32] = 1
return msg
}
func (s *Session25) msgAck0A08(msg28 []byte) []byte {
// <- 0a080b005b0000000b51590002000000
// -> 0b000b00000001000b5103000300000000000000
msg := s.Msg(msgHhrSize + 20)
cmd := msg[msgHhrSize:]
copy(cmd, "\x0b\x00\x0b\x00")
copy(cmd[8:], msg28[8:10])
return msg
}
// ReorderBuffer used for UDP incoming data. Because the order of the packets may be mixed up.
type ReorderBuffer struct {
buf map[uint16][]byte
seq uint16
size int
}
func NewReorderBuffer(size int) *ReorderBuffer {
return &ReorderBuffer{buf: make(map[uint16][]byte), size: size}
}
// Check return OK if this is the seq we are waiting for.
func (r *ReorderBuffer) Check(seq uint16) (ok bool) {
return seq == r.seq
}
func (r *ReorderBuffer) Next() {
r.seq++
}
// Available return how much free slots is in the buffer.
func (r *ReorderBuffer) Available() int {
return r.size - len(r.buf)
}
// Push new item to buffer. Important! There is no buffer full check here.
func (r *ReorderBuffer) Push(seq uint16, data []byte) {
//log.Printf("push seq=%d wait=%d", seq, r.seq)
r.buf[seq] = bytes.Clone(data)
}
// Pop latest item from buffer. OK - if items wasn't dropped.
func (r *ReorderBuffer) Pop() []byte {
for {
if data := r.buf[r.seq]; data != nil {
delete(r.buf, r.seq)
r.Next()
//log.Printf("pop seq=%d", r.seq)
return data
}
if r.Available() > 0 {
return nil
}
//log.Printf("drop seq=%d", r.seq)
r.Next() // drop item
}
}
+123 -38
View File
@@ -1,7 +1,9 @@
package webrtc
import (
"fmt"
"net"
"slices"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/xnet"
@@ -27,6 +29,69 @@ type Filters struct {
UDPPorts []uint16 `yaml:"udp_ports"`
}
func (f *Filters) Network(protocol string) string {
if f == nil || f.Networks == nil {
return protocol
}
v4 := slices.Contains(f.Networks, protocol+"4")
v6 := slices.Contains(f.Networks, protocol+"6")
if v4 && v6 {
return protocol
} else if v4 {
return protocol + "4"
} else if v6 {
return protocol + "6"
}
return ""
}
func (f *Filters) NetIPs() (ips []net.IP) {
itfs, _ := net.Interfaces()
for _, itf := range itfs {
if itf.Flags&net.FlagUp == 0 {
continue
}
if !f.IncludeLoopback() && itf.Flags&net.FlagLoopback != 0 {
continue
}
if !f.InterfaceFilter(itf.Name) {
continue
}
addrs, _ := itf.Addrs()
for _, addr := range addrs {
ip := parseNetAddr(addr)
if ip == nil || !f.IPFilter(ip) {
continue
}
ips = append(ips, ip)
}
}
return
}
func parseNetAddr(addr net.Addr) net.IP {
switch addr := addr.(type) {
case *net.IPNet:
return addr.IP
case *net.IPAddr:
return addr.IP
}
return nil
}
func (f *Filters) IncludeLoopback() bool {
return f != nil && f.Loopback
}
func (f *Filters) InterfaceFilter(name string) bool {
return f == nil || f.Interfaces == nil || slices.Contains(f.Interfaces, name)
}
func (f *Filters) IPFilter(ip net.IP) bool {
return f == nil || f.IPs == nil || core.Contains(f.IPs, ip.String())
}
func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error) {
// for debug logs add to env: `PION_LOG_DEBUG=all`
m := &webrtc.MediaEngine{}
@@ -99,48 +164,17 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error
_ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1])
}
//if len(hosts) != 0 {
// // support only: host, srflx
// if candidateType, err := webrtc.NewICECandidateType(hosts[0]); err == nil {
// s.SetNAT1To1IPs(hosts[1:], candidateType)
// } else {
// s.SetNAT1To1IPs(hosts, 0) // 0 = host
// }
//}
// If you don't specify an address, this won't cause an error.
// Connections can still be established using random UDP addresses.
if address != "" {
// Both newMux functions respect filters and do not raise an error
// if the port cannot be listened on.
if network == "" || network == "tcp" {
if ln, err := net.Listen("tcp", address); err == nil {
tcpMux := webrtc.NewICETCPMux(nil, ln, 8)
s.SetICETCPMux(tcpMux)
}
tcpMux := newTCPMux(address, filters)
s.SetICETCPMux(tcpMux)
}
if network == "" || network == "udp" {
// UDPMuxDefault should not listening on unspecified address, use NewMultiUDPMuxFromPort instead
var udpMux ice.UDPMux
if port := xnet.ParseUnspecifiedPort(address); port != 0 {
var networks []ice.NetworkType
for _, ntype := range networkTypes {
networks = append(networks, ice.NetworkType(ntype))
}
var err error
if udpMux, err = ice.NewMultiUDPMuxFromPort(
port,
ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter),
ice.UDPMuxFromPortWithIPFilter(ipFilter),
ice.UDPMuxFromPortWithNetworks(networks...),
); err != nil {
return nil, err
}
} else {
ln, err := net.ListenPacket("udp", address)
if err != nil {
return nil, err
}
udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln})
}
udpMux := newUDPMux(address, filters)
s.SetICEUDPMux(udpMux)
}
}
@@ -152,6 +186,57 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error
), nil
}
// OnNewListener temporary ugly solution for log
var OnNewListener = func(ln any) {}
func newTCPMux(address string, filters *Filters) ice.TCPMux {
networkTCP := filters.Network("tcp") // tcp or tcp4 or tcp6
if ln, _ := net.Listen(networkTCP, address); ln != nil {
OnNewListener(ln)
return webrtc.NewICETCPMux(nil, ln, 8)
}
return nil
}
func newUDPMux(address string, filters *Filters) ice.UDPMux {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil
}
// UDPMux should not listening on unspecified address.
// So we will create a listener on all available interfaces.
// We can't use ice.NewMultiUDPMuxFromPort, because it sometimes crashes with an error:
// listen udp [***]:8555: bind: cannot assign requested address
var addrs []string
if host == "" {
for _, ip := range filters.NetIPs() {
addrs = append(addrs, fmt.Sprintf("%s:%s", ip, port))
}
} else {
addrs = []string{address}
}
networkUDP := filters.Network("udp") // udp or udp4 or udp6
var muxes []ice.UDPMux
for _, addr := range addrs {
if ln, _ := net.ListenPacket(networkUDP, addr); ln != nil {
OnNewListener(ln)
mux := ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln})
muxes = append(muxes, mux)
}
}
switch len(muxes) {
case 0:
return nil
case 1:
return muxes[0]
}
return ice.NewMultiUDPMuxDefault(muxes...)
}
func RegisterDefaultCodecs(m *webrtc.MediaEngine) error {
for _, codec := range []webrtc.RTPCodecParameters{
{
+55
View File
@@ -0,0 +1,55 @@
package wyze
import (
"fmt"
"github.com/AlexxIT/go2rtc/pkg/aac"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/pion/rtp"
)
func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
if err := p.client.StartIntercom(); err != nil {
return fmt.Errorf("wyze: failed to enable intercom: %w", err)
}
// Get the camera's audio codec info (what it sent us = what it accepts)
tutkCodec, sampleRate, channels := p.client.GetBackchannelCodec()
if tutkCodec == 0 {
return fmt.Errorf("wyze: no audio codec detected from camera")
}
if p.client.verbose {
fmt.Printf("[Wyze] Intercom enabled, using codec=0x%04x rate=%d ch=%d\n", tutkCodec, sampleRate, channels)
}
sender := core.NewSender(media, track.Codec)
// Track our own timestamp - camera expects timestamps starting from 0
// and incrementing by frame duration in microseconds
var timestamp uint32 = 0
samplesPerFrame := tutk.GetSamplesPerFrame(tutkCodec)
frameDurationUS := samplesPerFrame * 1000000 / sampleRate
sender.Handler = func(pkt *rtp.Packet) {
if err := p.client.WriteAudio(tutkCodec, pkt.Payload, timestamp, sampleRate, channels); err == nil {
p.Send += len(pkt.Payload)
}
timestamp += frameDurationUS
}
switch track.Codec.Name {
case core.CodecAAC:
if track.Codec.IsRTP() {
sender.Handler = aac.RTPToADTS(codec, sender.Handler)
} else {
sender.Handler = aac.EncodeToADTS(codec, sender.Handler)
}
}
sender.HandleRTP(track)
p.Senders = append(p.Senders, sender)
return nil
}
+618
View File
@@ -0,0 +1,618 @@
package wyze
import (
"crypto/rand"
"encoding/binary"
"encoding/json"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/AlexxIT/go2rtc/pkg/tutk/dtls"
)
const (
FrameSize1080P = 0
FrameSize360P = 1
FrameSize720P = 2
FrameSize2K = 3
FrameSizeFloodlight = 4
)
const (
BitrateMax uint16 = 0xF0
BitrateSD uint16 = 0x3C
)
const (
MediaTypeVideo = 1
MediaTypeAudio = 2
MediaTypeReturnAudio = 3
MediaTypeRDT = 4
)
const (
KCmdAuth = 10000
KCmdChallenge = 10001
KCmdChallengeResp = 10002
KCmdAuthResult = 10003
KCmdControlChannel = 10010
KCmdControlChannelResp = 10011
KCmdSetResolutionDB = 10052
KCmdSetResolutionDBRes = 10053
KCmdSetResolution = 10056
KCmdSetResolutionResp = 10057
)
type Client struct {
conn *dtls.DTLSConn
host string
uid string
enr string
mac string
model string
authKey string
verbose bool
closed bool
closeMu sync.Mutex
hasAudio bool
hasIntercom bool
audioCodecID byte
audioSampleRate uint32
audioChannels uint8
}
type AuthResponse struct {
ConnectionRes string `json:"connectionRes"`
CameraInfo map[string]any `json:"cameraInfo"`
}
func Dial(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("wyze: invalid URL: %w", err)
}
query := u.Query()
if query.Get("dtls") != "true" {
return nil, fmt.Errorf("wyze: only DTLS cameras are supported")
}
c := &Client{
host: u.Host,
uid: query.Get("uid"),
enr: query.Get("enr"),
mac: query.Get("mac"),
model: query.Get("model"),
verbose: query.Get("verbose") == "true",
}
c.authKey = string(dtls.CalculateAuthKey(c.enr, c.mac))
if c.verbose {
fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid)
}
if err := c.connect(); err != nil {
c.Close()
return nil, err
}
if err := c.doAVLogin(); err != nil {
c.Close()
return nil, err
}
if err := c.doKAuth(); err != nil {
c.Close()
return nil, err
}
if c.verbose {
fmt.Printf("[Wyze] Connection established\n")
}
return c, nil
}
func (c *Client) SupportsAudio() bool {
return c.hasAudio
}
func (c *Client) SupportsIntercom() bool {
return c.hasIntercom
}
func (c *Client) SetBackchannelCodec(codecID byte, sampleRate uint32, channels uint8) {
c.audioCodecID = codecID
c.audioSampleRate = sampleRate
c.audioChannels = channels
}
func (c *Client) GetBackchannelCodec() (codecID byte, sampleRate uint32, channels uint8) {
return c.audioCodecID, c.audioSampleRate, c.audioChannels
}
func (c *Client) SetResolution(quality byte) error {
var frameSize uint8
var bitrate uint16
switch quality {
case 0: // Auto/HD - use model's best
frameSize = c.hdFrameSize()
bitrate = BitrateMax
case FrameSize360P: // 1 = SD/360P
frameSize = FrameSize360P
bitrate = BitrateSD
case FrameSize720P: // 2 = 720P
frameSize = FrameSize720P
bitrate = BitrateMax
case FrameSize2K: // 3 = 2K
if c.is2K() {
frameSize = FrameSize2K
} else {
frameSize = c.hdFrameSize()
}
bitrate = BitrateMax
case FrameSizeFloodlight: // 4 = Floodlight
frameSize = c.hdFrameSize()
bitrate = BitrateMax
default:
frameSize = quality
bitrate = BitrateMax
}
if c.verbose {
fmt.Printf("[Wyze] SetResolution: quality=%d frameSize=%d bitrate=%d model=%s\n", quality, frameSize, bitrate, c.model)
}
// Use K10052 (doorbell format) for certain models
if c.useDoorbellResolution() {
k10052 := c.buildK10052(frameSize, bitrate)
_, err := c.conn.WriteAndWaitIOCtrl(k10052, c.matchHL(KCmdSetResolutionDBRes), 5*time.Second)
return err
}
k10056 := c.buildK10056(frameSize, bitrate)
_, err := c.conn.WriteAndWaitIOCtrl(k10056, c.matchHL(KCmdSetResolutionResp), 5*time.Second)
return err
}
func (c *Client) StartVideo() error {
k10010 := c.buildK10010(MediaTypeVideo, true)
_, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second)
return err
}
func (c *Client) StartAudio() error {
k10010 := c.buildK10010(MediaTypeAudio, true)
_, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second)
return err
}
func (c *Client) StartIntercom() error {
if c.conn == nil {
return fmt.Errorf("connection is nil")
}
if c.conn.IsBackchannelReady() {
return nil
}
k10010 := c.buildK10010(MediaTypeReturnAudio, true)
if _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second); err != nil {
return fmt.Errorf("enable return audio: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] Speaker channel enabled, waiting for readiness...\n")
}
return c.conn.AVServStart()
}
func (c *Client) StopIntercom() error {
if c.conn == nil || !c.conn.IsBackchannelReady() {
return nil
}
k10010 := c.buildK10010(MediaTypeReturnAudio, false)
c.conn.WriteIOCtrl(k10010)
return c.conn.AVServStop()
}
func (c *Client) ReadPacket() (*tutk.Packet, error) {
return c.conn.AVRecvFrameData()
}
func (c *Client) WriteAudio(codec byte, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error {
if !c.conn.IsBackchannelReady() {
return fmt.Errorf("speaker channel not connected")
}
if c.verbose {
fmt.Printf("[Wyze] WriteAudio: codec=0x%02x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels)
}
return c.conn.AVSendAudioData(codec, payload, timestamp, sampleRate, channels)
}
func (c *Client) SetDeadline(t time.Time) error {
if c.conn != nil {
return c.conn.SetDeadline(t)
}
return nil
}
func (c *Client) Protocol() string {
return "wyze/dtls"
}
func (c *Client) RemoteAddr() net.Addr {
if c.conn != nil {
return c.conn.RemoteAddr()
}
return nil
}
func (c *Client) Close() error {
c.closeMu.Lock()
if c.closed {
c.closeMu.Unlock()
return nil
}
c.closed = true
c.closeMu.Unlock()
if c.verbose {
fmt.Printf("[Wyze] Closing connection\n")
}
c.StopIntercom()
if c.conn != nil {
c.conn.Close()
}
if c.verbose {
fmt.Printf("[Wyze] Connection closed\n")
}
return nil
}
func (c *Client) connect() error {
host := c.host
port := 0
if idx := strings.Index(host, ":"); idx > 0 {
if p, err := strconv.Atoi(host[idx+1:]); err == nil {
port = p
}
host = host[:idx]
}
conn, err := dtls.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose)
if err != nil {
return fmt.Errorf("wyze: connect failed: %w", err)
}
c.conn = conn
if c.verbose {
fmt.Printf("[Wyze] Connected to %s (IOTC + DTLS)\n", conn.RemoteAddr())
}
return nil
}
func (c *Client) doAVLogin() error {
if c.verbose {
fmt.Printf("[Wyze] Sending AV Login\n")
}
if err := c.conn.AVClientStart(5 * time.Second); err != nil {
return fmt.Errorf("wyze: av login failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] AV Login response received\n")
}
return nil
}
func (c *Client) doKAuth() error {
// Step 1: K10000 -> K10001 (Challenge)
data, err := c.conn.WriteAndWaitIOCtrl(c.buildK10000(), c.matchHL(KCmdChallenge), 5*time.Second)
if err != nil {
return fmt.Errorf("wyze: K10001 failed: %w", err)
}
hlData := c.extractHL(data)
challenge, status, err := c.parseK10001(hlData)
if err != nil {
return fmt.Errorf("wyze: K10001 parse failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] K10001 challenge received, status=%d\n", status)
}
// Step 2: K10002 -> K10003 (Auth)
data, err = c.conn.WriteAndWaitIOCtrl(c.buildK10002(challenge, status), c.matchHL(KCmdAuthResult), 5*time.Second)
if err != nil {
return fmt.Errorf("wyze: K10002 failed: %w", err)
}
hlData = c.extractHL(data)
// Parse K10003 response
authResp, err := c.parseK10003(hlData)
if err != nil {
return fmt.Errorf("wyze: K10003 parse failed: %w", err)
}
if c.verbose && authResp != nil {
if jsonBytes, err := json.MarshalIndent(authResp, "", " "); err == nil {
fmt.Printf("[Wyze] K10003 response:\n%s\n", jsonBytes)
}
}
// Extract audio capability from cameraInfo
if authResp != nil && authResp.CameraInfo != nil {
if channelResult, ok := authResp.CameraInfo["channelRequestResult"].(map[string]any); ok {
if audio, ok := channelResult["audio"].(string); ok {
c.hasAudio = audio == "1"
} else {
c.hasAudio = true
}
} else {
c.hasAudio = true
}
} else {
c.hasAudio = true
}
if c.verbose {
fmt.Printf("[Wyze] K10003 auth success\n")
}
c.hasIntercom = c.conn.HasTwoWayStreaming()
if c.verbose {
fmt.Printf("[Wyze] K-auth complete\n")
}
return nil
}
func (c *Client) buildK10000() []byte {
json := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) // 137=PCMU, 138=PCMA, 140=PCM
b := make([]byte, 16+len(json))
copy(b, "HL") // magic
b[2] = 5 // version
binary.LittleEndian.PutUint16(b[4:], KCmdAuth) // 10000
binary.LittleEndian.PutUint16(b[6:], uint16(len(json))) // payload len
copy(b[16:], json)
return b
}
func (c *Client) buildK10002(challenge []byte, status byte) []byte {
resp := generateChallengeResponse(challenge, c.enr, status)
sessionID := make([]byte, 4)
rand.Read(sessionID)
b := make([]byte, 38)
copy(b, "HL") // magic
b[2] = 5 // version
binary.LittleEndian.PutUint16(b[4:], KCmdChallengeResp) // 10002
b[6] = 22 // payload len
copy(b[16:], resp[:16]) // challenge response
copy(b[32:], sessionID) // random session ID
b[36] = 1 // video enabled/disabled
b[37] = 1 // audio enabled/disabled
return b
}
func (c *Client) buildK10010(mediaType byte, enabled bool) []byte {
b := make([]byte, 18)
copy(b, "HL") // magic
b[2] = 5 // version
binary.LittleEndian.PutUint16(b[4:], KCmdControlChannel) // 10010
binary.LittleEndian.PutUint16(b[6:], 2) // payload len
b[16] = mediaType // 1=video, 2=audio, 3=return audio
b[17] = 1 // 1=enable, 2=disable
if !enabled {
b[17] = 2
}
return b
}
func (c *Client) buildK10052(frameSize uint8, bitrate uint16) []byte {
b := make([]byte, 22)
copy(b, "HL") // magic
b[2] = 5 // version
binary.LittleEndian.PutUint16(b[4:], KCmdSetResolutionDB) // 10052
binary.LittleEndian.PutUint16(b[6:], 6) // payload len
binary.LittleEndian.PutUint16(b[16:], bitrate) // bitrate (2 bytes)
b[18] = frameSize + 1 // frame size (1 byte)
// b[19] = fps, b[20:22] = zeros
return b
}
func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte {
b := make([]byte, 21)
copy(b, "HL") // magic
b[2] = 5 // version
binary.LittleEndian.PutUint16(b[4:], KCmdSetResolution) // 10056
binary.LittleEndian.PutUint16(b[6:], 5) // payload len
b[16] = frameSize + 1 // frame size
binary.LittleEndian.PutUint16(b[17:], bitrate) // bitrate
// b[19:21] = FPS (0 = auto)
return b
}
func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err error) {
if c.verbose {
fmt.Printf("[Wyze] parseK10001: received %d bytes\n", len(data))
}
if len(data) < 33 {
return nil, 0, fmt.Errorf("data too short: %d bytes", len(data))
}
if data[0] != 'H' || data[1] != 'L' {
return nil, 0, fmt.Errorf("invalid HL magic: %x %x", data[0], data[1])
}
cmdID := binary.LittleEndian.Uint16(data[4:])
if cmdID != KCmdChallenge {
return nil, 0, fmt.Errorf("expected cmdID 10001, got %d", cmdID)
}
status = data[16]
challenge = make([]byte, 16)
copy(challenge, data[17:33])
return challenge, status, nil
}
func (c *Client) parseK10003(data []byte) (*AuthResponse, error) {
if c.verbose {
fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data))
}
if len(data) < 16 {
return &AuthResponse{}, nil
}
if data[0] != 'H' || data[1] != 'L' {
return &AuthResponse{}, nil
}
cmdID := binary.LittleEndian.Uint16(data[4:])
textLen := binary.LittleEndian.Uint16(data[6:])
if cmdID != KCmdAuthResult {
return &AuthResponse{}, nil
}
if len(data) > 16 && textLen > 0 {
jsonData := data[16:]
for i := range jsonData {
if jsonData[i] == '{' {
var resp AuthResponse
if err := json.Unmarshal(jsonData[i:], &resp); err == nil {
if c.verbose {
fmt.Printf("[Wyze] parseK10003: parsed JSON\n")
}
return &resp, nil
}
break
}
}
}
return &AuthResponse{}, nil
}
func (c *Client) useDoorbellResolution() bool {
switch c.model {
case "WYZEDB3", "WVOD1", "HL_WCO2", "WYZEC1":
return true
}
return false
}
func (c *Client) hdFrameSize() uint8 {
if c.isFloodlight() {
return FrameSizeFloodlight
}
if c.is2K() {
return FrameSize2K
}
return FrameSize1080P
}
func (c *Client) is2K() bool {
switch c.model {
case "HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2":
return true
}
return false
}
func (c *Client) isFloodlight() bool {
return c.model == "HL_CFL2"
}
func (c *Client) matchHL(expectCmd uint16) func([]byte) bool {
return func(data []byte) bool {
hlData := c.extractHL(data)
if hlData == nil {
return false
}
cmd, _, ok := tutk.ParseHL(hlData)
return ok && cmd == expectCmd
}
}
func (c *Client) extractHL(data []byte) []byte {
// Try offset 32 (magicIOCtrl, protoVersion)
if hlData := tutk.FindHL(data, 32); hlData != nil {
return hlData
}
// Try offset 36 (magicChannelMsg)
if len(data) >= 36 && data[16] == 0x00 {
return tutk.FindHL(data, 36)
}
return nil
}
const (
statusDefault byte = 1
statusENR16 byte = 3
statusENR32 byte = 6
)
func generateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte {
var secretKey []byte
switch status {
case statusDefault:
secretKey = []byte("FFFFFFFFFFFFFFFF")
case statusENR16:
if len(enr) >= 16 {
secretKey = []byte(enr[:16])
} else {
secretKey = make([]byte, 16)
copy(secretKey, enr)
}
case statusENR32:
if len(enr) >= 16 {
firstKey := []byte(enr[:16])
challengeBytes = tutk.XXTEADecryptVar(challengeBytes, firstKey)
}
if len(enr) >= 32 {
secretKey = []byte(enr[16:32])
} else if len(enr) > 16 {
secretKey = make([]byte, 16)
copy(secretKey, []byte(enr[16:]))
} else {
secretKey = []byte("FFFFFFFFFFFFFFFF")
}
default:
secretKey = []byte("FFFFFFFFFFFFFFFF")
}
return tutk.XXTEADecryptVar(challengeBytes, secretKey)
}
+337
View File
@@ -0,0 +1,337 @@
package wyze
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
const (
baseURLAuth = "https://auth-prod.api.wyze.com"
baseURLAPI = "https://api.wyzecam.com"
appName = "com.hualai.WyzeCam"
appVersion = "2.50.0"
)
type Cloud struct {
client *http.Client
apiKey string
keyID string
accessToken string
phoneID string
cameras []*Camera
}
type Camera struct {
MAC string `json:"mac"`
P2PID string `json:"p2p_id"`
ENR string `json:"enr"`
IP string `json:"ip"`
Nickname string `json:"nickname"`
ProductModel string `json:"product_model"`
ProductType string `json:"product_type"`
DTLS int `json:"dtls"`
FirmwareVer string `json:"firmware_ver"`
IsOnline bool `json:"is_online"`
}
type deviceListResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data struct {
DeviceList []deviceInfo `json:"device_list"`
} `json:"data"`
}
type deviceInfo struct {
MAC string `json:"mac"`
ENR string `json:"enr"`
Nickname string `json:"nickname"`
ProductModel string `json:"product_model"`
ProductType string `json:"product_type"`
FirmwareVer string `json:"firmware_ver"`
ConnState int `json:"conn_state"`
DeviceParams deviceParams `json:"device_params"`
}
type deviceParams struct {
P2PID string `json:"p2p_id"`
P2PType int `json:"p2p_type"`
IP string `json:"ip"`
DTLS int `json:"dtls"`
}
type p2pInfoResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data map[string]any `json:"data"`
}
type loginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
UserID string `json:"user_id"`
MFAOptions []string `json:"mfa_options"`
SMSSessionID string `json:"sms_session_id"`
EmailSessionID string `json:"email_session_id"`
}
func NewCloud(apiKey, keyID string) *Cloud {
return &Cloud{
client: &http.Client{Timeout: 30 * time.Second},
phoneID: generatePhoneID(),
apiKey: apiKey,
keyID: keyID,
}
}
func (c *Cloud) Login(email, password string) error {
payload := map[string]string{
"email": strings.TrimSpace(email),
"password": hashPassword(password),
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", baseURLAuth+"/api/user/login", strings.NewReader(string(jsonData)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Apikey", c.apiKey)
req.Header.Set("Keyid", c.keyID)
req.Header.Set("User-Agent", "go2rtc")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var errResp apiError
_ = json.Unmarshal(body, &errResp)
if errResp.hasError() {
return fmt.Errorf("wyze: login failed (code %s): %s", errResp.code(), errResp.message())
}
var result loginResponse
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("wyze: failed to parse login response: %w", err)
}
if len(result.MFAOptions) > 0 {
return &AuthError{
Message: "MFA required",
NeedsMFA: true,
MFAType: strings.Join(result.MFAOptions, ","),
}
}
if result.AccessToken == "" {
return errors.New("wyze: no access token in response")
}
c.accessToken = result.AccessToken
return nil
}
func (c *Cloud) GetCameraList() ([]*Camera, error) {
payload := map[string]any{
"access_token": c.accessToken,
"phone_id": c.phoneID,
"app_name": appName,
"app_ver": appName + "___" + appVersion,
"app_version": appVersion,
"phone_system_type": 1,
"sc": "9f275790cab94a72bd206c8876429f3c",
"sv": "9d74946e652647e9b6c9d59326aef104",
"ts": time.Now().UnixMilli(),
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/home_page/get_object_list", strings.NewReader(string(jsonData)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result deviceListResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("wyze: failed to parse device list: %w", err)
}
if result.Code != "1" {
return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg)
}
c.cameras = nil
for _, dev := range result.Data.DeviceList {
if dev.ProductType != "Camera" {
continue
}
if dev.DeviceParams.IP == "" {
continue // skip cameras without IP (gwell protocol)
}
c.cameras = append(c.cameras, &Camera{
MAC: dev.MAC,
P2PID: dev.DeviceParams.P2PID,
ENR: dev.ENR,
IP: dev.DeviceParams.IP,
Nickname: dev.Nickname,
ProductModel: dev.ProductModel,
ProductType: dev.ProductType,
DTLS: dev.DeviceParams.DTLS,
FirmwareVer: dev.FirmwareVer,
IsOnline: dev.ConnState == 1,
})
}
return c.cameras, nil
}
func (c *Cloud) GetCamera(id string) (*Camera, error) {
if c.cameras == nil {
if _, err := c.GetCameraList(); err != nil {
return nil, err
}
}
id = strings.ToUpper(id)
for _, cam := range c.cameras {
if strings.ToUpper(cam.MAC) == id || strings.EqualFold(cam.Nickname, id) {
return cam, nil
}
}
return nil, fmt.Errorf("wyze: camera not found: %s", id)
}
func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) {
payload := map[string]any{
"access_token": c.accessToken,
"phone_id": c.phoneID,
"device_mac": mac,
"app_name": appName,
"app_ver": appName + "___" + appVersion,
"app_version": appVersion,
"phone_system_type": 1,
"sc": "9f275790cab94a72bd206c8876429f3c",
"sv": "9d74946e652647e9b6c9d59326aef104",
"ts": time.Now().UnixMilli(),
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/device/get_iotc_info", strings.NewReader(string(jsonData)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result p2pInfoResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
if result.Code != "1" {
return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg)
}
return result.Data, nil
}
type apiError struct {
Code string `json:"code"`
ErrorCode int `json:"errorCode"`
Msg string `json:"msg"`
Description string `json:"description"`
}
func (e *apiError) hasError() bool {
if e.Code == "1" || e.Code == "0" {
return false
}
if e.Code == "" && e.ErrorCode == 0 {
return false
}
return e.Code != "" || e.ErrorCode != 0
}
func (e *apiError) message() string {
if e.Msg != "" {
return e.Msg
}
return e.Description
}
func (e *apiError) code() string {
if e.Code != "" {
return e.Code
}
return fmt.Sprintf("%d", e.ErrorCode)
}
type AuthError struct {
Message string `json:"message"`
NeedsMFA bool `json:"needs_mfa,omitempty"`
MFAType string `json:"mfa_type,omitempty"`
}
func (e *AuthError) Error() string {
return e.Message
}
func generatePhoneID() string {
return core.RandString(16, 16) // 16 hex chars
}
func hashPassword(password string) string {
encoded := strings.TrimSpace(password)
if strings.HasPrefix(strings.ToLower(encoded), "md5:") {
return encoded[4:]
}
for range 3 {
hash := md5.Sum([]byte(encoded))
encoded = hex.EncodeToString(hash[:])
}
return encoded
}
+277
View File
@@ -0,0 +1,277 @@
package wyze
import (
"fmt"
"net/url"
"time"
"github.com/AlexxIT/go2rtc/pkg/aac"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/AlexxIT/go2rtc/pkg/h264/annexb"
"github.com/AlexxIT/go2rtc/pkg/h265"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/pion/rtp"
)
type Producer struct {
core.Connection
client *Client
model string
}
func NewProducer(rawURL string) (*Producer, error) {
client, err := Dial(rawURL)
if err != nil {
return nil, err
}
u, _ := url.Parse(rawURL)
query := u.Query()
// 0 = HD (default), 1 = SD/360P, 2 = 720P, 3 = 2K, 4 = Floodlight
var quality byte
switch s := query.Get("subtype"); s {
case "", "hd":
quality = 0
case "sd":
quality = FrameSize360P
default:
quality = core.ParseByte(s)
}
medias, err := probe(client, quality)
if err != nil {
_ = client.Close()
return nil, err
}
prod := &Producer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "wyze",
Protocol: client.Protocol(),
RemoteAddr: client.RemoteAddr().String(),
Source: rawURL,
Medias: medias,
Transport: client,
},
client: client,
model: query.Get("model"),
}
return prod, nil
}
func (p *Producer) Start() error {
for {
if p.client.verbose {
fmt.Println("[Wyze] Reading packet...")
}
_ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline))
pkt, err := p.client.ReadPacket()
if err != nil {
return err
}
if pkt == nil {
continue
}
var name string
var pkt2 *core.Packet
switch codecID := pkt.Codec; codecID {
case tutk.CodecH264:
name = core.CodecH264
pkt2 = &core.Packet{
Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: annexb.EncodeToAVCC(pkt.Payload),
}
case tutk.CodecH265:
name = core.CodecH265
pkt2 = &core.Packet{
Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: annexb.EncodeToAVCC(pkt.Payload),
}
case tutk.CodecPCMU:
name = core.CodecPCMU
pkt2 = &core.Packet{
Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: pkt.Payload,
}
case tutk.CodecPCMA:
name = core.CodecPCMA
pkt2 = &core.Packet{
Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: pkt.Payload,
}
case tutk.CodecAACADTS, tutk.CodecAACAlt, tutk.CodecAACRaw, tutk.CodecAACLATM:
name = core.CodecAAC
payload := pkt.Payload
if aac.IsADTS(payload) {
payload = payload[aac.ADTSHeaderLen(payload):]
}
pkt2 = &core.Packet{
Header: rtp.Header{Version: aac.RTPPacketVersionAAC, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: payload,
}
case tutk.CodecOpus:
name = core.CodecOpus
pkt2 = &core.Packet{
Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: pkt.Payload,
}
case tutk.CodecPCML:
name = core.CodecPCML
pkt2 = &core.Packet{
Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: pkt.Payload,
}
case tutk.CodecMP3:
name = core.CodecMP3
pkt2 = &core.Packet{
Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: pkt.Payload,
}
case tutk.CodecMJPEG:
name = core.CodecJPEG
pkt2 = &core.Packet{
Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp},
Payload: pkt.Payload,
}
default:
continue
}
for _, recv := range p.Receivers {
if recv.Codec.Name == name {
recv.WriteRTP(pkt2)
break
}
}
}
}
func probe(client *Client, quality byte) ([]*core.Media, error) {
client.SetResolution(quality)
client.SetDeadline(time.Now().Add(core.ProbeTimeout))
var vcodec, acodec *core.Codec
var tutkAudioCodec byte
for {
if client.verbose {
fmt.Println("[Wyze] Probing for codecs...")
}
pkt, err := client.ReadPacket()
if err != nil {
return nil, fmt.Errorf("wyze: probe: %w", err)
}
if pkt == nil || len(pkt.Payload) < 5 {
continue
}
switch pkt.Codec {
case tutk.CodecH264:
if vcodec == nil {
buf := annexb.EncodeToAVCC(pkt.Payload)
if len(buf) >= 5 && h264.NALUType(buf) == h264.NALUTypeSPS {
vcodec = h264.AVCCToCodec(buf)
}
}
case tutk.CodecH265:
if vcodec == nil {
buf := annexb.EncodeToAVCC(pkt.Payload)
if len(buf) >= 5 && h265.NALUType(buf) == h265.NALUTypeVPS {
vcodec = h265.AVCCToCodec(buf)
}
}
case tutk.CodecPCMU:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
case tutk.CodecPCMA:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
case tutk.CodecAACAlt, tutk.CodecAACADTS, tutk.CodecAACRaw, tutk.CodecAACLATM:
if acodec == nil {
config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false)
acodec = aac.ConfigToCodec(config)
tutkAudioCodec = pkt.Codec
}
case tutk.CodecOpus:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2}
tutkAudioCodec = pkt.Codec
}
case tutk.CodecPCML:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
case tutk.CodecMP3:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
case tutk.CodecMJPEG:
if vcodec == nil {
vcodec = &core.Codec{Name: core.CodecJPEG, ClockRate: 90000, PayloadType: core.PayloadTypeRAW}
}
}
if vcodec != nil && (acodec != nil || !client.SupportsAudio()) {
break
}
}
_ = client.SetDeadline(time.Time{})
medias := []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{vcodec},
},
}
if acodec != nil {
medias = append(medias, &core.Media{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{acodec},
})
if client.SupportsIntercom() {
client.SetBackchannelCodec(tutkAudioCodec, acodec.ClockRate, uint8(acodec.Channels))
medias = append(medias, &core.Media{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{acodec.Clone()},
})
}
}
if client.verbose {
fmt.Printf("[Wyze] Probed codecs: video=%s audio=%s\n", vcodec.Name, acodec.Name)
if client.SupportsIntercom() {
fmt.Printf("[Wyze] Intercom supported, audio send codec=%s\n", acodec.Name)
}
}
return medias, nil
}
+17 -12
View File
@@ -78,12 +78,9 @@ func (c *Cloud) Login(username, password string) error {
}
req := Request{
Method: "POST",
URL: "https://account.xiaomi.com/pass/serviceLoginAuth2",
RawBody: form.Encode(),
Headers: url.Values{
"Content-Type": {"application/x-www-form-urlencoded"},
},
Method: "POST",
URL: "https://account.xiaomi.com/pass/serviceLoginAuth2",
Body: form,
RawCookies: cookies,
}.Encode()
@@ -105,7 +102,7 @@ func (c *Cloud) Login(username, password string) error {
return err
}
// save auth for two step verification
// save auth for two-step verification
c.auth = map[string]string{
"username": username,
"password": password,
@@ -265,11 +262,17 @@ func (c *Cloud) sendTicket() error {
cookies += "; ick=" + c.auth["ick"]
}
form := url.Values{
"_json": {"true"},
"icode": {captCode},
"retry": {"0"},
}
req = Request{
Method: "POST",
URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket",
Body: form,
RawCookies: cookies,
RawBody: `{"retry":0,"icode":"` + captCode + `","_json":"true"}`,
}.Encode()
res, err = c.client.Do(req)
@@ -531,7 +534,7 @@ type Request struct {
Method string
URL string
RawParams string
RawBody string
Body url.Values
Headers url.Values
RawCookies string
}
@@ -542,8 +545,8 @@ func (r Request) Encode() *http.Request {
}
var body io.Reader
if r.RawBody != "" {
body = strings.NewReader(r.RawBody)
if r.Body != nil {
body = strings.NewReader(r.Body.Encode())
}
req, err := http.NewRequest(r.Method, r.URL, body)
@@ -554,7 +557,9 @@ func (r Request) Encode() *http.Request {
if r.Headers != nil {
req.Header = http.Header(r.Headers)
}
if r.Body != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if r.RawCookies != "" {
req.Header.Set("Cookie", r.RawCookies)
}
+68
View File
@@ -0,0 +1,68 @@
package crypto
import (
"crypto/rand"
"encoding/hex"
"golang.org/x/crypto/chacha20"
"golang.org/x/crypto/nacl/box"
)
func GenerateKey() ([]byte, []byte, error) {
public, private, err := box.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return public[:], private[:], err
}
func CalcSharedKey(devicePublicB64, clientPrivateB64 string) ([]byte, error) {
var sharedKey, publicKey, privateKey [32]byte
if _, err := hex.Decode(publicKey[:], []byte(devicePublicB64)); err != nil {
return nil, err
}
if _, err := hex.Decode(privateKey[:], []byte(clientPrivateB64)); err != nil {
return nil, err
}
box.Precompute(&sharedKey, &publicKey, &privateKey)
return sharedKey[:], nil
}
func Encode(src, key32 []byte) ([]byte, error) {
dst := make([]byte, len(src)+8)
if _, err := rand.Read(dst[:8]); err != nil {
return nil, err
}
nonce12 := make([]byte, 12)
copy(nonce12[4:], dst[:8])
c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12)
if err != nil {
return nil, err
}
c.XORKeyStream(dst[8:], src)
return dst, nil
}
func Decode(src, key32 []byte) ([]byte, error) {
return DecodeNonce(src[8:], src[:8], key32)
}
func DecodeNonce(src, nonce8, key32 []byte) ([]byte, error) {
nonce12 := make([]byte, 12)
copy(nonce12[4:], nonce8)
c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12)
if err != nil {
return nil, err
}
dst := make([]byte, len(src))
c.XORKeyStream(dst, src)
return dst, nil
}
+218
View File
@@ -0,0 +1,218 @@
package legacy
import (
"encoding/binary"
"errors"
"fmt"
"net/url"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto"
)
func NewClient(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
query := u.Query()
model := query.Get("model")
var username, password string
var key []byte
if query.Has("sign") {
// Legacy with encryption
key, err = crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private"))
if err != nil {
return nil, err
}
username = fmt.Sprintf(
`{"public_key":"%s","sign":"%s","account":"admin"}`,
query.Get("client_public"), query.Get("sign"),
)
} else if model == ModelXiaobai {
username = "admin"
password = query.Get("password")
} else if model == ModelXiaofang {
username = "admin"
} else {
return nil, fmt.Errorf("xiaomi: unsupported model: %s", model)
}
conn, err := tutk.Dial(u.Host, query.Get("uid"), username, password)
if err != nil {
return nil, err
}
if model == ModelXiaofang {
err = xiaofangLogin(conn, query.Get("password"))
if err != nil {
_ = conn.Close()
return nil, err
}
}
c := &Client{
Conn: conn,
key: key,
model: model,
}
return c, nil
}
func xiaofangLogin(conn *tutk.Conn, password string) error {
data := tutk.ICAM(0x0400be) // ask login
if err := conn.WriteCommand(0x0100, data); err != nil {
return err
}
_, data, err := conn.ReadCommand() // login request
if err != nil {
return err
}
enc := data[24:] // data[23] == 3
tutk.XXTEADecrypt(enc, enc, []byte(password))
enc = append(enc, 0, 0, 0, 0, 1, 1, 1)
data = tutk.ICAM(0x0400c0, enc...) // login response
if err = conn.WriteCommand(0x0100, data); err != nil {
return err
}
_, data, err = conn.ReadCommand()
return err
}
type Client struct {
*tutk.Conn
key []byte
model string
}
func (c *Client) Version() string {
return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model)
}
func (c *Client) ReadPacket() (hdr, payload []byte, err error) {
hdr, payload, err = c.Conn.ReadPacket()
if err != nil {
return
}
if c.key != nil {
switch hdr[0] {
case tutk.CodecH264, tutk.CodecH265:
payload, err = DecodeVideo(payload, c.key)
case tutk.CodecAACLATM:
payload, err = crypto.Decode(payload, c.key)
}
}
return
}
func (c *Client) StartMedia(video, audio string) error {
switch c.model {
case ModelAqaraG2:
return c.WriteCommand(0x01ff, []byte(`{}`))
case ModelXiaobai:
// 00030000 7b7d audio on
// 01030000 7b7d audio off
if err := c.WriteCommand(0x0300, []byte(`{}`)); err != nil {
return err
}
var b byte
switch video {
case "", "fhd":
b = 1
case "hd":
b = 2
case "sd":
b = 4
case "auto":
b = 0xff
}
// 20030000 0000000001000000 fhd (1920x1080)
// 20030000 0000000002000000 hd (1280x720)
// 20030000 0000000004000000 low (640x360)
// 20030000 00000000ff000000 auto (1920x1080)
if err := c.WriteCommand(0x0320, []byte{0, 0, 0, 0, b, 0, 0, 0}); err != nil {
return err
}
// ff010000 7b7d video tart
// ff020000 7b7d video stop
return c.WriteCommand(0x01ff, []byte(`{}`))
case ModelXiaofang:
// 00010000 4943414d 95010400000000000000000600000000000000d20400005a07 - 90k bitrate
// 00010000 4943414d 95010400000000000000000600000000000000d20400001e07 - 30k bitrate
//var b byte
//switch video {
//case "", "hd":
// b = 0x5a // bitrate 90k
//case "sd":
// b = 0x1e // bitrate 30k
//}
//data := tutk.ICAM(0x040195, 0xd2, 4, 0, 0, b, 7)
//if err := c.WriteCommand(0x100, data); err != nil {
// return err
//}
}
return nil
}
func (c *Client) StopMedia() error {
return errors.Join(
c.WriteCommand(0x02ff, []byte(`{}`)),
c.WriteCommand(0x02ff, make([]byte, 8)),
)
}
func DecodeVideo(data, key []byte) ([]byte, error) {
if string(data[:4]) == "\x00\x00\x00\x01" || data[8] == 0 {
return data, nil
}
if data[8] != 1 {
// Support could be added, but I haven't seen such cameras.
return nil, fmt.Errorf("xiaomi: unsupported encryption")
}
nonce8 := data[:8]
i1 := binary.LittleEndian.Uint16(data[9:])
i2 := binary.LittleEndian.Uint16(data[13:])
data = data[17:]
src := data[i1 : i1+i2]
for i := 32; i+16 < len(src); i += 160 {
dst, err := crypto.DecodeNonce(src[i:i+16], nonce8, key)
if err != nil {
return nil, err
}
copy(src[i:], dst) // copy result in same place
}
return data, nil
}
const (
ModelAqaraG2 = "lumi.camera.gwagl01"
ModelLoockV1 = "loock.cateye.v01"
ModelXiaobai = "chuangmi.camera.xiaobai"
ModelXiaofang = "isa.camera.isc5"
)
func Supported(model string) bool {
switch model {
case ModelAqaraG2, ModelLoockV1, ModelXiaobai, ModelXiaofang:
return true
}
return false
}
+216
View File
@@ -0,0 +1,216 @@
package legacy
import (
"net/url"
"time"
"github.com/AlexxIT/go2rtc/pkg/aac"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/AlexxIT/go2rtc/pkg/h264/annexb"
"github.com/AlexxIT/go2rtc/pkg/h265"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/pion/rtp"
)
func Dial(rawURL string) (*Producer, error) {
client, err := NewClient(rawURL)
if err != nil {
return nil, err
}
u, _ := url.Parse(rawURL)
query := u.Query()
err = client.StartMedia(query.Get("subtype"), "")
if err != nil {
_ = client.Close()
return nil, err
}
medias, err := probe(client)
if err != nil {
_ = client.Close()
return nil, err
}
c := &Producer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "xiaomi/legacy",
Protocol: "tutk+udp",
RemoteAddr: client.RemoteAddr().String(),
UserAgent: client.Version(),
Medias: medias,
Transport: client,
},
client: client,
}
return c, nil
}
type Producer struct {
core.Connection
client *Client
}
const codecXiaobaiPCMA = 1 // chuangmi.camera.xiaobai
func probe(client *Client) ([]*core.Media, error) {
_ = client.SetDeadline(time.Now().Add(15 * time.Second))
var vcodec, acodec *core.Codec
for {
// 0 5000 codec
// 2 0000 codec params
// 4 01 active clients
// 5 34 unknown const
// 6 0600 unknown seq(s)
// 8 80026801 unknown fixed
// 12 ed8d5c69 time in sec
// 16 4c03 time in 1/1000
// 18 0000
hdr, payload, err := client.ReadPacket()
if err != nil {
return nil, err
}
switch codec := hdr[0]; codec {
case tutk.CodecH264, tutk.CodecH265:
if vcodec == nil {
avcc := annexb.EncodeToAVCC(payload)
if codec == tutk.CodecH264 {
if h264.NALUType(avcc) == h264.NALUTypeSPS {
vcodec = h264.AVCCToCodec(avcc)
}
} else {
if h265.NALUType(avcc) == h265.NALUTypeVPS {
vcodec = h265.AVCCToCodec(avcc)
}
}
}
case tutk.CodecPCMA, codecXiaobaiPCMA:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000}
}
case tutk.CodecPCML:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000}
}
case tutk.CodecAACLATM:
if acodec == nil {
acodec = aac.ADTSToCodec(payload)
if acodec != nil {
acodec.PayloadType = core.PayloadTypeRAW
}
}
}
if vcodec != nil && acodec != nil {
break
}
}
medias := []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{vcodec},
},
{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{acodec},
},
}
return medias, nil
}
func (c *Producer) Protocol() string {
return "tutk+udp"
}
func (c *Producer) Start() error {
var audioTS uint32
var videoSeq, audioSeq uint16
for {
_ = c.client.SetDeadline(time.Now().Add(5 * time.Second))
hdr, payload, err := c.client.ReadPacket()
if err != nil {
return err
}
n := len(payload)
c.Recv += n
// TODO: rewrite this
var name string
var pkt *core.Packet
switch codec := hdr[0]; codec {
case tutk.CodecH264, tutk.CodecH265:
pkt = &core.Packet{
Header: rtp.Header{
SequenceNumber: videoSeq,
Timestamp: core.Now90000(),
},
Payload: annexb.EncodeToAVCC(payload),
}
videoSeq++
if codec == tutk.CodecH264 {
name = core.CodecH264
} else {
name = core.CodecH265
}
case tutk.CodecPCMA, tutk.CodecPCML, codecXiaobaiPCMA:
pkt = &core.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
SequenceNumber: audioSeq,
Timestamp: audioTS,
},
Payload: payload,
}
audioSeq++
switch codec {
case tutk.CodecPCMA, codecXiaobaiPCMA:
name = core.CodecPCMA
audioTS += uint32(n)
case tutk.CodecPCML:
name = core.CodecPCML
audioTS += uint32(n / 2) // because 16bit
}
case tutk.CodecAACLATM:
pkt = &core.Packet{
Header: rtp.Header{
SequenceNumber: audioSeq,
Timestamp: audioTS,
},
Payload: payload,
}
audioSeq++
name = core.CodecAAC
audioTS += 1024
}
for _, recv := range c.Receivers {
if recv.Codec.Name == name {
recv.WriteRTP(pkt)
break
}
}
}
}
func (c *Producer) Stop() error {
_ = c.client.StopMedia()
return c.Connection.Stop()
}
@@ -1,4 +1,4 @@
package xiaomi
package miss
import (
"time"
@@ -6,12 +6,11 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/opus"
"github.com/AlexxIT/go2rtc/pkg/pcm"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
"github.com/pion/rtp"
)
func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
if err := p.client.SpeakerStart(); err != nil {
if err := p.client.StartSpeaker(); err != nil {
return err
}
// TODO: check this!!!
@@ -23,7 +22,7 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
case core.CodecPCMA:
var buf []byte
if p.model == "isa.camera.hlc6" {
if p.client.SpeakerCodec() == codecPCM {
dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000}
transcode := pcm.Transcode(dst, track.Codec)
@@ -31,7 +30,8 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
buf = append(buf, transcode(pkt.Payload)...)
const size = 2 * 8000 * 0.040 // 16bit 40ms
for len(buf) >= size {
_ = p.client.WriteAudio(miss.CodecPCM, buf[:size])
p.Send += size
_ = p.client.WriteAudio(codecPCM, buf[:size])
buf = buf[size:]
}
}
@@ -40,13 +40,14 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
buf = append(buf, pkt.Payload...)
const size = 8000 * 0.040 // 8bit 40 ms
for len(buf) >= size {
_ = p.client.WriteAudio(miss.CodecPCMA, buf[:size])
p.Send += size
_ = p.client.WriteAudio(codecPCMA, buf[:size])
buf = buf[size:]
}
}
}
case core.CodecOpus:
if p.model == "chuangmi.camera.72ac1" {
if p.client.SpeakerCodec() == codecOPUS {
var buf []byte
sender.Handler = func(pkt *rtp.Packet) {
if buf == nil {
@@ -54,13 +55,15 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
} else {
// convert two 20ms to one 40ms
buf = opus.JoinFrames(buf, pkt.Payload)
_ = p.client.WriteAudio(miss.CodecOPUS, buf)
p.Send += len(buf)
_ = p.client.WriteAudio(codecOPUS, buf)
buf = nil
}
}
} else {
sender.Handler = func(pkt *rtp.Packet) {
_ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload)
p.Send += len(pkt.Payload)
_ = p.client.WriteAudio(codecOPUS, pkt.Payload)
}
}
}
+208 -343
View File
@@ -1,109 +1,87 @@
package miss
import (
"crypto/rand"
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"log"
"net"
"net/url"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"golang.org/x/crypto/chacha20"
"golang.org/x/crypto/nacl/box"
"github.com/AlexxIT/go2rtc/pkg/tutk"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss/cs2"
)
func Dial(rawURL string) (*Client, error) {
const (
codecH264 = 4
codecH265 = 5
codecPCM = 1024
codecPCMU = 1026
codecPCMA = 1027
codecOPUS = 1032
)
type Conn interface {
Protocol() string
Version() string
ReadCommand() (cmd uint32, data []byte, err error)
WriteCommand(cmd uint32, data []byte) error
ReadPacket() (hdr, payload []byte, err error)
WritePacket(hdr, payload []byte) error
RemoteAddr() net.Addr
SetDeadline(t time.Time) error
Close() error
}
func NewClient(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
// 1. Check if we can create shared key.
query := u.Query()
if s := query.Get("vendor"); s != "cs2" {
return nil, fmt.Errorf("miss: unsupported vendor %s", s)
}
clientPrivate := query.Get("client_private")
devicePublic := query.Get("device_public")
key, err := calcSharedKey(devicePublic, clientPrivate)
key, err := crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private"))
if err != nil {
return nil, err
}
conn, err := net.ListenUDP("udp", nil)
model := query.Get("model")
// 2. Check if this vendor supported.
var conn Conn
switch s := query.Get("vendor"); s {
case "cs2":
conn, err = cs2.Dial(u.Host, query.Get("transport"))
case "tutk":
conn, err = tutk.Dial(u.Host, query.Get("uid"), "Miss", "client")
default:
err = fmt.Errorf("miss: unsupported vendor %s", s)
}
if err != nil {
return nil, err
}
client := &Client{
conn: conn,
addr: &net.UDPAddr{IP: net.ParseIP(u.Host), Port: 32108},
buf: make([]byte, 1500),
key: key,
}
clientPublic := query.Get("client_public")
sign := query.Get("sign")
if err = client.login(clientPublic, sign); err != nil {
err = login(conn, query.Get("client_public"), query.Get("sign"))
if err != nil {
_ = conn.Close()
return nil, err
}
client.chSeq0 = 1
client.chRaw2 = make(chan []byte, 100)
go client.worker()
return client, nil
return &Client{Conn: conn, key: key, model: model}, nil
}
const (
CodecH264 = 4
CodecH265 = 5
CodecPCM = 1024
CodecPCMU = 1026
CodecPCMA = 1027
CodecOPUS = 1032
)
type Client struct {
conn *net.UDPConn
addr *net.UDPAddr
buf []byte
key []byte // shared key
chSeq0 uint16
chSeq3 uint16
chRaw2 chan []byte
}
func (c *Client) RemoteAddr() *net.UDPAddr {
return c.addr
}
func (c *Client) SetDeadline(t time.Time) error {
return c.conn.SetDeadline(t)
}
func (c *Client) Close() error {
return c.conn.Close()
Conn
key []byte
model string
}
const (
magic = 0xF1
magicDrw = 0xD1
msgLanSearch = 0x30
msgPunchPkt = 0x41
msgP2PRdy = 0x42
msgDrw = 0xD0
msgDrwAck = 0xD1
msgAlive = 0xE0
cmdAuthReq = 0x100
cmdAuthRes = 0x101
cmdVideoStart = 0x102
@@ -126,309 +104,166 @@ const (
cmdEncoded = 0x1001
)
func (c *Client) login(clientPublic, sign string) error {
_ = c.conn.SetDeadline(time.Now().Add(core.ConnDialTimeout))
buf, err := c.writeAndWait([]byte{magic, msgLanSearch, 0, 0}, msgPunchPkt)
if err != nil {
return fmt.Errorf("miss: read punch: %w", err)
}
_, err = c.writeAndWait(buf, msgP2PRdy)
if err != nil {
return fmt.Errorf("miss: read ready: %w", err)
}
_, _ = c.conn.WriteToUDP([]byte{magic, msgAlive, 0, 0}, c.addr)
func login(conn Conn, clientPublic, sign string) error {
s := fmt.Sprintf(`{"public_key":"%s","sign":"%s","uuid":"","support_encrypt":0}`, clientPublic, sign)
buf, err = c.writeAndWait(marshalCmd(0, 0, cmdAuthReq, []byte(s)), msgDrw)
if err := conn.WriteCommand(cmdAuthReq, []byte(s)); err != nil {
return err
}
_, data, err := conn.ReadCommand()
if err != nil {
return fmt.Errorf("miss: read auth: %w", err)
return err
}
if !strings.Contains(string(buf[16:]), `"result":"success"`) {
return fmt.Errorf("miss: read auth: %s", buf[16:])
if !bytes.Contains(data, []byte(`"result":"success"`)) {
return fmt.Errorf("miss: auth: %s", data)
}
_, _ = c.conn.WriteToUDP([]byte{magic, msgDrwAck, 0, 6, magicDrw, 0, 0, 1, 0, 0}, c.addr)
_ = c.conn.SetDeadline(time.Time{})
return nil
}
func (c *Client) writeAndWait(b []byte, waitMsg uint8) ([]byte, error) {
if _, err := c.conn.WriteToUDP(b, c.addr); err != nil {
return nil, err
}
for {
n, addr, err := c.conn.ReadFromUDP(c.buf)
if err != nil {
return nil, err
}
if string(addr.IP) != string(c.addr.IP) {
continue // skip messages from another IP
}
if n >= 16 && c.buf[0] == magic && c.buf[1] == waitMsg {
if waitMsg == msgPunchPkt {
c.addr.Port = addr.Port
}
return c.buf[:n], nil
}
}
func (c *Client) Version() string {
return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model)
}
func (c *Client) VideoStart(channel, quality, audio uint8) error {
buf := binary.BigEndian.AppendUint32(nil, cmdVideoStart)
if channel == 0 {
buf = fmt.Appendf(buf, `{"videoquality":%d,"enableaudio":%d}`, quality, audio)
func (c *Client) WriteCommand(data []byte) error {
data, err := crypto.Encode(data, c.key)
if err != nil {
return err
}
return c.Conn.WriteCommand(cmdEncoded, data)
}
const (
ModelDafang = "isa.camera.df3"
ModelLoockV2 = "loock.cateye.v02"
ModelC200 = "chuangmi.camera.046c04"
ModelC300 = "chuangmi.camera.72ac1"
)
func (c *Client) StartMedia(channel, quality, audio string) error {
switch c.model {
case ModelDafang:
var q, a byte
if quality == "sd" {
q = 1 // 0 - hd, 1 - sd, default - hd
}
if audio != "0" {
a = 1 // 0 - off, 1 - on, default - on
}
return errors.Join(
c.WriteCommand(dafangVideoQuality(q)),
c.WriteCommand(dafangVideoStart(1, a)),
)
}
// 0 - auto, 1 - sd, 2 - hd, default - hd
switch quality {
case "", "hd":
// Some models have broken codec settings in quality 3.
// Some models have low quality in quality 2.
// Different models require different default quality settings.
switch c.model {
case ModelC200, ModelC300:
quality = "3"
default:
quality = "2"
}
case "sd":
quality = "1"
case "auto":
quality = "0"
}
if audio == "" {
audio = "1"
}
data := binary.BigEndian.AppendUint32(nil, cmdVideoStart)
if channel == "" {
data = fmt.Appendf(data, `{"videoquality":%s,"enableaudio":%s}`, quality, audio)
} else {
buf = fmt.Appendf(buf, `{"videoquality":-1,"videoquality2":%d,"enableaudio":%d}`, quality, audio)
data = fmt.Appendf(data, `{"videoquality":-1,"videoquality2":%s,"enableaudio":%s}`, quality, audio)
}
buf, err := encode(c.key, buf)
if err != nil {
return err
}
buf = marshalCmd(0, c.chSeq0, cmdEncoded, buf)
c.chSeq0++
_, err = c.conn.WriteToUDP(buf, c.addr)
return err
return c.WriteCommand(data)
}
func (c *Client) SpeakerStart() error {
buf := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq)
buf, err := encode(c.key, buf)
if err != nil {
return err
}
buf = marshalCmd(0, c.chSeq0, cmdEncoded, buf)
c.chSeq0++
_, err = c.conn.WriteToUDP(buf, c.addr)
return err
func (c *Client) StopMedia() error {
data := binary.BigEndian.AppendUint32(nil, cmdVideoStop)
return c.WriteCommand(data)
}
func (c *Client) StartAudio() error {
data := binary.BigEndian.AppendUint32(nil, cmdAudioStart)
return c.WriteCommand(data)
}
func (c *Client) StartSpeaker() error {
data := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq)
return c.WriteCommand(data)
}
// SpeakerCodec if the camera model has a non-standard two-way codec.
func (c *Client) SpeakerCodec() uint32 {
switch c.model {
case ModelDafang, "isa.camera.hlc6":
return codecPCM
case "chuangmi.camera.72ac1":
return codecOPUS
}
return 0
}
const hdrSize = 32
func (c *Client) ReadPacket() (*Packet, error) {
b, ok := <-c.chRaw2
if !ok {
return nil, fmt.Errorf("miss: read raw: i/o timeout")
hdr, payload, err := c.Conn.ReadPacket()
if err != nil {
return nil, fmt.Errorf("miss: read media: %w", err)
}
return unmarshalPacket(c.key, b)
}
func unmarshalPacket(key, b []byte) (*Packet, error) {
n := uint32(len(b))
if n < 32 {
if len(hdr) < hdrSize {
return nil, fmt.Errorf("miss: packet header too small")
}
if l := binary.LittleEndian.Uint32(b); l+32 != n {
return nil, fmt.Errorf("miss: packet payload has wrong length")
}
payload, err := decode(key, b[32:])
payload, err = crypto.Decode(payload, c.key)
if err != nil {
return nil, err
}
return &Packet{
CodecID: binary.LittleEndian.Uint32(b[4:]),
Sequence: binary.LittleEndian.Uint32(b[8:]),
Flags: binary.LittleEndian.Uint32(b[12:]),
Timestamp: binary.LittleEndian.Uint64(b[16:]),
Payload: payload,
}, nil
pkt := &Packet{
CodecID: binary.LittleEndian.Uint32(hdr[4:]),
Sequence: binary.LittleEndian.Uint32(hdr[8:]),
Flags: binary.LittleEndian.Uint32(hdr[12:]),
Payload: payload,
}
switch c.model {
case ModelDafang, ModelLoockV2:
// Dafang has ts in sec
// LoockV2 has ts in msec for video, but zero ts for audio
pkt.Timestamp = uint64(time.Now().UnixMilli())
default:
pkt.Timestamp = binary.LittleEndian.Uint64(hdr[16:])
}
return pkt, nil
}
func (c *Client) WriteAudio(codecID uint32, payload []byte) error {
payload, err := encode(c.key, payload)
payload, err := crypto.Encode(payload, c.key) // new payload will have new size!
if err != nil {
return err
}
n := uint32(len(payload))
const hdrOffset = 12
const hdrSize = 32
buf := make([]byte, n+hdrOffset+hdrSize)
buf[0] = magic
buf[1] = msgDrw
binary.BigEndian.PutUint16(buf[2:], uint16(n+8+hdrSize))
buf[4] = magicDrw
buf[5] = 3 // channel
binary.BigEndian.PutUint16(buf[6:], c.chSeq3)
binary.BigEndian.PutUint32(buf[8:], n+hdrSize)
binary.LittleEndian.PutUint32(buf[hdrOffset:], n)
binary.LittleEndian.PutUint32(buf[hdrOffset+4:], codecID)
binary.LittleEndian.PutUint64(buf[hdrOffset+16:], uint64(time.Now().UnixMilli()))
copy(buf[hdrOffset+hdrSize:], payload)
c.chSeq3++
_, err = c.conn.WriteToUDP(buf, c.addr)
return err
}
func (c *Client) worker() {
defer close(c.chRaw2)
chAck := []uint16{1, 0, 0, 0}
var ch2WaitSize int
var ch2WaitData []byte
for {
n, addr, err := c.conn.ReadFromUDP(c.buf)
if err != nil {
return
}
//log.Printf("<- %.20x...", c.buf[:n])
if string(addr.IP) != string(c.addr.IP) || n < 8 || c.buf[0] != magic {
//log.Printf("unknown msg: %x", c.buf[:n])
continue // skip messages from another IP
}
switch c.buf[1] {
case msgDrw:
ch := c.buf[5]
seqHI := c.buf[6]
seqLO := c.buf[7]
if chAck[ch] != uint16(seqHI)<<8|uint16(seqLO) {
continue
}
chAck[ch]++
//log.Printf("%.40x", c.buf)
ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO}
if _, err = c.conn.WriteToUDP(ack, c.addr); err != nil {
return
}
switch ch {
case 0:
//log.Printf("data ch0 %x", c.buf[:n])
//size := binary.BigEndian.Uint32(c.buf[8:])
//if binary.BigEndian.Uint32(c.buf[12:]) == cmdEncoded {
// raw, _ := decode(c.key, c.buf[16:12+size])
// log.Printf("cmd enc %x", raw)
//} else {
// log.Printf("cmd raw %x", c.buf[12:12+size])
//}
case 2:
ch2WaitData = append(ch2WaitData, c.buf[8:n]...)
for len(ch2WaitData) > 4 {
if ch2WaitSize == 0 {
ch2WaitSize = int(binary.BigEndian.Uint32(ch2WaitData))
ch2WaitData = ch2WaitData[4:]
}
if ch2WaitSize <= len(ch2WaitData) {
c.chRaw2 <- ch2WaitData[:ch2WaitSize]
ch2WaitData = ch2WaitData[ch2WaitSize:]
ch2WaitSize = 0
} else {
break
}
}
default:
log.Printf("!!! unknown chanel: %x", c.buf[:n])
}
case msgDrwAck: // skip it
default:
log.Printf("!!! unknown msg type: %x", c.buf[:n])
}
}
}
func marshalCmd(channel byte, seq uint16, cmd uint32, payload []byte) []byte {
size := len(payload)
buf := make([]byte, 4+4+4+4+size)
// 1. message header (4 bytes)
buf[0] = magic
buf[1] = msgDrw
binary.BigEndian.PutUint16(buf[2:], uint16(4+4+4+size))
// 2. drw? header (4 bytes)
buf[4] = magicDrw
buf[5] = channel
binary.BigEndian.PutUint16(buf[6:], seq)
// 3. payload size (4 bytes)
binary.BigEndian.PutUint32(buf[8:], uint32(4+size))
// 4. payload command (4 bytes)
binary.BigEndian.PutUint32(buf[12:], cmd)
// 5. payload
copy(buf[16:], payload)
return buf
}
func calcSharedKey(devicePublic, clientPrivate string) ([]byte, error) {
var sharedKey, publicKey, privateKey [32]byte
if _, err := hex.Decode(publicKey[:], []byte(devicePublic)); err != nil {
return nil, err
}
if _, err := hex.Decode(privateKey[:], []byte(clientPrivate)); err != nil {
return nil, err
}
box.Precompute(&sharedKey, &publicKey, &privateKey)
return sharedKey[:], nil
}
func encode(key, src []byte) ([]byte, error) {
dst := make([]byte, len(src)+8)
if _, err := rand.Read(dst[:8]); err != nil {
return nil, err
}
nonce := make([]byte, 12)
copy(nonce[4:], dst[:8])
c, err := chacha20.NewUnauthenticatedCipher(key, nonce)
if err != nil {
return nil, err
}
c.XORKeyStream(dst[8:], src)
return dst, nil
}
func decode(key, src []byte) ([]byte, error) {
nonce := make([]byte, 12)
copy(nonce[4:], src[:8])
c, err := chacha20.NewUnauthenticatedCipher(key, nonce)
if err != nil {
return nil, err
}
dst := make([]byte, len(src)-8)
c.XORKeyStream(dst, src[8:])
return dst, nil
header := make([]byte, hdrSize)
binary.LittleEndian.PutUint32(header, n)
binary.LittleEndian.PutUint32(header[4:], codecID)
binary.LittleEndian.PutUint64(header[16:], uint64(time.Now().UnixMilli())) // not really necessary
return c.Conn.WritePacket(header, payload)
}
type Packet struct {
@@ -442,10 +277,40 @@ type Packet struct {
Payload []byte
}
func GenerateKey() ([]byte, []byte, error) {
public, private, err := box.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return public[:], private[:], err
func dafangRaw(cmd uint32, args ...byte) []byte {
payload := tutk.ICAM(cmd, args...)
data := make([]byte, 4+len(payload)*2)
copy(data, "\x7f\xff\xff\xff")
hex.Encode(data[4:], payload)
return data
}
// DafangVideoQuality 0 - hd, 1 - sd
func dafangVideoQuality(quality uint8) []byte {
return dafangRaw(0xff07d5, quality)
}
func dafangVideoStart(video, audio uint8) []byte {
return dafangRaw(0xff07d8, video, audio)
}
//func dafangLeft() []byte {
// return dafangRaw(0xff2404, 2, 0, 5)
//}
//
//func dafangRight() []byte {
// return dafangRaw(0xff2404, 1, 0, 5)
//}
//
//func dafangUp() []byte {
// return dafangRaw(0xff2404, 0, 2, 5)
//}
//
//func dafangDown() []byte {
// return dafangRaw(0xff2404, 0, 1, 5)
//}
//
//func dafangStop() []byte {
// return dafangRaw(0xff2404, 0, 0, 5)
//}
+505
View File
@@ -0,0 +1,505 @@
package cs2
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
)
func Dial(host, transport string) (*Conn, error) {
conn, err := handshake(host, transport)
if err != nil {
return nil, err
}
_, isTCP := conn.(*tcpConn)
c := &Conn{
Conn: conn,
isTCP: isTCP,
channels: [4]*dataChannel{
newDataChannel(0, 10), nil, newDataChannel(250, 100), nil,
},
}
go c.worker()
return c, nil
}
type Conn struct {
net.Conn
isTCP bool
err error
seqCh0 uint16
seqCh3 uint16
channels [4]*dataChannel
cmdMu sync.Mutex
cmdAck func()
}
const (
magic = 0xF1
magicDrw = 0xD1
magicTCP = 0x68
msgLanSearch = 0x30
msgPunchPkt = 0x41
msgP2PRdyUDP = 0x42
msgP2PRdyTCP = 0x43
msgDrw = 0xD0
msgDrwAck = 0xD1
msgPing = 0xE0
msgPong = 0xE1
msgClose = 0xF1
)
func handshake(host, transport string) (net.Conn, error) {
conn, err := newUDPConn(host, 32108)
if err != nil {
return nil, err
}
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
req := []byte{magic, msgLanSearch, 0, 0}
res, err := conn.(*udpConn).WriteUntil(req, func(res []byte) bool {
return res[1] == msgPunchPkt
})
if err != nil {
_ = conn.Close()
return nil, err
}
var msgUDP, msgTCP byte
if transport == "" || transport == "udp" {
msgUDP = msgP2PRdyUDP
}
if transport == "" || transport == "tcp" {
msgTCP = msgP2PRdyTCP
}
res, err = conn.(*udpConn).WriteUntil(res, func(res []byte) bool {
return res[1] == msgUDP || res[1] == msgTCP
})
if err != nil {
_ = conn.Close()
return nil, err
}
_ = conn.SetDeadline(time.Time{})
if res[1] == msgTCP {
_ = conn.Close()
//host := fmt.Sprintf("%d.%d.%d.%d:%d", b[31], b[30], b[29], b[28], uint16(b[27])<<8|uint16(b[26]))
return newTCPConn(conn.RemoteAddr().String())
}
return conn, nil
}
func (c *Conn) worker() {
defer func() {
c.channels[0].Close()
c.channels[2].Close()
}()
var keepaliveTS time.Time // only for TCP
buf := make([]byte, 1200)
for {
n, err := c.Conn.Read(buf)
if err != nil {
c.err = fmt.Errorf("%s: %w", "cs2", err)
return
}
// 0 f1d0 magic
// 2 005d size = total size + 4
// 4 d1 magic
// 5 00 channel
// 6 0000 seq
switch buf[1] {
case msgDrw:
ch := buf[5]
channel := c.channels[ch]
if c.isTCP {
// For TCP we should send ping every second to keep connection alive.
// Based on PCAP analysis: official Mi Home app sends PING every ~1s.
if now := time.Now(); now.After(keepaliveTS) {
_, _ = c.Conn.Write([]byte{magic, msgPing, 0, 0})
keepaliveTS = now.Add(time.Second)
}
err = channel.Push(buf[8:n])
} else {
var pushed int
seqHI, seqLO := buf[6], buf[7]
seq := uint16(seqHI)<<8 | uint16(seqLO)
pushed, err = channel.PushSeq(seq, buf[8:n])
if pushed >= 0 {
// For UDP we should send ACK.
ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO}
_, _ = c.Conn.Write(ack)
}
}
if err != nil {
c.err = fmt.Errorf("%s: %w", "cs2", err)
return
}
case msgPing:
_, _ = c.Conn.Write([]byte{magic, msgPong, 0, 0})
case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: // skip it
case msgDrwAck: // only for UDP
if c.cmdAck != nil {
c.cmdAck()
}
default:
fmt.Printf("%s: unknown msg: %x\n", "cs2", buf[:n])
}
}
}
func (c *Conn) Protocol() string {
if c.isTCP {
return "cs2+tcp"
}
return "cs2+udp"
}
func (c *Conn) Version() string {
return "CS2"
}
func (c *Conn) Error() error {
if c.err != nil {
return c.err
}
return io.EOF
}
func (c *Conn) ReadCommand() (cmd uint32, data []byte, err error) {
buf, ok := c.channels[0].Pop()
if !ok {
return 0, nil, c.Error()
}
cmd = binary.LittleEndian.Uint32(buf)
data = buf[4:]
return
}
func (c *Conn) WriteCommand(cmd uint32, data []byte) error {
c.cmdMu.Lock()
defer c.cmdMu.Unlock()
req := marshalCmd(0, c.seqCh0, cmd, data)
c.seqCh0++
if c.isTCP {
_, err := c.Conn.Write(req)
return err
}
var repeat atomic.Int32
repeat.Store(5)
timeout := time.NewTicker(time.Second)
defer timeout.Stop()
c.cmdAck = func() {
repeat.Store(0)
timeout.Reset(1)
}
for {
if _, err := c.Conn.Write(req); err != nil {
return err
}
<-timeout.C
r := repeat.Add(-1)
if r < 0 {
return nil
}
if r == 0 {
return fmt.Errorf("%s: can't send command %d", "cs2", cmd)
}
}
}
const hdrSize = 32
func (c *Conn) ReadPacket() (hdr, payload []byte, err error) {
data, ok := c.channels[2].Pop()
if !ok {
return nil, nil, c.Error()
}
return data[:hdrSize], data[hdrSize:], nil
}
func (c *Conn) WritePacket(hdr, payload []byte) error {
const offset = 12
n := hdrSize + uint32(len(payload))
req := make([]byte, n+offset)
req[0] = magic
req[1] = msgDrw
binary.BigEndian.PutUint16(req[2:], uint16(n+8))
req[4] = magicDrw
req[5] = 3 // channel
binary.BigEndian.PutUint16(req[6:], c.seqCh3)
c.seqCh3++
binary.BigEndian.PutUint32(req[8:], n)
copy(req[offset:], hdr)
copy(req[offset+hdrSize:], hdr)
_, err := c.Conn.Write(req)
return err
}
func marshalCmd(channel byte, seq uint16, cmd uint32, payload []byte) []byte {
size := len(payload)
req := make([]byte, 4+4+4+4+size)
// 1. message header (4 bytes)
req[0] = magic
req[1] = msgDrw
binary.BigEndian.PutUint16(req[2:], uint16(4+4+4+size))
// 2. drw? header (4 bytes)
req[4] = magicDrw
req[5] = channel
binary.BigEndian.PutUint16(req[6:], seq)
// 3. payload size (4 bytes)
binary.BigEndian.PutUint32(req[8:], uint32(4+size))
// 4. payload command (4 bytes)
binary.BigEndian.PutUint32(req[12:], cmd)
// 5. payload
copy(req[16:], payload)
return req
}
func newUDPConn(host string, port int) (net.Conn, error) {
// We using raw net.UDPConn, because RemoteAddr should be changed during handshake.
conn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
addr, err := net.ResolveUDPAddr("udp", host)
if err != nil {
addr = &net.UDPAddr{IP: net.ParseIP(host), Port: port}
}
return &udpConn{UDPConn: conn, addr: addr}, nil
}
type udpConn struct {
*net.UDPConn
addr *net.UDPAddr
}
func (c *udpConn) Read(b []byte) (n int, err error) {
var addr *net.UDPAddr
for {
n, addr, err = c.UDPConn.ReadFromUDP(b)
if err != nil {
return 0, err
}
if string(addr.IP) == string(c.addr.IP) || n >= 8 {
//log.Printf("<- %x", b[:n])
return
}
}
}
func (c *udpConn) Write(b []byte) (n int, err error) {
//log.Printf("-> %x", b)
return c.UDPConn.WriteToUDP(b, c.addr)
}
func (c *udpConn) RemoteAddr() net.Addr {
return c.addr
}
func (c *udpConn) WriteUntil(req []byte, ok func(res []byte) bool) ([]byte, error) {
var t *time.Timer
t = time.AfterFunc(1, func() {
if _, err := c.Write(req); err == nil && t != nil {
t.Reset(time.Second)
}
})
defer t.Stop()
buf := make([]byte, 1200)
for {
n, addr, err := c.UDPConn.ReadFromUDP(buf)
if err != nil {
return nil, err
}
if string(addr.IP) != string(c.addr.IP) || n < 16 {
continue // skip messages from another IP
}
if ok(buf[:n]) {
c.addr.Port = addr.Port
return buf[:n], nil
}
}
}
func newTCPConn(addr string) (net.Conn, error) {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return nil, err
}
return &tcpConn{conn.(*net.TCPConn), bufio.NewReader(conn)}, nil
}
type tcpConn struct {
*net.TCPConn
rd *bufio.Reader
}
func (c *tcpConn) Read(p []byte) (n int, err error) {
tmp := make([]byte, 8)
if _, err = io.ReadFull(c.rd, tmp); err != nil {
return
}
n = int(binary.BigEndian.Uint16(tmp))
if len(p) < n {
return 0, fmt.Errorf("tcp: buffer too small")
}
_, err = io.ReadFull(c.rd, p[:n])
//log.Printf("<- %x%x", tmp, p[:n])
return
}
func (c *tcpConn) Write(req []byte) (n int, err error) {
n = len(req)
buf := make([]byte, 8+n)
binary.BigEndian.PutUint16(buf, uint16(n))
buf[2] = magicTCP
copy(buf[8:], req)
//log.Printf("-> %x", buf)
_, err = c.TCPConn.Write(buf)
return
}
func newDataChannel(pushSize, popSize int) *dataChannel {
c := &dataChannel{}
if pushSize > 0 {
c.pushBuf = make(map[uint16][]byte, pushSize)
c.pushSize = pushSize
}
if popSize >= 0 {
c.popBuf = make(chan []byte, popSize)
}
return c
}
type dataChannel struct {
waitSeq uint16
pushBuf map[uint16][]byte
pushSize int
waitData []byte
waitSize int
popBuf chan []byte
}
func (c *dataChannel) Push(b []byte) error {
c.waitData = append(c.waitData, b...)
for len(c.waitData) > 4 {
// Every new data starts with size. There can be several data inside one packet.
if c.waitSize == 0 {
c.waitSize = int(binary.BigEndian.Uint32(c.waitData))
c.waitData = c.waitData[4:]
}
if c.waitSize > len(c.waitData) {
break
}
select {
case c.popBuf <- c.waitData[:c.waitSize]:
default:
return fmt.Errorf("pop buffer is full")
}
c.waitData = c.waitData[c.waitSize:]
c.waitSize = 0
}
return nil
}
func (c *dataChannel) Pop() ([]byte, bool) {
data, ok := <-c.popBuf
return data, ok
}
func (c *dataChannel) Close() {
close(c.popBuf)
}
// PushSeq returns how many seq were processed.
// Returns 0 if seq was saved or processed earlier.
// Returns -1 if seq could not be saved (buffer full or disabled).
func (c *dataChannel) PushSeq(seq uint16, data []byte) (int, error) {
diff := int16(seq - c.waitSeq)
// Check if this is seq from the future.
if diff > 0 {
// Support disabled buffer.
if c.pushSize == 0 {
return -1, nil // couldn't save seq
}
// Check if we don't have this seq in the buffer.
if c.pushBuf[seq] == nil {
// Check if there is enough space in the buffer.
if len(c.pushBuf) == c.pushSize {
return -1, nil // couldn't save seq
}
c.pushBuf[seq] = bytes.Clone(data)
//log.Printf("push buf wait=%d seq=%d len=%d", c.waitSeq, seq, len(c.pushBuf))
}
return 0, nil
}
// Check if this is seq from the past.
if diff < 0 {
return 0, nil
}
for i := 1; ; i++ {
if err := c.Push(data); err != nil {
return i, err
}
c.waitSeq++
// Check if we have next seq in the buffer.
if data = c.pushBuf[c.waitSeq]; data != nil {
delete(c.pushBuf, c.waitSeq)
} else {
return i, nil
}
}
}
+204
View File
@@ -0,0 +1,204 @@
package miss
import (
"fmt"
"net/url"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/AlexxIT/go2rtc/pkg/h264/annexb"
"github.com/AlexxIT/go2rtc/pkg/h265"
"github.com/pion/rtp"
)
type Producer struct {
core.Connection
client *Client
}
func Dial(rawURL string) (core.Producer, error) {
client, err := NewClient(rawURL)
if err != nil {
return nil, err
}
u, _ := url.Parse(rawURL)
query := u.Query()
err = client.StartMedia(query.Get("channel"), query.Get("subtype"), query.Get("audio"))
if err != nil {
_ = client.Close()
return nil, err
}
medias, err := probe(client, query.Get("audio") != "0")
if err != nil {
_ = client.Close()
return nil, err
}
return &Producer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "xiaomi/miss",
Protocol: client.Protocol(),
RemoteAddr: client.RemoteAddr().String(),
UserAgent: client.Version(),
Medias: medias,
Transport: client,
},
client: client,
}, nil
}
func probe(client *Client, audio bool) ([]*core.Media, error) {
_ = client.SetDeadline(time.Now().Add(15 * time.Second))
var vcodec, acodec *core.Codec
for {
pkt, err := client.ReadPacket()
if err != nil {
if vcodec != nil {
err = fmt.Errorf("no audio")
} else if acodec != nil {
err = fmt.Errorf("no video")
}
return nil, fmt.Errorf("xiaomi: probe: %w", err)
}
switch pkt.CodecID {
case codecH264:
if vcodec == nil {
buf := annexb.EncodeToAVCC(pkt.Payload)
if h264.NALUType(buf) == h264.NALUTypeSPS {
vcodec = h264.AVCCToCodec(buf)
}
}
case codecH265:
if vcodec == nil {
buf := annexb.EncodeToAVCC(pkt.Payload)
if h265.NALUType(buf) == h265.NALUTypeVPS {
vcodec = h265.AVCCToCodec(buf)
}
}
case codecPCMA:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000}
}
case codecOPUS:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2}
}
}
if vcodec != nil && (acodec != nil || !audio) {
break
}
}
_ = client.SetDeadline(time.Time{})
medias := []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{vcodec},
},
}
if acodec != nil {
medias = append(medias, &core.Media{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{acodec},
})
medias = append(medias, &core.Media{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{acodec.Clone()},
})
}
return medias, nil
}
const timestamp40ms = 48000 * 0.040
func (p *Producer) Start() error {
var audioTS uint32
for {
_ = p.client.SetDeadline(time.Now().Add(10 * time.Second))
pkt, err := p.client.ReadPacket()
if err != nil {
return err
}
p.Recv += len(pkt.Payload)
// TODO: rewrite this
var name string
var pkt2 *core.Packet
switch pkt.CodecID {
case codecH264, codecH265:
pkt2 = &core.Packet{
Header: rtp.Header{
SequenceNumber: uint16(pkt.Sequence),
Timestamp: TimeToRTP(pkt.Timestamp, 90000),
},
Payload: annexb.EncodeToAVCC(pkt.Payload),
}
if pkt.CodecID == codecH264 {
name = core.CodecH264
} else {
name = core.CodecH265
}
case codecPCMA:
name = core.CodecPCMA
pkt2 = &core.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
SequenceNumber: uint16(pkt.Sequence),
Timestamp: audioTS,
},
Payload: pkt.Payload,
}
audioTS += uint32(len(pkt.Payload))
case codecOPUS:
name = core.CodecOpus
pkt2 = &core.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
SequenceNumber: uint16(pkt.Sequence),
Timestamp: audioTS,
},
Payload: pkt.Payload,
}
// known cameras sends packets with 40ms long
audioTS += timestamp40ms
}
for _, recv := range p.Receivers {
if recv.Codec.Name == name {
recv.WriteRTP(pkt2)
break
}
}
}
}
func (p *Producer) Stop() error {
_ = p.client.StopMedia()
return p.Connection.Stop()
}
// TimeToRTP convert time in milliseconds to RTP time
func TimeToRTP(timeMS, clockRate uint64) uint32 {
return uint32(timeMS * clockRate / 1000)
}
+9 -194
View File
@@ -1,208 +1,23 @@
package xiaomi
import (
"fmt"
"net/url"
"time"
"strings"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/AlexxIT/go2rtc/pkg/h264/annexb"
"github.com/AlexxIT/go2rtc/pkg/h265"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/legacy"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
"github.com/pion/rtp"
)
type Producer struct {
core.Connection
client *miss.Client
model string
}
func Dial(rawURL string) (core.Producer, error) {
client, err := miss.Dial(rawURL)
if err != nil {
return nil, err
// Format: xiaomi/miss
if strings.Contains(rawURL, "vendor") {
return miss.Dial(rawURL)
}
u, _ := url.Parse(rawURL)
query := u.Query()
// 0 - main, 1 - second
channel := core.ParseByte(query.Get("channel"))
// 0 - auto, 1 - worst, 3 or 5 - best
var quality byte
switch s := query.Get("subtype"); s {
case "", "hd":
quality = 3
case "sd":
quality = 1
case "auto":
quality = 0
default:
quality = core.ParseByte(s)
}
medias, err := probe(client, channel, quality)
if err != nil {
_ = client.Close()
return nil, err
}
return &Producer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "xiaomi",
Protocol: "cs2+udp",
RemoteAddr: client.RemoteAddr().String(),
Source: rawURL,
Medias: medias,
Transport: client,
},
client: client,
model: query.Get("model"),
}, nil
// Format: xiaomi/legacy
return legacy.Dial(rawURL)
}
func probe(client *miss.Client, channel, quality uint8) ([]*core.Media, error) {
_ = client.SetDeadline(time.Now().Add(core.ProbeTimeout))
if err := client.VideoStart(channel, quality, 1); err != nil {
return nil, err
}
var video, audio *core.Codec
for {
pkt, err := client.ReadPacket()
if err != nil {
return nil, fmt.Errorf("xiaomi: probe: %w", err)
}
switch pkt.CodecID {
case miss.CodecH264:
if video == nil {
buf := annexb.EncodeToAVCC(pkt.Payload)
if h264.NALUType(buf) == h264.NALUTypeSPS {
video = h264.AVCCToCodec(buf)
}
}
case miss.CodecH265:
if video == nil {
buf := annexb.EncodeToAVCC(pkt.Payload)
if h265.NALUType(buf) == h265.NALUTypeVPS {
video = h265.AVCCToCodec(buf)
}
}
case miss.CodecPCMA:
if audio == nil {
audio = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000}
}
case miss.CodecOPUS:
if audio == nil {
audio = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2}
}
}
if video != nil && audio != nil {
break
}
}
_ = client.SetDeadline(time.Time{})
return []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{video},
},
{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{audio},
},
{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{audio.Clone()},
},
}, nil
}
const timestamp40ms = 48000 * 0.040
func (p *Producer) Start() error {
var audioTS uint32
for {
_ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline))
pkt, err := p.client.ReadPacket()
if err != nil {
return err
}
// TODO: rewrite this
var name string
var pkt2 *core.Packet
switch pkt.CodecID {
case miss.CodecH264:
name = core.CodecH264
pkt2 = &core.Packet{
Header: rtp.Header{
SequenceNumber: uint16(pkt.Sequence),
Timestamp: TimeToRTP(pkt.Timestamp, 90000),
},
Payload: annexb.EncodeToAVCC(pkt.Payload),
}
case miss.CodecH265:
name = core.CodecH265
pkt2 = &core.Packet{
Header: rtp.Header{
SequenceNumber: uint16(pkt.Sequence),
Timestamp: TimeToRTP(pkt.Timestamp, 90000),
},
Payload: annexb.EncodeToAVCC(pkt.Payload),
}
case miss.CodecPCMA:
name = core.CodecPCMA
pkt2 = &core.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
SequenceNumber: uint16(pkt.Sequence),
Timestamp: audioTS,
},
Payload: pkt.Payload,
}
audioTS += uint32(len(pkt.Payload))
case miss.CodecOPUS:
name = core.CodecOpus
pkt2 = &core.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
SequenceNumber: uint16(pkt.Sequence),
Timestamp: audioTS,
},
Payload: pkt.Payload,
}
// known cameras sends packets with 40ms long
audioTS += timestamp40ms
}
for _, recv := range p.Receivers {
if recv.Codec.Name == name {
recv.WriteRTP(pkt2)
break
}
}
}
}
// TimeToRTP convert time in milliseconds to RTP time
func TimeToRTP(timeMS, clockRate uint64) uint32 {
return uint32(timeMS * clockRate / 1000)
func IsLegacy(model string) bool {
return legacy.Supported(model)
}
+16 -177
View File
@@ -1,189 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>go2rtc - WebTorrent</title>
<meta charset="UTF-8">
<title>go2rtc</title>
<meta http-equiv="refresh" content="2; URL='https://github.com/AlexxIT/go2rtc'"/>
<style>
body {
background-color: black;
margin: 0;
padding: 0;
}
html, body, video {
body, html {
height: 100%;
width: 100%;
}
div {
position: absolute;
top: 50%;
left: 50%;
margin: 0;
display: flex;
flex-direction: column;
transform: translateX(-50%) translateY(-50%);
justify-content: center;
align-items: center;
background-color: white;
}
img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<video id="video" autoplay controls playsinline muted></video>
<div id="login">
<input id="share" type="text" placeholder="share">
<input id="pwd" type="text" placeholder="password">
<button id="connect">connect</button>
</div>
<script>
async function PeerConnection(media) {
const pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
})
const localTracks = []
if (/camera|microphone/.test(media)) {
const tracks = await getMediaTracks('user', {
video: media.indexOf('camera') >= 0,
audio: media.indexOf('microphone') >= 0,
})
tracks.forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'})
if (track.kind === 'video') localTracks.push(track)
})
}
if (media.indexOf('display') >= 0) {
const tracks = await getMediaTracks('display', {
video: true,
audio: media.indexOf('speaker') >= 0,
})
tracks.forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'})
if (track.kind === 'video') localTracks.push(track)
})
}
if (/video|audio/.test(media)) {
const tracks = ['video', 'audio']
.filter(kind => media.indexOf(kind) >= 0)
.map(kind => pc.addTransceiver(kind, {direction: 'recvonly'}).receiver.track)
localTracks.push(...tracks)
}
document.getElementById('video').srcObject = new MediaStream(localTracks)
return pc
}
async function getMediaTracks(media, constraints) {
try {
const stream = media === 'user'
? await navigator.mediaDevices.getUserMedia(constraints)
: await navigator.mediaDevices.getDisplayMedia(constraints)
return stream.getTracks()
} catch (e) {
console.warn(e)
return []
}
}
function getOffer(pc, timeout) {
return new Promise((resolve, reject) => {
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp)
})
pc.createOffer().then(offer => pc.setLocalDescription(offer))
setTimeout(() => resolve(pc.localDescription.sdp), timeout || 5000)
})
}
</script>
<script>
function decode(buffer) {
return String.fromCharCode(...new Uint8Array(buffer))
}
function encode(string) {
return Uint8Array.from(string, c => c.charCodeAt(0))
}
async function cipher(share, pwd) {
const hash = await crypto.subtle.digest('SHA-256', encode(share))
const nonce = (Date.now() * 1000000).toString(36)
const ivData = await crypto.subtle.digest('SHA-256', encode(share + ':' + nonce))
const keyData = await crypto.subtle.digest('SHA-256', encode(nonce + ':' + pwd))
const key = await crypto.subtle.importKey(
'raw', keyData, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt'],
)
return {
hash: btoa(decode(hash)),
nonce: nonce,
encrypt: async function (plaintext) {
const cryptotext = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: ivData.slice(0, 12), additionalData: encode(nonce)},
key, encode(plaintext),
)
return btoa(decode(cryptotext))
},
decrypt: async function (cryptotext) {
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: ivData.slice(0, 12), additionalData: encode(nonce)},
key, encode(atob(cryptotext)),
)
return decode(plaintext)
}
}
}
</script>
<script>
async function connect(share, pwd, media, tracker) {
const crypto = await cipher(share, pwd)
const pc = await PeerConnection(media || 'video+audio')
const offer = await crypto.encrypt(await getOffer(pc))
const ws = new WebSocket(tracker || 'wss://tracker.openwebtorrent.com/')
ws.addEventListener('open', () => {
ws.send(JSON.stringify({
action: 'announce',
info_hash: crypto.hash,
peer_id: Math.random().toString(36).substring(2),
offers: [{
offer_id: crypto.nonce,
offer: {type: 'offer', sdp: offer},
}],
numwant: 1,
}))
})
ws.addEventListener('message', async (ev) => {
const msg = JSON.parse(ev.data)
if (!msg.answer) return
const answer = await crypto.decrypt(msg.answer.sdp)
await pc.setRemoteDescription({type: 'answer', sdp: answer})
ws.close()
})
}
document.getElementById('connect').addEventListener('click', () => {
const share = document.getElementById('share').value
const pwd = document.getElementById('pwd').value
connect(share, pwd)
document.getElementById('login').style.display = 'none'
})
if (location.hash) {
const params = new URLSearchParams(location.hash.substring(1))
const share = params.get('share')
const pwd = params.get('pwd')
const media = params.get('media')
const tracker = params.get('tr')
connect(share, pwd, media, tracker)
document.getElementById('login').style.display = 'none'
}
</script>
<img src="https://raw.githubusercontent.com/AlexxIT/go2rtc/master/assets/logo.gif" alt="go2rtc">
<a href="https://github.com/AlexxIT/go2rtc">github.com/AlexxIT/go2rtc</a>
</body>
</html>
</html>
+189
View File
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>webtorrent - go2rtc</title>
<style>
body {
background-color: black;
margin: 0;
padding: 0;
}
html, body, video {
height: 100%;
width: 100%;
}
div {
position: absolute;
top: 50%;
left: 50%;
display: flex;
flex-direction: column;
transform: translateX(-50%) translateY(-50%);
}
</style>
</head>
<body>
<video id="video" autoplay controls playsinline muted></video>
<div id="login">
<input id="share" type="text" placeholder="share">
<input id="pwd" type="text" placeholder="password">
<button id="connect">connect</button>
</div>
<script>
async function PeerConnection(media) {
const pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
});
const localTracks = [];
if (/camera|microphone/.test(media)) {
const tracks = await getMediaTracks('user', {
video: media.indexOf('camera') >= 0,
audio: media.indexOf('microphone') >= 0,
});
tracks.forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'});
if (track.kind === 'video') localTracks.push(track);
});
}
if (media.indexOf('display') >= 0) {
const tracks = await getMediaTracks('display', {
video: true,
audio: media.indexOf('speaker') >= 0,
});
tracks.forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'});
if (track.kind === 'video') localTracks.push(track);
});
}
if (/video|audio/.test(media)) {
const tracks = ['video', 'audio']
.filter(kind => media.indexOf(kind) >= 0)
.map(kind => pc.addTransceiver(kind, {direction: 'recvonly'}).receiver.track);
localTracks.push(...tracks);
}
document.getElementById('video').srcObject = new MediaStream(localTracks);
return pc;
}
async function getMediaTracks(media, constraints) {
try {
const stream = media === 'user'
? await navigator.mediaDevices.getUserMedia(constraints)
: await navigator.mediaDevices.getDisplayMedia(constraints);
return stream.getTracks();
} catch (e) {
console.warn(e);
return [];
}
}
function getOffer(pc, timeout) {
return new Promise((resolve, reject) => {
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp);
});
pc.createOffer().then(offer => pc.setLocalDescription(offer));
setTimeout(() => resolve(pc.localDescription.sdp), timeout || 5000);
});
}
</script>
<script>
function decode(buffer) {
return String.fromCharCode(...new Uint8Array(buffer));
}
function encode(string) {
return Uint8Array.from(string, c => c.charCodeAt(0));
}
async function cipher(share, pwd) {
const hash = await crypto.subtle.digest('SHA-256', encode(share));
const nonce = (Date.now() * 1000000).toString(36);
const ivData = await crypto.subtle.digest('SHA-256', encode(share + ':' + nonce));
const keyData = await crypto.subtle.digest('SHA-256', encode(nonce + ':' + pwd));
const key = await crypto.subtle.importKey(
'raw', keyData, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt'],
);
return {
hash: btoa(decode(hash)),
nonce: nonce,
encrypt: async function (plaintext) {
const cryptotext = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: ivData.slice(0, 12), additionalData: encode(nonce)},
key, encode(plaintext),
);
return btoa(decode(cryptotext));
},
decrypt: async function (cryptotext) {
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: ivData.slice(0, 12), additionalData: encode(nonce)},
key, encode(atob(cryptotext)),
);
return decode(plaintext);
}
};
}
</script>
<script>
async function connect(share, pwd, media, tracker) {
const crypto = await cipher(share, pwd);
const pc = await PeerConnection(media || 'video+audio');
const offer = await crypto.encrypt(await getOffer(pc));
const ws = new WebSocket(tracker || 'wss://tracker.openwebtorrent.com/');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({
action: 'announce',
info_hash: crypto.hash,
peer_id: Math.random().toString(36).substring(2),
offers: [{
offer_id: crypto.nonce,
offer: {type: 'offer', sdp: offer},
}],
numwant: 1,
}));
});
ws.addEventListener('message', async (ev) => {
const msg = JSON.parse(ev.data);
if (!msg.answer) return;
const answer = await crypto.decrypt(msg.answer.sdp);
await pc.setRemoteDescription({type: 'answer', sdp: answer});
ws.close();
});
}
document.getElementById('connect').addEventListener('click', () => {
const share = document.getElementById('share').value;
const pwd = document.getElementById('pwd').value;
connect(share, pwd);
document.getElementById('login').style.display = 'none';
});
if (location.hash) {
const params = new URLSearchParams(location.hash.substring(1));
const share = params.get('share');
const pwd = params.get('pwd');
const media = params.get('media');
const tracker = params.get('tr');
connect(share, pwd, media, tracker);
document.getElementById('login').style.display = 'none';
}
</script>
</body>
</html>
+3 -3
View File
@@ -81,11 +81,11 @@ https://webrtc.org/getting-started/unified-plan-transition-guide?hl=en
```html
<!-- iOS Safari -->
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
<link rel="apple-touch-icon" href="https://go2rtc.org/icons/apple-touch-icon-180x180.png" sizes="180x180">
<!-- Classic, desktop browsers -->
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
<link rel="icon" href="https://go2rtc.org/icons/favicon.ico">
<!-- Android Chrome -->
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
<link rel="manifest" href="https://go2rtc.org/manifest.json">
```
## Useful links
+58
View File
@@ -413,6 +413,64 @@
</script>
<button id="wyze">Wyze</button>
<div>
<p style="margin: 5px 0; font-size: 12px; color: #888;">
API Key required: <a href="https://support.wyze.com/hc/en-us/articles/16129834216731" target="_blank">Get your API Key</a>
</p>
<form id="wyze-login-form">
<input type="text" name="api_id" placeholder="API ID" required size="20">
<input type="text" name="api_key" placeholder="API Key" required size="36">
<input type="email" name="email" placeholder="email" required>
<input type="password" name="password" placeholder="password" required>
<button type="submit">login</button>
</form>
<form id="wyze-devices-form">
<select id="wyze-id" name="id" required></select>
<button type="submit">load devices</button>
</form>
<table id="wyze-table"></table>
</div>
<script>
async function wyzeReload(ev) {
if (ev) ev.target.nextElementSibling.style.display = 'grid';
const r = await fetch('api/wyze', {'cache': 'no-cache'});
const data = await r.json();
const users = document.getElementById('wyze-id');
users.innerHTML = data.map(item => `<option value="${item}">${item}</option>`).join('');
}
document.getElementById('wyze').addEventListener('click', wyzeReload);
document.getElementById('wyze-login-form').addEventListener('submit', async ev => {
ev.preventDefault();
const table = document.getElementById('wyze-table');
table.innerText = 'loading...';
const params = new URLSearchParams(new FormData(ev.target));
const r = await fetch('api/wyze', {method: 'POST', body: params});
if (!r.ok) {
table.innerText = (await r.text()) || 'Unknown error';
return;
}
const data = await r.json();
table.innerText = '';
drawTable(table, data);
wyzeReload();
});
document.getElementById('wyze-devices-form').addEventListener('submit', async ev => {
ev.preventDefault();
const params = new URLSearchParams(new FormData(ev.target));
await getSources('wyze-table', 'api/wyze?' + params.toString());
});
</script>
<button id="xiaomi">Xiaomi</button>
<div>
<form id="xiaomi-login-form">
-65
View File
@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>codecs - go2rtc</title>
<style>
body {
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<div id="out"></div>
<script>
const out = document.getElementById('out');
const print = (name, caps) => {
out.innerText += name + '\n';
caps.codecs.forEach((codec) => {
out.innerText += [codec.mimeType, codec.channels, codec.clockRate, codec.sdpFmtpLine] + '\n';
});
out.innerText += '\n';
};
if (RTCRtpReceiver.getCapabilities) {
print('receiver video', RTCRtpReceiver.getCapabilities('video'));
print('receiver audio', RTCRtpReceiver.getCapabilities('audio'));
print('sender video', RTCRtpSender.getCapabilities('video'));
print('sender audio', RTCRtpSender.getCapabilities('audio'));
}
const types = [
'video/mp4; codecs="avc1.42401E"',
'video/mp4; codecs="avc1.42C01E"',
'video/mp4; codecs="avc1.42E01E"',
'video/mp4; codecs="avc1.42001E"',
'video/mp4; codecs="avc1.4D401E"',
'video/mp4; codecs="avc1.4D001E"',
'video/mp4; codecs="avc1.640032"',
'video/mp4; codecs="avc1.640C32"',
'video/mp4; codecs="avc1.F4001F"',
'video/mp4; codecs="hvc1.1.6.L93.B0"',
'video/mp4; codecs="hev1.1.6.L93.B0"',
'video/mp4; codecs="hev1.2.4.L120.B0"',
'video/mp4; codecs="flac"',
'video/mp4; codecs="opus"',
'video/mp4; codecs="mp3"',
'video/mp4; codecs="null"',
'application/vnd.apple.mpegurl',
];
const video = document.createElement('video');
out.innerText += 'video.canPlayType\n';
types.forEach(type => {
out.innerText += `${type} = ${'MediaSource' in window && MediaSource.isTypeSupported(type)} / ${video.canPlayType(type)}\n`;
});
</script>
</body>
</html>
+1198 -35
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -87,7 +87,7 @@
if (data.setup_code === undefined) return;
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js';
script.src = 'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js';
script.async = true;
script.onload = () => {
/* global BigInt */
@@ -186,7 +186,7 @@ Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
const share = document.getElementById('shareget');
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
share.href = `https://go2rtc.org/webtorrent/#${share.dataset.auth}&media=${media}`;
}
function share(method) {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>net - go2rtc</title>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js"></script>
<style>
html, body, #network {
height: 100%;
+326 -63
View File
@@ -24,7 +24,26 @@
"debug",
"info",
"warn",
"error"
"error",
"fatal",
"panic",
"disabled"
]
},
"source": {
"type": "string",
"examples": [
"rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif",
"rtsp://username:password@192.168.1.123/stream1",
"rtsp://username:password@192.168.1.123/h264Preview_01_main",
"rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password",
"http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password",
"http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1",
"ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy",
"ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M",
"exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}",
"onvif://username:password@192.168.1.123:80?subtype=0",
"tapo://password@192.168.1.123:8800?channel=0&subtype=0"
]
}
},
@@ -33,13 +52,14 @@
"type": "object",
"properties": {
"listen": {
"type": "string",
"default": ":1984",
"examples": [
"127.0.0.1:8080"
],
"$ref": "#/definitions/listen"
"127.0.0.1:1984"
]
},
"username": {
"description": "Basic auth for WebUI",
"type": "string",
"examples": [
"admin"
@@ -48,24 +68,35 @@
"password": {
"type": "string"
},
"local_auth": {
"description": "Enable auth check for localhost requests",
"type": "boolean",
"default": false
},
"base_path": {
"description": "API prefix for serving on suburl (/api => /rtc/api)",
"type": "string",
"examples": [
"/go2rtc"
"/rtc"
]
},
"static_dir": {
"description": "Folder for static files (custom web interface)",
"type": "string",
"examples": [
"/var/www"
"www"
]
},
"origin": {
"description": "Allow CORS requests (only * supported)",
"type": "string",
"const": "*"
"enum": [
"*",
""
]
},
"tls_listen": {
"$ref": "#/definitions/listen"
"type": "string"
},
"tls_cert": {
"type": "string",
@@ -86,6 +117,111 @@
"examples": [
"/tmp/go2rtc.sock"
]
},
"allow_paths": {
"description": "Allow only these HTTP paths (full paths, including base_path)",
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"/api",
"/api/streams",
"/api/webrtc"
]
]
}
}
},
"app": {
"type": "object",
"properties": {
"modules": {
"description": "Enable only these modules (empty / omitted means all)",
"type": "array",
"items": {
"type": "string",
"enum": [
"api",
"ws",
"http",
"rtsp",
"webrtc",
"mp4",
"hls",
"mjpeg",
"hass",
"homekit",
"onvif",
"rtmp",
"webtorrent",
"wyoming",
"echo",
"exec",
"expr",
"ffmpeg",
"alsa",
"v4l2",
"bubble",
"doorbird",
"dvrip",
"eseecloud",
"flussonic",
"gopro",
"isapi",
"ivideon",
"mpegts",
"nest",
"ring",
"roborock",
"tapo",
"tuya",
"xiaomi",
"yandex",
"debug",
"ngrok",
"pinggy",
"srtp"
]
}
}
}
},
"env": {
"description": "Config variables that can be referenced as ${NAME} / ${NAME:default}",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"echo": {
"type": "object",
"properties": {
"allow_paths": {
"description": "Allow only these binaries for echo: URLs (exact cmd name/path)",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"exec": {
"type": "object",
"properties": {
"allow_paths": {
"description": "Allow only these binaries for exec: URLs (exact cmd name/path)",
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"ffmpeg",
"/usr/bin/ffmpeg"
]
]
}
}
},
@@ -95,6 +231,26 @@
"bin": {
"type": "string",
"default": "ffmpeg"
},
"global": {
"type": "string",
"default": "-hide_banner"
},
"file": {
"type": "string",
"default": "-re -i {input}"
},
"http": {
"type": "string",
"default": "-fflags nobuffer -flags low_delay -i {input}"
},
"rtsp": {
"type": "string",
"default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}"
},
"rtsp/udp": {
"type": "string",
"default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}"
}
},
"additionalProperties": {
@@ -117,12 +273,25 @@
"homekit": {
"type": "object",
"additionalProperties": {
"type": "object",
"type": [
"object",
"null"
],
"properties": {
"pin": {
"description": "HomeKit pairing PIN",
"type": "string",
"default": "19550224",
"pattern": "^[0-9]{8}$"
"anyOf": [
{
"type": "string",
"pattern": "^[0-9]{8}$"
},
{
"type": "string",
"pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{3}$"
}
]
},
"name": {
"type": "string"
@@ -133,6 +302,29 @@
"device_private": {
"type": "string"
},
"category_id": {
"description": "Accessory category: `bridge`, `doorbell` or numeric ID",
"type": "string",
"default": "camera",
"anyOf": [
{
"type": "string",
"enum": [
"bridge",
"camera",
"doorbell"
]
},
{
"type": "string",
"pattern": "^[0-9]+$"
},
{
"type": "string",
"const": ""
}
]
},
"pairings": {
"type": "array",
"items": {
@@ -146,9 +338,11 @@
"type": "object",
"properties": {
"format": {
"description": "Log format: color/json/text or empty for autodetect",
"type": "string",
"default": "color",
"enum": [
"",
"color",
"json",
"text"
@@ -160,12 +354,26 @@
"$ref": "#/definitions/log_level"
},
"output": {
"description": "Log output: stdout/stderr/file[:path] or empty (memory only)",
"type": "string",
"default": "stdout",
"enum": [
"",
"stdout",
"stderr"
"anyOf": [
{
"type": "string",
"enum": [
"",
"stdout",
"stderr"
]
},
{
"type": "string",
"pattern": "^file(:.+)?$",
"examples": [
"file",
"file:go2rtc.log"
]
}
]
},
"time": {
@@ -215,6 +423,9 @@
"homekit": {
"$ref": "#/definitions/log_level"
},
"mjpeg": {
"$ref": "#/definitions/log_level"
},
"mp4": {
"$ref": "#/definitions/log_level"
},
@@ -238,6 +449,9 @@
},
"webtorrent": {
"$ref": "#/definitions/log_level"
},
"wyoming": {
"$ref": "#/definitions/log_level"
}
}
},
@@ -253,6 +467,30 @@
}
}
},
"pinggy": {
"type": "object",
"properties": {
"tunnel": {
"description": "Expose local address via Pinggy",
"type": "string",
"examples": [
"http://127.0.0.1:1984",
"tcp://192.168.1.123:554"
]
}
}
},
"preload": {
"description": "Preload streams on startup (map stream name => probe query, default `video&audio`)",
"type": "object",
"additionalProperties": {
"type": "string",
"examples": [
"video&audio",
"video"
]
}
},
"publish": {
"type": "object",
"additionalProperties": {
@@ -277,10 +515,10 @@
"type": "object",
"properties": {
"listen": {
"type": "string",
"examples": [
":1935"
],
"$ref": "#/definitions/listen"
]
}
}
},
@@ -288,8 +526,8 @@
"type": "object",
"properties": {
"listen": {
"default": ":8554",
"$ref": "#/definitions/listen"
"type": "string",
"default": ":8554"
},
"username": {
"type": "string",
@@ -314,75 +552,56 @@
"type": "object",
"properties": {
"listen": {
"default": ":8443",
"$ref": "#/definitions/listen"
"type": "string",
"default": ":8443"
}
}
},
"streams": {
"type": "object",
"additionalProperties": {
"title": "Stream",
"anyOf": [
{
"description": "Source",
"type": "string",
"examples": [
"rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif",
"rtsp://username:password@192.168.1.123/stream1",
"rtsp://username:password@192.168.1.123/h264Preview_01_main",
"rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password",
"http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password",
"http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1",
"ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy",
"ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M",
"bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0",
"dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0",
"exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}",
"isapi://username:password@192.168.1.123:80/",
"kasa://username:password@192.168.1.123:19443/https/stream/mixed",
"onvif://username:password@192.168.1.123:80?subtype=0",
"tapo://password@192.168.1.123:8800?channel=0&subtype=0",
"webtorrent:?share=xxx&pwd=xxx"
]
"$ref": "#/definitions/source"
},
{
"type": "array",
"items": {
"description": "Source",
"type": "string"
"$ref": "#/definitions/source"
}
},
{
"type": "null"
}
]
}
},
"xiaomi": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"webrtc": {
"type": "object",
"properties": {
"listen": {
"default": ":8555/tcp",
"type": "string",
"anyOf": [
{
"type": "string",
"pattern": ":[0-9]{1,5}(/tcp|/udp)?$"
},
{
"type": "string",
"const": ""
}
"default": ":8555",
"examples": [
":8555/udp"
]
},
"candidates": {
"type": "array",
"items": {
"$ref": "#/definitions/listen/anyOf/0"
},
"examples": [
"216.58.210.174:8555",
"stun:8555",
"home.duckdns.org:8555"
]
"type": "string",
"examples": [
"216.58.210.174:8555",
"stun:8555",
"home.duckdns.org:8555"
]
}
},
"ice_servers": {
"type": "array",
@@ -436,13 +655,13 @@
"description": "Use only these network types",
"type": "array",
"items": {
"type": "string",
"enum": [
"tcp4",
"tcp6",
"udp4",
"udp6"
],
"type": "string"
]
}
},
"udp_ports": {
@@ -472,7 +691,8 @@
"type": "object",
"properties": {
"pwd": {
"type": "string"
"type": "string",
"minLength": 4
},
"src": {
"type": "string"
@@ -481,6 +701,49 @@
}
}
}
},
"wyoming": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"listen": {
"description": "Listen address for Wyoming server",
"type": "string"
},
"name": {
"description": "Optional satellite name (default: stream name)",
"type": "string"
},
"mode": {
"description": "Optional mode: mic / snd / default",
"type": "string",
"enum": [
"",
"mic",
"snd"
]
},
"event": {
"description": "Event handlers (map event type => expr script)",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"wake_uri": {
"description": "Optional WAKE service URI (ex. tcp://host:port?name=...)",
"type": "string",
"examples": [
"tcp://192.168.1.23:10400"
]
},
"vad_threshold": {
"description": "Optional VAD threshold (0.1..3.5 typical)",
"type": "number"
}
}
}
}
}
}
+1
View File
@@ -4,4 +4,5 @@ import "embed"
//go:embed *.html
//go:embed *.js
//go:embed *.json
var Static embed.FS
+16 -1
View File
@@ -249,7 +249,22 @@ export class VideoRTC extends HTMLElement {
this.appendChild(this.video);
this.video.addEventListener('error', ev => {
console.warn(ev);
const err = this.video.error;
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
const MEDIA_ERRORS = {
1: 'MEDIA_ERR_ABORTED',
2: 'MEDIA_ERR_NETWORK',
3: 'MEDIA_ERR_DECODE',
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED'
};
console.error('[VideoRTC] Video error:', {
error: err ? MEDIA_ERRORS[err.code] : 'unknown',
message: err ? err.message : 'unknown',
codecs: this.mseCodecs || 'not set',
readyState: this.video.readyState,
networkState: this.video.networkState,
currentTime: this.video.currentTime
});
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
});