diff --git a/README.md b/README.md index f27afeb0..b96c9a8e 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,12 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4), [HomeKit](#module-homekit) [HLS](#module-hls) or [MJPEG](#module-mjpeg) - [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram, etc.) - first project in the World with support streaming from [HomeKit Cameras](#source-homekit) -- support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5)) - on-the-fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) - play audio files and live streams on some cameras with [speaker](#stream-to-camera) -- multi-source 2-way [codecs negotiation](#codecs-negotiation) +- multi-source two-way [codecs negotiation](#codecs-negotiation) - mixing tracks from different sources to single stream - auto-match client-supported codecs - - [2-way audio](#two-way-audio) for some cameras + - [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) **Inspired by:** @@ -67,6 +66,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [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) @@ -74,6 +74,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [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) @@ -203,15 +204,18 @@ Available source types: - [homekit](#source-homekit) - streaming from HomeKit Camera - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR +- [eseecloud](#source-eseecloud) - streaming from ESeeCloud/dvr163 NVR - [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support - [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support - [tuya](#source-tuya) - Tuya cameras with [two way audio](#two-way-audio) support +- [xiaomi](#source-xiaomi) - Xiaomi cameras with [two way audio](#two-way-audio) support - [kasa](#source-tapo) - TP-Link Kasa cameras - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service - [hass](#source-hass) - Home Assistant integration - [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras - [roborock](#source-roborock) - Roborock vacuums with cameras +- [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 @@ -226,9 +230,11 @@ Supported sources: - [TP-Link Tapo](#source-tapo) cameras - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras +- [Doorbird](#source-doorbird) cameras - [Exec](#source-exec) audio on server - [Ring](#source-ring) cameras - [Tuya](#source-tuya) cameras +- [Xiaomi](#source-xiaomi) cameras - [Any Browser](#incoming-browser) as IP-camera Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). @@ -532,6 +538,15 @@ streams: - dvrip://username:password@192.168.1.123:34567?backchannel=1 ``` +#### Source: EseeCloud + +*[New in v1.9.10](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10)* + +```yaml +streams: + camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12 +``` + #### Source: Tapo *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -580,40 +595,15 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -**Tuya Smart API (recommended)**: -- Cameras can be discovered through the go2rtc web interface via Tuya Smart account (Add > Tuya > Select region and fill in email and password > Login). -- **Smart Life accounts are not supported**, you need to create a Tuya Smart account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the Tuya Smart app. +[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 Cloud API**: -- Requires setting up a cloud project in the Tuya Developer Platform. -- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). -- Please ensure that you have subscribed to the `IoT Video Live Stream` service (Free Trial) in the Tuya Developer Platform, otherwise the stream will not work (Tuya Developer Platform > Service API > Authorize > IoT Video Live Stream). +#### Source: Xiaomi -**Configuring the stream:** -- Use `resolution` parameter to select the stream (not all cameras support `hd` stream through WebRTC even if the camera has it): - - `hd` - HD stream (default) - - `sd` - SD stream +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -```yaml -streams: - # Tuya Smart API: WebRTC main stream (use Add > Tuya to discover the URL) - tuya_main: - - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX - - # Tuya Smart API: WebRTC sub stream (use Add > Tuya to discover the URL) - tuya_sub: - - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd - - # Tuya Cloud API: WebRTC main stream - tuya_webrtc: - - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX - - # Tuya Cloud API: WebRTC sub stream - tuya_webrtc_sd: - - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd -``` +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). #### Source: GoPro @@ -716,6 +706,21 @@ 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 + +*[New in v1.9.11](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* + +This source type supports Doorbird devices including MJPEG stream, audio stream as well as two-way audio. + +```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 *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -1312,25 +1317,22 @@ Some examples: ## Codecs madness -`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. It's all about patents and money; you can't do anything about it. +`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. -| Device | WebRTC | MSE | HTTP* | HLS | -|--------------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| -| *latency* | best | medium | bad | bad | -| - Desktop Chrome 107+
- Desktop Edge
- Android Chrome 107+ | H264
PCMU, PCMA
OPUS | H264, H265*
AAC, FLAC*
OPUS | H264, H265*
AAC, FLAC*
OPUS, MP3 | no | -| Desktop Firefox | H264
PCMU, PCMA
OPUS | H264
AAC, FLAC*
OPUS | H264
AAC, FLAC*
OPUS | no | -| - Desktop Safari 14+
- iPad Safari 14+
- iPhone Safari 17.1+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265
AAC, FLAC* | **no!** | H264, H265
AAC, FLAC* | -| iPhone Safari 14+ | H264, H265*
PCMU, PCMA
OPUS | **no!** | **no!** | H264, H265
AAC, FLAC* | -| macOS [Hass App][1] | no | no | no | H264, H265
AAC, FLAC* | +| Device | WebRTC | MSE | HTTP* | HLS | +|--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| +| *latency* | best | medium | bad | bad | +| Desktop Chrome 136+
Desktop Edge
Android Chrome 136+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265*
AAC, FLAC*
OPUS | H264, H265*
AAC, FLAC*
OPUS, MP3 | no | +| Desktop Firefox | H264
PCMU, PCMA
OPUS | H264
AAC, FLAC*
OPUS | H264
AAC, FLAC*
OPUS | no | +| Desktop Safari 14+
iPad Safari 14+
iPhone Safari 17.1+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265
AAC, FLAC* | **no!** | H264, H265
AAC, FLAC* | +| iPhone Safari 14+ | H264, H265*
PCMU, PCMA
OPUS | **no!** | **no!** | H264, H265
AAC, FLAC* | +| macOS [Hass App][1] | no | no | no | H264, H265
AAC, FLAC* | [1]: https://apps.apple.com/app/home-assistant/id1099568401 -`HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end - -- Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) -- Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/) -- Desktop Safari H265: Menu > Develop > Experimental > WebRTC H265 -- iOS Safari H265: Settings > Safari > Advanced > Experimental > WebRTC H265 +- `HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end +- `WebRTC H265` - supported in [Chrome 136+](https://developer.chrome.com/release-notes/136), supported in [Safari 18+](https://developer.apple.com/documentation/safari-release-notes/safari-18-release-notes) +- `MSE iPhone` - supported in [iOS 17.1+](https://webkit.org/blog/14735/webkit-features-in-safari-17-1/) **Audio** @@ -1341,7 +1343,7 @@ Some examples: **Apple devices** - all Apple devices don't support HTTP progressive streaming -- iPhones don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple +- old iPhone firmwares don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple - HLS is the worst technology for **live** streaming, it still exists only because of iPhones **Codec names** @@ -1414,7 +1416,8 @@ streams: ## Projects using go2rtc -- [Frigate](https://frigate.video/) 0.12+ - open-source NVR built around real-time AI object detection +- [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 - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card) - custom card for Home Assistant - [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - alternative IP camera firmware from an open community - [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - custom firmware for Wyze cameras @@ -1455,27 +1458,3 @@ streams: **Snapshots to Telegram** [read more](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram) - -## FAQ - -**Q. What's the difference between go2rtc, WebRTC Camera and RTSPtoWebRTC?** - -**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default Lovelace Picture Entity or Picture Glance. - -**Q. Should I use the go2rtc add-on or WebRTC Camera integration?** - -**go2rtc** is more than just viewing your stream online with WebRTC/MSE/HLS/etc. You can use it all the time for your various tasks. But every time Hass is rebooted, all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks. - -Basic users can use the **WebRTC Camera** integration. Advanced users can use the go2rtc add-on or the Frigate 0.12+ add-on. - -**Q. Which RTSP link should I use inside Hass?** - -You can use a direct link to your cameras there (as you always do). **go2rtc** supports zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**. - -Also, you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this add-on with additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as a source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connections - some from Hass via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols. - -Use any config that you like. - -**Q. What about Lovelace card with support for two-way audio?** - -At this moment, I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html). diff --git a/api/openapi.yaml b/api/openapi.yaml index a2d66a87..6ec0b492 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -238,6 +238,14 @@ paths: /api/preload: + get: + summary: Get all preloaded streams + tags: [ Streams list ] + responses: + "200": + description: "" + content: + application/json: { example: { camera1: "video&audio", camera2: "video" } } put: summary: Preload new stream tags: [ Streams list ] diff --git a/go.mod b/go.mod index d1bc1971..1e649cae 100644 --- a/go.mod +++ b/go.mod @@ -4,47 +4,47 @@ 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/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.68 - github.com/pion/ice/v4 v4.0.10 + github.com/miekg/dns v1.1.69 + github.com/pion/ice/v4 v4.1.0 github.com/pion/interceptor v0.1.42 github.com/pion/rtcp v1.2.16 - github.com/pion/rtp v1.8.25 + github.com/pion/rtp v1.8.26 github.com/pion/sdp/v3 v3.0.16 - github.com/pion/srtp/v3 v3.0.8 - github.com/pion/stun/v3 v3.0.1 - github.com/pion/webrtc/v4 v4.1.6 + 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/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.45.0 + golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/asticode/go-astikit v0.57.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.0 // 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.7 // indirect + github.com/pion/dtls/v3 v3.0.9 // 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.40 // indirect + github.com/pion/sctp v1.8.41 // indirect github.com/pion/transport/v3 v3.1.1 // indirect github.com/pion/turn/v4 v4.1.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/mod v0.31.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 ) diff --git a/go.sum b/go.sum index f2134ca0..d251618d 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,6 @@ github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= -github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ= -github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= -github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= -github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astikit v0.57.1 h1:fEykwH98Nny08kcRbk4uer+S8h0rKveCIpG9F6NVLuA= github.com/asticode/go-astikit v0.57.1/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= -github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= -github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/asticode/go-astits v1.14.0 h1:zkgnZzipx2XX5mWycqsSBeEyDH58+i4HtyF4j2ROb00= github.com/asticode/go-astits v1.14.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -14,15 +8,13 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o= -github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk= -github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= -github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= -github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +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/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= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -38,94 +30,40 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= -github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= -github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= -github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +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/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= -github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= -github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= -github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= -github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= -github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= -github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= -github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= -github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= -github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= -github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw= -github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY= +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/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU= +github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4= 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/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= -github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= 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.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= -github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= -github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= 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.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= -github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= -github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= -github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/rtp v1.8.24 h1:+ICyZXUQDv95EsHN70RrA4XKJf5MGWyC6QQc1u6/ynI= -github.com/pion/rtp v1.8.24/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/rtp v1.8.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw= -github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= -github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= -github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= -github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= -github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= -github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= -github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +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/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs= +github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY= 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/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= -github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= -github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= -github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= -github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= -github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= -github.com/pion/stun/v3 v3.0.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA= -github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc= -github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +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/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= +github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= 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/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= -github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= -github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= -github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= 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/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg= -github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk= -github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= -github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= -github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= -github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= +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/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= @@ -142,67 +80,32 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxI github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= 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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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= 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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +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/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go index 2f731fc1..fb044467 100644 --- a/internal/ffmpeg/producer.go +++ b/internal/ffmpeg/producer.go @@ -96,7 +96,7 @@ func (p *Producer) newURL() string { codec := receiver.Codec switch codec.Name { case core.CodecOpus: - s += "#audio=opus" + s += "#audio=opus/16000" case core.CodecAAC: s += "#audio=aac/16000" case core.CodecPCML: diff --git a/internal/streams/api.go b/internal/streams/api.go index 697d8e67..d6142eb0 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -130,16 +130,15 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } func apiPreload(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - src := query.Get("src") - - // check if stream exists - stream := Get(src) - if stream == nil { - http.Error(w, "", http.StatusNotFound) + // GET - return all preloads + if r.Method == "GET" { + api.ResponseJSON(w, GetPreloads()) return } + query := r.URL.Query() + src := query.Get("src") + switch r.Method { case "PUT": // it's safe to delete from map while iterating @@ -153,7 +152,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { rawQuery := query.Encode() - if err := AddPreload(stream, rawQuery); err != nil { + if err := AddPreload(src, rawQuery); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -163,7 +162,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { } case "DELETE": - if err := DelPreload(stream); err != nil { + if err := DelPreload(src); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/streams/preload.go b/internal/streams/preload.go index 527746ac..447b5ac3 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -1,23 +1,24 @@ package streams import ( - "errors" + "fmt" + "maps" "net/url" "sync" "github.com/AlexxIT/go2rtc/pkg/probe" ) -var preloads = map[*Stream]*probe.Probe{} -var preloadsMu sync.Mutex - -func Preload(stream *Stream, rawQuery string) { - if err := AddPreload(stream, rawQuery); err != nil { - log.Error().Err(err).Caller().Send() - } +type Preload struct { + stream *Stream // Don't output the stream to JSON to not worry about its secrets. + Cons *probe.Probe `json:"consumer"` + Query string `json:"query"` } -func AddPreload(stream *Stream, rawQuery string) error { +var preloads = map[string]*Preload{} +var preloadsMu sync.Mutex + +func AddPreload(name, rawQuery string) error { if rawQuery == "" { rawQuery = "video&audio" } @@ -30,29 +31,39 @@ func AddPreload(stream *Stream, rawQuery string) error { preloadsMu.Lock() defer preloadsMu.Unlock() - if cons := preloads[stream]; cons != nil { - stream.RemoveConsumer(cons) + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) } + stream := Get(name) + if stream == nil { + return fmt.Errorf("streams: stream not found: %s", name) + } cons := probe.Create("preload", query) if err = stream.AddConsumer(cons); err != nil { return err } - preloads[stream] = cons + preloads[name] = &Preload{stream: stream, Cons: cons, Query: rawQuery} return nil } -func DelPreload(stream *Stream) error { +func DelPreload(name string) error { preloadsMu.Lock() defer preloadsMu.Unlock() - if cons := preloads[stream]; cons != nil { - stream.RemoveConsumer(cons) - delete(preloads, stream) + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) + delete(preloads, name) return nil } - return errors.New("streams: preload not found") + return fmt.Errorf("streams: preload not found: %s", name) +} + +func GetPreloads() map[string]*Preload { + preloadsMu.Lock() + defer preloadsMu.Unlock() + return maps.Clone(preloads) } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 433f9d36..f3b8df03 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -43,8 +43,8 @@ func Init() { } } for name, rawQuery := range cfg.Preload { - if stream := Get(name); stream != nil { - Preload(stream, rawQuery) + if err := AddPreload(name, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() } } }) diff --git a/internal/tuya/README.md b/internal/tuya/README.md new file mode 100644 index 00000000..b37a27c3 --- /dev/null +++ b/internal/tuya/README.md @@ -0,0 +1,39 @@ +# 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`. + +**Tuya Smart API (recommended)**: +- Cameras can be discovered through the go2rtc web interface via Tuya Smart account (Add > Tuya > Select region and fill in email and password > Login). +- **Smart Life accounts are not supported**, you need to create a Tuya Smart account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the Tuya Smart app. + +**Tuya Cloud API**: +- Requires setting up a cloud project in the Tuya Developer Platform. +- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). +- Please ensure that you have subscribed to the `IoT Video Live Stream` service (Free Trial) in the Tuya Developer Platform, otherwise the stream will not work (Tuya Developer Platform > Service API > Authorize > IoT Video Live Stream). + +## Configuration + +Use `resolution` parameter to select the stream (not all cameras support `hd` stream through WebRTC even if the camera has it): +- `hd` - HD stream (default) +- `sd` - SD stream + +```yaml +streams: + # Tuya Smart API: WebRTC main stream (use Add > Tuya to discover the URL) + tuya_main: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX + + # Tuya Smart API: WebRTC sub stream (use Add > Tuya to discover the URL) + tuya_sub: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd + + # Tuya Cloud API: WebRTC main stream + tuya_webrtc: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX + + # Tuya Cloud API: WebRTC sub stream + tuya_webrtc_sd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd +``` diff --git a/internal/xiaomi/README.md b/internal/xiaomi/README.md new file mode 100644 index 00000000..80d98beb --- /dev/null +++ b/internal/xiaomi/README.md @@ -0,0 +1,50 @@ +# Xiaomi + +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. + +**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. +2. Each time you connect to the camera, you need internet access to obtain encryption keys. +3. Connection to the camera is local only. + +**Features:** + +- Multiple Xiaomi accounts supported +- Cameras from multiple regions are supported for a single account +- Two-way audio is supported +- Cameras with multiple lenses are supported + +## Setup + +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. + +**Example** + +```yaml +xiaomi: + 1234567890: V1:*** + +streams: + xiaomi1: xiaomi://1234567890:cn@192.168.1.123?did=9876543210&model=isa.camera.hlc7 +``` + +## Configuration + +You can change camera's quality: `subtype=hd/sd/auto` + +```yaml +streams: + xiaomi1: xiaomi://***&subtype=sd +``` + +You can use second channel for Dual cameras: `channel=1` + +```yaml +streams: + xiaomi1: xiaomi://***&channel=1 +``` diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go new file mode 100644 index 00000000..55cb30c1 --- /dev/null +++ b/internal/xiaomi/xiaomi.go @@ -0,0 +1,267 @@ +package xiaomi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "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/xiaomi" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" +) + +func Init() { + var v struct { + Cfg map[string]string `yaml:"xiaomi"` + } + app.LoadConfig(&v) + + tokens = v.Cfg + + log := app.GetLogger("xiaomi") + + streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.User != nil { + rawURL, err = getCameraURL(u) + if err != nil { + return nil, err + } + } + + log.Debug().Msgf("xiaomi: dial %s", rawURL) + + return xiaomi.Dial(rawURL) + }) + + api.HandleFunc("api/xiaomi", apiXiaomi) +} + +var tokens map[string]string +var tokensMu sync.Mutex + +func getCloud(userID string) (*xiaomi.Cloud, error) { + tokensMu.Lock() + defer tokensMu.Unlock() + + token := tokens[userID] + cloud := xiaomi.NewCloud(AppXiaomiHome) + if err := cloud.LoginWithToken(userID, token); err != nil { + return nil, err + } + + return cloud, nil +} + +func getCameraURL(url *url.URL) (string, error) { + clientPublic, clientPrivate, err := miss.GenerateKey() + if err != nil { + return "", err + } + + query := url.Query() + + params := fmt.Sprintf( + `{"app_pubkey":"%x","did":"%s","support_vendors":"CS2"}`, + clientPublic, query.Get("did"), + ) + + cloud, err := getCloud(url.User.Username()) + if err != nil { + return "", err + } + + region, _ := url.User.Password() + + res, err := cloud.Request(GetBaseURL(region), "/v2/device/miss_get_vendor", params, nil) + if err != nil { + return "", err + } + + var v struct { + Vendor struct { + VendorID byte `json:"vendor"` + } `json:"vendor"` + PublicKey string `json:"public_key"` + Sign string `json:"sign"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + 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) + query.Set("vendor", getVendorName(v.Vendor.VendorID)) + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getVendorName(i byte) string { + switch i { + case 1: + return "tutk" + case 3: + return "agora" + case 4: + return "cs2" + case 6: + return "mtp" + } + return fmt.Sprintf("%d", i) +} + +func apiXiaomi(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() + + user := query.Get("id") + if user == "" { + tokensMu.Lock() + users := make([]string, 0, len(tokens)) + for s := range tokens { + users = append(users, s) + } + tokensMu.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) + if err != nil { + return err + } + var v struct { + List []*Device `json:"list"` + } + + if err = json.Unmarshal(res, &v); err != nil { + return err + } + + var items []*api.Source + + for _, device := range v.List { + if !strings.Contains(device.Model, ".camera.") && !strings.Contains(device.Model, ".cateye.") { + continue + } + items = append(items, &api.Source{ + Name: device.Name, + Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC), + URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +type Device struct { + Did string `json:"did"` + Name string `json:"name"` + Model string `json:"model"` + MAC string `json:"mac"` + IP string `json:"localip"` +} + +var auth *xiaomi.Cloud + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + captcha := r.Form.Get("captcha") + verify := r.Form.Get("verify") + + var err error + + switch { + case username != "" || password != "": + auth = xiaomi.NewCloud(AppXiaomiHome) + err = auth.Login(username, password) + case captcha != "": + err = auth.LoginWithCaptcha(captcha) + case verify != "": + err = auth.LoginWithVerify(verify) + default: + http.Error(w, "wrong request", http.StatusBadRequest) + return + } + + if err == nil { + userID, token := auth.UserToken() + auth = nil + + tokensMu.Lock() + if tokens == nil { + tokens = map[string]string{userID: token} + } else { + tokens[userID] = token + } + tokensMu.Unlock() + + err = app.PatchConfig([]string{"xiaomi", userID}, token) + } + + if err != nil { + var login *xiaomi.LoginError + if errors.As(err, &login) { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(err) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +const AppXiaomiHome = "xiaomiio" + +func GetBaseURL(region string) string { + switch region { + case "de", "i2", "ru", "sg", "us": + return "https://" + region + ".api.io.mi.com/app" + } + return "https://api.io.mi.com/app" +} diff --git a/main.go b/main.go index bdf94a6a..72946013 100644 --- a/main.go +++ b/main.go @@ -43,12 +43,13 @@ import ( "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/xiaomi" "github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/pkg/shell" ) func main() { - app.Version = "1.9.12" + app.Version = "1.9.13" type module struct { name string @@ -98,6 +99,7 @@ func main() { {"roborock", roborock.Init}, {"tapo", tapo.Init}, {"tuya", tuya.Init}, + {"xiaomi", xiaomi.Init}, {"yandex", yandex.Init}, // Helper modules {"debug", debug.Init}, diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 6935e7f2..52b969a7 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -67,6 +67,21 @@ func Atoi(s string) (i int) { return } +// ParseByte - fast parsing string to byte function +func ParseByte(s string) (b byte) { + for i, ch := range []byte(s) { + ch -= '0' + if ch > 9 { + return 0 + } + if i > 0 { + b *= 10 + } + b += ch + } + return +} + func Assert(ok bool) { if !ok { _, file, line, _ := runtime.Caller(1) diff --git a/pkg/opus/opus.go b/pkg/opus/opus.go index 95956b93..fb67c66d 100644 --- a/pkg/opus/opus.go +++ b/pkg/opus/opus.go @@ -85,3 +85,34 @@ func parseFrames(c byte) byte { } return 0xFF } + +func JoinFrames(b1, b2 []byte) []byte { + // can't join + if b1[0]&0b11 != 0 || b2[0]&0b11 != 0 { + return append(b1, b2...) + } + + size1, size2 := len(b1)-1, len(b2)-1 + + // join same sizes + if size1 == size2 { + b := make([]byte, 1+size1+size2) + copy(b, b1) + copy(b[1+size1:], b2[1:]) + b[0] |= 0b01 + return b + } + + b := make([]byte, 1, 3+size1+size2) + b[0] = b1[0] | 0b10 + if size1 >= 252 { + b0 := 252 + byte(size1)&0b11 + b = append(b, b0, byte(size1/4)-b0) + } else { + b = append(b, byte(size1)) + } + + b = append(b, b1[1:]...) + b = append(b, b2[1:]...) + return b +} diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/backchannel.go new file mode 100644 index 00000000..0224a594 --- /dev/null +++ b/pkg/xiaomi/backchannel.go @@ -0,0 +1,71 @@ +package xiaomi + +import ( + "time" + + "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 { + return err + } + // TODO: check this!!! + time.Sleep(time.Second) + + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecPCMA: + var buf []byte + + if p.model == "isa.camera.hlc6" { + dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + transcode := pcm.Transcode(dst, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + 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]) + buf = buf[size:] + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + const size = 8000 * 0.040 // 8bit 40 ms + for len(buf) >= size { + _ = p.client.WriteAudio(miss.CodecPCMA, buf[:size]) + buf = buf[size:] + } + } + } + case core.CodecOpus: + if p.model == "chuangmi.camera.72ac1" { + var buf []byte + sender.Handler = func(pkt *rtp.Packet) { + if buf == nil { + buf = pkt.Payload + } else { + // convert two 20ms to one 40ms + buf = opus.JoinFrames(buf, pkt.Payload) + _ = p.client.WriteAudio(miss.CodecOPUS, buf) + buf = nil + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + _ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload) + } + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} diff --git a/pkg/xiaomi/cloud.go b/pkg/xiaomi/cloud.go new file mode 100644 index 00000000..5e1e73cc --- /dev/null +++ b/pkg/xiaomi/cloud.go @@ -0,0 +1,563 @@ +package xiaomi + +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "crypto/rc4" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Cloud struct { + client *http.Client + + sid string + cookies string // for auth + ssecurity []byte // for encryption + + userID string + passToken string + + auth map[string]string +} + +func NewCloud(sid string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 15 * time.Second}, + sid: sid, + } +} + +func (c *Cloud) Login(username, password string) error { + res, err := c.client.Get("https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=" + c.sid) + if err != nil { + return err + } + + var v1 struct { + Qs string `json:"qs"` + Sign string `json:"_sign"` + Sid string `json:"sid"` + Callback string `json:"callback"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + hash := fmt.Sprintf("%X", md5.Sum([]byte(password))) + + form := url.Values{ + "_json": {"true"}, + "hash": {hash}, + "sid": {v1.Sid}, + "callback": {v1.Callback}, + "_sign": {v1.Sign}, + "qs": {v1.Qs}, + "user": {username}, + } + cookies := "deviceId=" + core.RandString(16, 62) + + // login after captcha + if c.auth != nil && c.auth["captcha_code"] != "" { + form.Set("captCode", c.auth["captcha_code"]) + cookies += "; ick=" + c.auth["ick"] + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/pass/serviceLoginAuth2", + RawBody: form.Encode(), + Headers: url.Values{ + "Content-Type": {"application/x-www-form-urlencoded"}, + }, + RawCookies: cookies, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + + CaptchaURL string `json:"captchaURL"` + NotificationURL string `json:"notificationUrl"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + // save auth for two step verification + c.auth = map[string]string{ + "username": username, + "password": password, + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.NotificationURL != "" { + return c.authStart(v2.NotificationURL) + } + + if v2.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + c.auth = nil + c.ssecurity = v2.Ssecurity + c.passToken = v2.PassToken + + return c.finishAuth(v2.Location) +} + +func (c *Cloud) LoginWithCaptcha(captcha string) error { + if c.auth == nil || c.auth["ick"] == "" { + panic("wrong login step") + } + + c.auth["captcha_code"] = captcha + + // check if captcha after verify + if c.auth["flag"] != "" { + return c.sendTicket() + } + + return c.Login(c.auth["username"], c.auth["password"]) +} + +func (c *Cloud) LoginWithVerify(ticket string) error { + if c.auth == nil || c.auth["flag"] == "" { + panic("wrong login step") + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/verify" + c.verifyName(), + RawParams: "_flag" + c.auth["flag"] + "&ticket=" + ticket + "&trust=false&_json=true", + RawCookies: "identity_session=" + c.auth["identity_session"], + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Location string `json:"location"` + } + body, err := readLoginResponse(res.Body, &v1) + if err != nil { + return err + } + if v1.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) getCaptcha(captchaURL string) error { + res, err := c.client.Get("https://account.xiaomi.com" + captchaURL) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + c.auth["ick"] = findCookie(res, "ick") + + return &LoginError{ + Captcha: body, + } +} + +func (c *Cloud) authStart(notificationURL string) error { + rawURL := strings.Replace(notificationURL, "/fe/service/identity/authStart", "/identity/list", 1) + res, err := c.client.Get(rawURL) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + Flag int `json:"flag"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.auth["flag"] = strconv.Itoa(v1.Flag) + c.auth["identity_session"] = findCookie(res, "identity_session") + + return c.sendTicket() +} + +func findCookie(res *http.Response, name string) string { + for _, cookie := range res.Cookies() { + if cookie.Name == name { + return cookie.Value + } + } + return "" +} + +func (c *Cloud) verifyName() string { + switch c.auth["flag"] { + case "4": + return "Phone" + case "8": + return "Email" + } + return "" +} + +func (c *Cloud) sendTicket() error { + name := c.verifyName() + cookies := "identity_session=" + c.auth["identity_session"] + + req := Request{ + URL: "https://account.xiaomi.com/identity/auth/verify" + name, + RawParams: "_flag=" + c.auth["flag"] + "&_json=true", + RawCookies: cookies, + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + MaskedPhone string `json:"maskedPhone"` + MaskedEmail string `json:"maskedEmail"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + // verify after captcha + captCode := c.auth["captcha_code"] + if captCode != "" { + cookies += "; ick=" + c.auth["ick"] + } + + req = Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket", + RawCookies: cookies, + RawBody: `{"retry":0,"icode":"` + captCode + `","_json":"true"}`, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Code int `json:"code"` + CaptchaURL string `json:"captchaURL"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.Code != 0 { + return fmt.Errorf("xiaomi: %s", body) + } + + return &LoginError{ + VerifyPhone: v1.MaskedPhone, + VerifyEmail: v1.MaskedEmail, + } +} + +type LoginError struct { + Captcha []byte `json:"captcha,omitempty"` + VerifyPhone string `json:"verify_phone,omitempty"` + VerifyEmail string `json:"verify_email,omitempty"` +} + +func (l *LoginError) Error() string { + return "" +} + +func (c *Cloud) finishAuth(location string) error { + res, err := c.client.Get(location) + if err != nil { + return err + } + defer res.Body.Close() + + // LoginWithVerify + // - userId, cUserId, serviceToken from cookies + // - passToken from redirect cookies + // - ssecurity from extra header + // LoginWithToken + // - userId, cUserId, serviceToken from cookies + var cUserId, serviceToken string + + for res != nil { + for _, cookie := range res.Cookies() { + switch cookie.Name { + case "userId": + c.userID = cookie.Value + case "cUserId": + cUserId = cookie.Value + case "serviceToken": + serviceToken = cookie.Value + case "passToken": + c.passToken = cookie.Value + } + } + + if s := res.Header.Get("Extension-Pragma"); s != "" { + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + } + if err = json.Unmarshal([]byte(s), &v1); err != nil { + return err + } + c.ssecurity = v1.Ssecurity + } + + res = res.Request.Response + } + + c.cookies = fmt.Sprintf("userId=%s; cUserId=%s; serviceToken=%s", c.userID, cUserId, serviceToken) + + return nil +} + +func (c *Cloud) LoginWithToken(userID, passToken string) error { + req, err := http.NewRequest("GET", "https://account.xiaomi.com/pass/serviceLogin?_json=true&sid="+c.sid, nil) + if err != nil { + return err + } + + req.Header.Set("Cookie", fmt.Sprintf("userId=%s; passToken=%s", userID, passToken)) + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.ssecurity = v1.Ssecurity + c.passToken = v1.PassToken + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) UserToken() (string, string) { + return c.userID, c.passToken +} + +func (c *Cloud) Request(baseURL, apiURL, params string, headers map[string]string) ([]byte, error) { + form := url.Values{"data": {params}} + + nonce := genNonce() + signedNonce := genSignedNonce(c.ssecurity, nonce) + + // 1. gen hash for data param + form.Set("rc4_hash__", genSignature64("POST", apiURL, form, signedNonce)) + + // 2. encrypt data and hash params + for _, v := range form { + ciphertext, err := crypt(signedNonce, []byte(v[0])) + if err != nil { + return nil, err + } + v[0] = base64.StdEncoding.EncodeToString(ciphertext) + } + + // 3. add signature for encrypted data and hash params + form.Set("signature", genSignature64("POST", apiURL, form, signedNonce)) + + // 4. add nonce + form.Set("_nonce", base64.StdEncoding.EncodeToString(nonce)) + + req, err := http.NewRequest("POST", baseURL+apiURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Cookie", c.cookies) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + for k, v := range headers { + req.Header.Set(k, v) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + ciphertext, err := base64.StdEncoding.DecodeString(string(body)) + if err != nil { + return nil, err + } + + plaintext, err := crypt(signedNonce, ciphertext) + if err != nil { + return nil, err + } + + var res1 struct { + Code int `json:"code"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err = json.Unmarshal(plaintext, &res1); err != nil { + return nil, err + } + + if res1.Code != 0 { + return nil, errors.New("xiaomi: " + res1.Message) + } + + return res1.Result, nil +} + +func readLoginResponse(rc io.ReadCloser, v any) ([]byte, error) { + defer rc.Close() + + body, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + body, ok := bytes.CutPrefix(body, []byte("&&&START&&&")) + if !ok { + return nil, fmt.Errorf("xiaomi: %s", body) + } + + return body, json.Unmarshal(body, &v) +} + +func genNonce() []byte { + ts := time.Now().Unix() / 60 + + nonce := make([]byte, 12) + _, _ = rand.Read(nonce[:8]) + binary.BigEndian.PutUint32(nonce[8:], uint32(ts)) + return nonce +} + +func genSignedNonce(ssecurity, nonce []byte) []byte { + hasher := sha256.New() + hasher.Write(ssecurity) + hasher.Write(nonce) + return hasher.Sum(nil) +} + +func crypt(key, plaintext []byte) ([]byte, error) { + cipher, err := rc4.NewCipher(key) + if err != nil { + return nil, err + } + + tmp := make([]byte, 1024) + cipher.XORKeyStream(tmp, tmp) + + ciphertext := make([]byte, len(plaintext)) + cipher.XORKeyStream(ciphertext, plaintext) + + return ciphertext, nil +} + +func genSignature64(method, path string, values url.Values, signedNonce []byte) string { + s := method + "&" + path + "&data=" + values.Get("data") + if values.Has("rc4_hash__") { + s += "&rc4_hash__=" + values.Get("rc4_hash__") + } + s += "&" + base64.StdEncoding.EncodeToString(signedNonce) + + hasher := sha1.New() + hasher.Write([]byte(s)) + signature := hasher.Sum(nil) + + return base64.StdEncoding.EncodeToString(signature) +} + +type Request struct { + Method string + URL string + RawParams string + RawBody string + Headers url.Values + RawCookies string +} + +func (r Request) Encode() *http.Request { + if r.RawParams != "" { + r.URL += "?" + r.RawParams + } + + var body io.Reader + if r.RawBody != "" { + body = strings.NewReader(r.RawBody) + } + + req, err := http.NewRequest(r.Method, r.URL, body) + if err != nil { + return nil + } + + if r.Headers != nil { + req.Header = http.Header(r.Headers) + } + + if r.RawCookies != "" { + req.Header.Set("Cookie", r.RawCookies) + } + + return req +} diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go new file mode 100644 index 00000000..a1e3ded9 --- /dev/null +++ b/pkg/xiaomi/miss/client.go @@ -0,0 +1,451 @@ +package miss + +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "log" + "net" + "net/url" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/nacl/box" +) + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + 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) + if err != nil { + return nil, err + } + + conn, err := net.ListenUDP("udp", nil) + 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 { + _ = conn.Close() + return nil, err + } + + client.chSeq0 = 1 + client.chRaw2 = make(chan []byte, 100) + go client.worker() + + return client, 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() +} + +const ( + magic = 0xF1 + magicDrw = 0xD1 + msgLanSearch = 0x30 + msgPunchPkt = 0x41 + msgP2PRdy = 0x42 + msgDrw = 0xD0 + msgDrwAck = 0xD1 + msgAlive = 0xE0 + + cmdAuthReq = 0x100 + cmdAuthRes = 0x101 + cmdVideoStart = 0x102 + cmdVideoStop = 0x103 + cmdAudioStart = 0x104 + cmdAudioStop = 0x105 + cmdSpeakerStartReq = 0x106 + cmdSpeakerStartRes = 0x107 + cmdSpeakerStop = 0x108 + cmdStreamCtrlReq = 0x109 + cmdStreamCtrlRes = 0x10A + cmdGetAudioFormatReq = 0x10B + cmdGetAudioFormatRes = 0x10C + cmdPlaybackReq = 0x10D + cmdPlaybackRes = 0x10E + cmdDevInfoReq = 0x110 + cmdDevInfoRes = 0x111 + cmdMotorReq = 0x112 + cmdMotorRes = 0x113 + 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) + + 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 != nil { + return fmt.Errorf("miss: read auth: %w", err) + } + + if !strings.Contains(string(buf[16:]), `"result":"success"`) { + return fmt.Errorf("miss: read auth: %s", buf[16:]) + } + + _, _ = 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) 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) + } else { + buf = fmt.Appendf(buf, `{"videoquality":-1,"videoquality2":%d,"enableaudio":%d}`, 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 +} + +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) ReadPacket() (*Packet, error) { + b, ok := <-c.chRaw2 + if !ok { + return nil, fmt.Errorf("miss: read raw: i/o timeout") + } + return unmarshalPacket(c.key, b) +} + +func unmarshalPacket(key, b []byte) (*Packet, error) { + n := uint32(len(b)) + + if n < 32 { + 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:]) + 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 +} + +func (c *Client) WriteAudio(codecID uint32, payload []byte) error { + payload, err := encode(c.key, payload) + 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 +} + +type Packet struct { + //Length uint32 + CodecID uint32 + Sequence uint32 + Flags uint32 + Timestamp uint64 // msec + //TimestampS uint32 + //Reserved uint32 + 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 +} diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go new file mode 100644 index 00000000..f9164d0b --- /dev/null +++ b/pkg/xiaomi/producer.go @@ -0,0 +1,208 @@ +package xiaomi + +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/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 + } + + 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 +} + +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) +} diff --git a/website/manifest.json b/website/manifest.json index b33a6064..1bfd3571 100644 --- a/website/manifest.json +++ b/website/manifest.json @@ -2,12 +2,12 @@ "name": "go2rtc", "icons": [ { - "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-192x192.png", + "src": "https://go2rtc.org/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-512x512.png", + "src": "https://go2rtc.org/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/www/add.html b/www/add.html index 98661fd3..38c4e155 100644 --- a/www/add.html +++ b/www/add.html @@ -413,6 +413,88 @@ + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + +
diff --git a/www/stream.html b/www/stream.html index 90797ef2..de7ad123 100644 --- a/www/stream.html +++ b/www/stream.html @@ -2,9 +2,9 @@ - - - + + + stream - go2rtc