Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfe47559d1 | |||
| eabd7d60cd | |||
| dfda4b11ff | |||
| 353262307b | |||
| d734140eaf | |||
| 2409bb56d7 | |||
| df484cc904 | |||
| 7e0c7a8173 | |||
| 7eaa4a1b55 | |||
| 03941a5691 | |||
| 28821c41e0 | |||
| 8636e96379 | |||
| 7119384184 | |||
| 57b0ace802 | |||
| b0f46bc919 | |||
| a4d4598a13 | |||
| 17c1f69f66 | |||
| fb31a251b8 | |||
| c5277daa45 | |||
| 76a5e160c2 | |||
| 494daed937 | |||
| a86e10446a | |||
| 209b73a0f1 | |||
| 54473ff1de | |||
| 7eb5fe0355 | |||
| fbd5215669 | |||
| 3ebc115cc5 | |||
| 6d1a95a4e3 | |||
| 4ec2849008 | |||
| 4ef6a147a6 | |||
| 94df080bf7 | |||
| 86edd814c9 | |||
| 5a259993d8 | |||
| b6fe8871df | |||
| b47a2ba73c | |||
| 4934fa4cc1 | |||
| 61f74820bc | |||
| 248fc7a11a | |||
| aa0ece2d1e | |||
| 68036b68c1 | |||
| 319dbf2c63 | |||
| 4b419309a8 | |||
| 42e7a03534 | |||
| 290e8fcfda | |||
| 6c78b5cb53 | |||
| c72b205d87 | |||
| 2cd009646a | |||
| 8f5fce4d73 | |||
| b705cadc04 | |||
| 2523a5ac38 | |||
| e246e2e756 | |||
| 56d7a6fee4 | |||
| 292b32af99 | |||
| 33e4527042 | |||
| 62a9046f01 | |||
| 25e7ac531e | |||
| 0f27bb1124 | |||
| 0ff3bf67e1 | |||
| 6d3d45e337 | |||
| c6940eb0f3 | |||
| 8142d2fc43 | |||
| 721ed98afb | |||
| fb8c6e1b1b | |||
| 80ab32379c | |||
| 863174839c | |||
| bd8e4fa298 | |||
| 7abc963a50 | |||
| ee5e31d3b3 | |||
| 5c01cbad9e | |||
| e9611769be | |||
| c5dfa84ff2 | |||
| da68101c09 | |||
| 30c418542c | |||
| b58c1a7ed6 | |||
| 47e87281a1 | |||
| 60b6b93ff8 | |||
| 3149b6f750 | |||
| e44f1ad53e | |||
| c38c8a7fce | |||
| 9efc717633 | |||
| a2d422f5cb | |||
| 3036dd7cfe | |||
| 5be5d9247c | |||
| 42b7eea852 | |||
| 7ee3f6e4f7 | |||
| fbd8d995ed | |||
| 998c85d6f5 | |||
| 67dfc942a0 | |||
| 1cc8b373de | |||
| f045f3fccd | |||
| 691f6d9cdd | |||
| 8a8fb66eeb | |||
| e7044a93f6 | |||
| a9bcb46f38 | |||
| 16a812c8b8 | |||
| 27fe2622ec | |||
| 3d222136f9 | |||
| 524cdb7176 | |||
| 499dc10390 | |||
| e7bd3d401f | |||
| 5ec942cb5e | |||
| 6c255cd2f2 | |||
| 4f969d750a | |||
| bd2cbe20e0 | |||
| 6d8d6a91ef | |||
| 05c12b34e5 | |||
| 43b7a662c1 | |||
| a7e76db464 | |||
| b797a2fcd1 | |||
| 6e35f1a389 | |||
| 30d48e139c | |||
| e74fc6f198 |
@@ -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:**
|
||||
@@ -66,6 +65,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
||||
* [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)
|
||||
@@ -73,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)
|
||||
@@ -202,14 +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
|
||||
|
||||
@@ -224,8 +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)).
|
||||
@@ -529,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)*
|
||||
@@ -575,6 +593,18 @@ streams:
|
||||
|
||||
Tested: KD110, KC200, KC401, KC420WS, EC71.
|
||||
|
||||
#### 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).
|
||||
|
||||
#### 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).
|
||||
|
||||
#### Source: GoPro
|
||||
|
||||
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)*
|
||||
@@ -676,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)*
|
||||
@@ -1272,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+ <br/> - Desktop Edge <br/> - Android Chrome 107+ | H264 <br/> PCMU, PCMA <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS, MP3 | no |
|
||||
| Desktop Firefox | H264 <br/> PCMU, PCMA <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | no |
|
||||
| - Desktop Safari 14+ <br/> - iPad Safari 14+ <br/> - iPhone Safari 17.1+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | H264, H265 <br/> AAC, FLAC* | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| iPhone Safari 14+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | **no!** | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| macOS [Hass App][1] | no | no | no | H264, H265 <br/> AAC, FLAC* |
|
||||
| Device | WebRTC | MSE | HTTP* | HLS |
|
||||
|--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------|
|
||||
| *latency* | best | medium | bad | bad |
|
||||
| Desktop Chrome 136+ <br/> Desktop Edge <br/> Android Chrome 136+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS, MP3 | no |
|
||||
| Desktop Firefox | H264 <br/> PCMU, PCMA <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | no |
|
||||
| Desktop Safari 14+ <br/> iPad Safari 14+ <br/> iPhone Safari 17.1+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | H264, H265 <br/> AAC, FLAC* | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| iPhone Safari 14+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | **no!** | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| macOS [Hass App][1] | no | no | no | H264, H265 <br/> 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**
|
||||
|
||||
@@ -1301,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**
|
||||
@@ -1374,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
|
||||
@@ -1415,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).
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module pinggy
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 // indirect
|
||||
golang.org/x/crypto v0.8.0 // indirect
|
||||
golang.org/x/sys v0.7.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 h1:lzZ00JK6BUGQXnpkJZ+cVj8kIkXsmiVBUci9uEkSwEY=
|
||||
github.com/Pinggy-io/pinggy-go/pinggy v0.6.9/go.mod h1:V1Sxb+4zyr36o9atZiqtT4XhsKtW1RSb2GvsbTbTJYw=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/Pinggy-io/pinggy-go/pinggy"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tunType := os.Args[1]
|
||||
address := os.Args[2]
|
||||
|
||||
log.SetFlags(log.Llongfile | log.LstdFlags)
|
||||
|
||||
config := pinggy.Config{
|
||||
Type: pinggy.TunnelType(tunType),
|
||||
TcpForwardingAddr: address,
|
||||
|
||||
//SshOverSsl: true,
|
||||
//Stdout: os.Stderr,
|
||||
//Stderr: os.Stderr,
|
||||
}
|
||||
|
||||
if tunType == "http" {
|
||||
hman := pinggy.CreateHeaderManipulationAndAuthConfig()
|
||||
//hman.SetReverseProxy(address)
|
||||
//hman.SetPassPreflight(true)
|
||||
//hman.SetNoReverseProxy()
|
||||
config.HeaderManipulationAndAuth = hman
|
||||
}
|
||||
|
||||
pl, err := pinggy.ConnectWithConfig(config)
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
}
|
||||
log.Println("Addrs: ", pl.RemoteUrls())
|
||||
//err = pl.InitiateWebDebug("localhost:3424")
|
||||
//log.Println(err)
|
||||
pl.StartForwarding()
|
||||
}
|
||||
@@ -3,47 +3,48 @@ module github.com/AlexxIT/go2rtc
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astits v1.13.0
|
||||
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/pion/interceptor v0.1.41
|
||||
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.24
|
||||
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.0
|
||||
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.43.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.56.0 // indirect
|
||||
github.com/asticode/go-astikit v0.57.1 // indirect
|
||||
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.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.0.7 // 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/transport/v3 v3.0.8 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // 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.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/tools v0.38.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
|
||||
)
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
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-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
|
||||
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||
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.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=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/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=
|
||||
@@ -32,82 +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/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/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.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/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/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/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/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/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.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
|
||||
github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
|
||||
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=
|
||||
@@ -124,55 +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/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/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/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/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/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/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=
|
||||
|
||||
+71
-11
@@ -12,34 +12,94 @@
|
||||
- `fetch` - JS-like HTTP requests
|
||||
- `match` - JS-like RegExp queries
|
||||
|
||||
## Examples
|
||||
## Fetch examples
|
||||
|
||||
Multiple fetch requests are executed within a single session. They share the same cookie.
|
||||
|
||||
**HTTP GET**
|
||||
|
||||
```js
|
||||
var r = fetch('https://example.org/products.json');
|
||||
```
|
||||
|
||||
**HTTP POST JSON**
|
||||
|
||||
```js
|
||||
var r = fetch('https://example.org/post', {
|
||||
method: 'POST',
|
||||
// Content-Type: application/json will be set automatically
|
||||
json: {username: 'example'}
|
||||
});
|
||||
```
|
||||
|
||||
**HTTP POST Form**
|
||||
|
||||
```js
|
||||
var r = fetch('https://example.org/post', {
|
||||
method: 'POST',
|
||||
// Content-Type: application/x-www-form-urlencoded will be set automatically
|
||||
data: {username: 'example', password: 'password'}
|
||||
});
|
||||
```
|
||||
|
||||
## Script examples
|
||||
|
||||
**Two way audio for Dahua VTO**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
dahua_vto: |
|
||||
expr: let host = "admin:password@192.168.1.123";
|
||||
fetch("http://"+host+"/cgi-bin/configManager.cgi?action=setConfig&Encode[0].MainFormat[0].Audio.Compression=G.711A&Encode[0].MainFormat[0].Audio.Frequency=8000").ok
|
||||
? "rtsp://"+host+"/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif" : ""
|
||||
expr:
|
||||
let host = 'admin:password@192.168.1.123';
|
||||
|
||||
var r = fetch('http://' + host + '/cgi-bin/configManager.cgi?action=setConfig&Encode[0].MainFormat[0].Audio.Compression=G.711A&Encode[0].MainFormat[0].Audio.Frequency=8000');
|
||||
|
||||
'rtsp://' + host + '/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif'
|
||||
```
|
||||
|
||||
**dom.ru**
|
||||
|
||||
You can get credentials via:
|
||||
|
||||
- https://github.com/alexmorbo/domru (file `/share/domru/accounts`)
|
||||
- https://github.com/ad/domru
|
||||
You can get credentials from https://github.com/ad/domru
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
dom_ru: |
|
||||
expr: let camera = "99999999"; let token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; let operator = 99;
|
||||
fetch("https://myhome.novotelecom.ru/rest/v1/forpost/cameras/"+camera+"/video", {
|
||||
headers: {Authorization: "Bearer "+token, Operator: operator}
|
||||
expr:
|
||||
let camera = '***';
|
||||
let token = '***';
|
||||
let operator = '***';
|
||||
|
||||
fetch('https://myhome.proptech.ru/rest/v1/forpost/cameras/' + camera + '/video', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'User-Agent': 'Google sdkgphone64x8664 | Android 14 | erth | 8.26.0 (82600010) | 0 | 0 | 0',
|
||||
'Operator': operator
|
||||
}
|
||||
}).json().data.URL
|
||||
```
|
||||
|
||||
**dom.ufanet.ru**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
ufanet_ru: |
|
||||
expr:
|
||||
let username = '***';
|
||||
let password = '***';
|
||||
let cameraid = '***';
|
||||
|
||||
let r1 = fetch('https://ucams.ufanet.ru/api/internal/login/', {
|
||||
method: 'POST',
|
||||
data: {username: username, password: password}
|
||||
});
|
||||
let r2 = fetch('https://ucams.ufanet.ru/api/v0/cameras/this/?lang=ru', {
|
||||
method: 'POST',
|
||||
json: {'fields': ['token_l', 'server'], 'token_l_ttl': 3600, 'numbers': [cameraid]},
|
||||
}).json().results[0];
|
||||
|
||||
'rtsp://' + r2.server.domain + '/' + r2.number + '?token=' + r2.token_l
|
||||
```
|
||||
|
||||
**Parse HLS files from Apple**
|
||||
|
||||
Same example in two languages - python and expr.
|
||||
|
||||
@@ -46,6 +46,7 @@ func NewProducer(url string) (core.Producer, error) {
|
||||
{Name: core.CodecPCM, ClockRate: 16000},
|
||||
{Name: core.CodecPCMA, ClockRate: 16000},
|
||||
{Name: core.CodecPCMU, ClockRate: 16000},
|
||||
{Name: core.CodecPCML, ClockRate: 8000},
|
||||
{Name: core.CodecPCM, ClockRate: 8000},
|
||||
{Name: core.CodecPCMA, ClockRate: 8000},
|
||||
{Name: core.CodecPCMU, ClockRate: 8000},
|
||||
@@ -95,11 +96,11 @@ 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:
|
||||
s += "#audio=pcml/16000"
|
||||
s += "#audio=pcml/" + strconv.Itoa(int(codec.ClockRate))
|
||||
case core.CodecPCM:
|
||||
s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate))
|
||||
case core.CodecPCMA:
|
||||
|
||||
@@ -51,7 +51,11 @@ func apiHomekit(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
if id := r.Form.Get("id"); id != "" {
|
||||
api.ResponsePrettyJSON(w, servers[id])
|
||||
if srv := servers[id]; srv != nil {
|
||||
api.ResponsePrettyJSON(w, srv)
|
||||
} else {
|
||||
http.Error(w, "server not found", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
api.ResponsePrettyJSON(w, servers)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func Init() {
|
||||
Name string `yaml:"name"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
DevicePrivate string `yaml:"device_private"`
|
||||
CategoryID string `yaml:"category_id"`
|
||||
Pairings []string `yaml:"pairings"`
|
||||
} `yaml:"homekit"`
|
||||
}
|
||||
@@ -64,10 +65,12 @@ func Init() {
|
||||
|
||||
deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address
|
||||
name := calcName(conf.Name, deviceID)
|
||||
setupID := calcSetupID(id)
|
||||
|
||||
srv := &server{
|
||||
stream: id,
|
||||
pairings: conf.Pairings,
|
||||
setupID: setupID,
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
@@ -88,8 +91,8 @@ func Init() {
|
||||
hap.TXTProtoVersion: "1.1",
|
||||
hap.TXTStateNumber: "1",
|
||||
hap.TXTStatusFlags: hap.StatusNotPaired,
|
||||
hap.TXTCategory: hap.CategoryCamera,
|
||||
hap.TXTSetupHash: srv.hap.SetupHash(),
|
||||
hap.TXTCategory: calcCategoryID(conf.CategoryID),
|
||||
hap.TXTSetupHash: hap.SetupHash(setupID, deviceID),
|
||||
},
|
||||
}
|
||||
entries = append(entries, srv.mdns)
|
||||
|
||||
@@ -40,20 +40,29 @@ type server struct {
|
||||
accessory *hap.Accessory // HAP accessory
|
||||
consumer *homekit.Consumer
|
||||
proxyURL string
|
||||
setupID string
|
||||
stream string // stream name from YAML
|
||||
}
|
||||
|
||||
func (s *server) MarshalJSON() ([]byte, error) {
|
||||
v := struct {
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Paired int `json:"paired"`
|
||||
Conns []any `json:"connections"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Paired int `json:"paired,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
SetupCode string `json:"setup_code,omitempty"`
|
||||
SetupID string `json:"setup_id,omitempty"`
|
||||
Conns []any `json:"connections,omitempty"`
|
||||
}{
|
||||
Name: s.mdns.Name,
|
||||
DeviceID: s.mdns.Info[hap.TXTDeviceID],
|
||||
Paired: len(s.pairings),
|
||||
Conns: s.conns,
|
||||
Name: s.mdns.Name,
|
||||
DeviceID: s.mdns.Info[hap.TXTDeviceID],
|
||||
CategoryID: s.mdns.Info[hap.TXTCategory],
|
||||
Paired: len(s.pairings),
|
||||
Conns: s.conns,
|
||||
}
|
||||
if v.Paired == 0 {
|
||||
v.SetupCode = s.hap.Pin
|
||||
v.SetupID = s.setupID
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
@@ -376,3 +385,21 @@ func calcDevicePrivate(private, seed string) []byte {
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize])
|
||||
}
|
||||
|
||||
func calcSetupID(seed string) string {
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X%02X", b[44], b[46])
|
||||
}
|
||||
|
||||
func calcCategoryID(categoryID string) string {
|
||||
switch categoryID {
|
||||
case "bridge":
|
||||
return hap.CategoryBridge
|
||||
case "doorbell":
|
||||
return hap.CategoryDoorbell
|
||||
}
|
||||
if core.Atoi(categoryID) > 0 {
|
||||
return categoryID
|
||||
}
|
||||
return hap.CategoryCamera
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Pinggy
|
||||
|
||||
[Pinggy](https://pinggy.io/) - nice service for public tunnels to your local services.
|
||||
|
||||
**Features:**
|
||||
|
||||
- A free account does not require registration.
|
||||
- It does not require downloading third-party binaries and works over the SSH protocol.
|
||||
- Works with HTTP, TCP and UDP protocols.
|
||||
- Creates HTTPS for your HTTP services.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> A free account creates a tunnel with a random address that only works for an hour. It is suitable for testing purposes ONLY.
|
||||
|
||||
> [!CAUTION]
|
||||
> Public access to go2rtc without authorization puts your entire home network at risk. Use with caution.
|
||||
|
||||
**Why:**
|
||||
|
||||
- It's easy to set up HTTPS for testing two-way audio.
|
||||
- It's easy to check whether external access via WebRTC technology will work.
|
||||
- It's easy to share direct access to your RTSP or HTTP camera with the go2rtc developer. If such access is necessary to debug your problem.
|
||||
|
||||
## Configuration
|
||||
|
||||
You will find public links in the go2rtc log after startup.
|
||||
|
||||
**Tunnel to go2rtc WebUI.**
|
||||
|
||||
```yaml
|
||||
pinggy:
|
||||
tunnel: http://localhost:1984
|
||||
```
|
||||
|
||||
**Tunnel to RTSP camera.**
|
||||
|
||||
For example, you have camera: `rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0`
|
||||
|
||||
```yaml
|
||||
pinggy:
|
||||
tunnel: tcp://192.168.10.91:554
|
||||
```
|
||||
|
||||
In go2rtc logs you will get similar output:
|
||||
|
||||
```
|
||||
16:17:43.167 INF [pinggy] proxy url=tcp://abcde-123-123-123-123.a.free.pinggy.link:12345
|
||||
```
|
||||
|
||||
Now you have working stream:
|
||||
|
||||
```
|
||||
rtsp://admin:password@abcde-123-123-123-123.a.free.pinggy.link:12345/cam/realmonitor?channel=1&subtype=0
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
package pinggy
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/pinggy"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Tunnel string `yaml:"tunnel"`
|
||||
} `yaml:"pinggy"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if cfg.Mod.Tunnel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
log = app.GetLogger("pinggy")
|
||||
|
||||
u, err := url.Parse(cfg.Mod.Tunnel)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Send()
|
||||
return
|
||||
}
|
||||
|
||||
go proxy(u.Scheme, u.Host)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func proxy(proto, address string) {
|
||||
client, err := pinggy.NewClient(proto)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Send()
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
urls, err := client.GetURLs()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Send()
|
||||
return
|
||||
}
|
||||
|
||||
for _, s := range urls {
|
||||
log.Info().Str("url", s).Msgf("[pinggy] proxy")
|
||||
}
|
||||
|
||||
err = client.Proxy(address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Send()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+28
-17
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,248 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tuya"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("tuya", func(source string) (core.Producer, error) {
|
||||
return tuya.Dial(source)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/tuya", apiTuya)
|
||||
}
|
||||
|
||||
func apiTuya(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
region := query.Get("region")
|
||||
email := query.Get("email")
|
||||
password := query.Get("password")
|
||||
|
||||
if email == "" || password == "" || region == "" {
|
||||
http.Error(w, "email, password and region are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var tuyaRegion *tuya.Region
|
||||
for _, r := range tuya.AvailableRegions {
|
||||
if r.Host == region {
|
||||
tuyaRegion = &r
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tuyaRegion == nil {
|
||||
http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
httpClient := tuya.CreateHTTPClientWithSession()
|
||||
|
||||
_, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tuyaAPI, err := tuya.NewTuyaSmartApiClient(
|
||||
httpClient,
|
||||
tuyaRegion.Host,
|
||||
email,
|
||||
password,
|
||||
"",
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var devices []tuya.Device
|
||||
|
||||
homes, _ := tuyaAPI.GetHomeList()
|
||||
if homes != nil && len(homes.Result) > 0 {
|
||||
for _, home := range homes.Result {
|
||||
roomList, err := tuyaAPI.GetRoomList(strconv.Itoa(home.Gid))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, room := range roomList.Result {
|
||||
for _, device := range room.DeviceList {
|
||||
if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sharedHomes, _ := tuyaAPI.GetSharedHomeList()
|
||||
if sharedHomes != nil && len(sharedHomes.Result.SecurityWebCShareInfoList) > 0 {
|
||||
for _, sharedHome := range sharedHomes.Result.SecurityWebCShareInfoList {
|
||||
for _, device := range sharedHome.DeviceInfoList {
|
||||
if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
http.Error(w, "no cameras found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var items []*api.Source
|
||||
for _, device := range devices {
|
||||
cleanQuery := url.Values{}
|
||||
cleanQuery.Set("device_id", device.DeviceId)
|
||||
cleanQuery.Set("email", email)
|
||||
cleanQuery.Set("password", password)
|
||||
url := fmt.Sprintf("tuya://%s?%s", tuyaRegion.Host, cleanQuery.Encode())
|
||||
|
||||
items = append(items, &api.Source{
|
||||
Name: device.DeviceName,
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
|
||||
func login(client *http.Client, serverHost, email, password, countryCode string) (*tuya.LoginResult, error) {
|
||||
tokenResp, err := getLoginToken(client, serverHost, email, countryCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt password: %v", err)
|
||||
}
|
||||
|
||||
var loginResp *tuya.PasswordLoginResponse
|
||||
var url string
|
||||
|
||||
loginReq := tuya.PasswordLoginRequest{
|
||||
CountryCode: countryCode,
|
||||
Passwd: encryptedPassword,
|
||||
Token: tokenResp.Result.Token,
|
||||
IfEncrypt: 1,
|
||||
Options: `{"group":1}`,
|
||||
}
|
||||
|
||||
if tuya.IsEmailAddress(email) {
|
||||
url = fmt.Sprintf("https://%s/api/private/email/login", serverHost)
|
||||
loginReq.Email = email
|
||||
} else {
|
||||
url = fmt.Sprintf("https://%s/api/private/phone/login", serverHost)
|
||||
loginReq.Mobile = email
|
||||
}
|
||||
|
||||
loginResp, err = performLogin(client, url, loginReq, serverHost)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !loginResp.Success {
|
||||
return nil, errors.New(loginResp.ErrorMsg)
|
||||
}
|
||||
|
||||
return &loginResp.Result, nil
|
||||
}
|
||||
|
||||
func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) {
|
||||
url := fmt.Sprintf("https://%s/api/login/token", serverHost)
|
||||
|
||||
tokenReq := tuya.LoginTokenRequest{
|
||||
CountryCode: countryCode,
|
||||
Username: username,
|
||||
IsUid: false,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(tokenReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost))
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost))
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tokenResp tuya.LoginTokenResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !tokenResp.Success {
|
||||
return nil, errors.New("tuya: " + tokenResp.Msg)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) {
|
||||
jsonData, err := json.Marshal(loginReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost))
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost))
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var loginResp tuya.PasswordLoginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &loginResp, nil
|
||||
}
|
||||
|
||||
func containsDevice(devices []tuya.Device, deviceID string) bool {
|
||||
for _, device := range devices {
|
||||
if device.DeviceId == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -18,9 +18,11 @@ type Address struct {
|
||||
Priority uint32
|
||||
}
|
||||
|
||||
var stuns []string
|
||||
|
||||
func (a *Address) Host() string {
|
||||
if a.host == "stun" {
|
||||
ip, err := webrtc.GetCachedPublicIP()
|
||||
ip, err := webrtc.GetCachedPublicIP(stuns...)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func Init() {
|
||||
|
||||
cfg.Mod.Listen = ":8555"
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
{URLs: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -38,6 +38,16 @@ func Init() {
|
||||
address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
|
||||
for _, candidate := range cfg.Mod.Candidates {
|
||||
AddCandidate(network, candidate)
|
||||
|
||||
if strings.HasPrefix(candidate, "stun:") && stuns == nil {
|
||||
for _, ice := range cfg.Mod.IceServers {
|
||||
for _, url := range ice.URLs {
|
||||
if strings.HasPrefix(url, "stun:") {
|
||||
stuns = append(stuns, url[5:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.") {
|
||||
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"
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/nest"
|
||||
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/internal/onvif"
|
||||
"github.com/AlexxIT/go2rtc/internal/pinggy"
|
||||
"github.com/AlexxIT/go2rtc/internal/ring"
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtmp"
|
||||
@@ -37,16 +38,18 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/tapo"
|
||||
"github.com/AlexxIT/go2rtc/internal/tuya"
|
||||
"github.com/AlexxIT/go2rtc/internal/v4l2"
|
||||
"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
|
||||
@@ -95,10 +98,13 @@ func main() {
|
||||
{"ring", ring.Init},
|
||||
{"roborock", roborock.Init},
|
||||
{"tapo", tapo.Init},
|
||||
{"tuya", tuya.Init},
|
||||
{"xiaomi", xiaomi.Init},
|
||||
{"yandex", yandex.Init},
|
||||
// Helper modules
|
||||
{"debug", debug.Init},
|
||||
{"ngrok", ngrok.Init},
|
||||
{"pinggy", pinggy.Init},
|
||||
{"srtp", srtp.Init},
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -8,8 +8,19 @@ import (
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
const ADTSHeaderSize = 7
|
||||
|
||||
func IsADTS(b []byte) bool {
|
||||
return len(b) > 7 && b[0] == 0xFF && b[1]&0xF6 == 0xF0
|
||||
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
|
||||
// A 12 Syncword, all bits must be set to 1.
|
||||
// C 2 Layer, always set to 0.
|
||||
return len(b) >= ADTSHeaderSize && b[0] == 0xFF && b[1]&0b1111_0110 == 0xF0
|
||||
}
|
||||
|
||||
func HasCRC(b []byte) bool {
|
||||
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
|
||||
// D 1 Protection absence, set to 1 if there is no CRC and 0 if there is CRC.
|
||||
return b[1]&0b1 == 0
|
||||
}
|
||||
|
||||
func ADTSToCodec(b []byte) *core.Codec {
|
||||
@@ -58,7 +69,7 @@ func ADTSToCodec(b []byte) *core.Codec {
|
||||
func ReadADTSSize(b []byte) uint16 {
|
||||
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
|
||||
_ = b[5] // bounds
|
||||
return uint16(b[3]&0x03)<<(8+3) | uint16(b[4])<<3 | uint16(b[5]>>5)
|
||||
return uint16(b[3]&0b11)<<11 | uint16(b[4])<<3 | uint16(b[5]>>5)
|
||||
}
|
||||
|
||||
func WriteADTSSize(b []byte, size uint16) {
|
||||
|
||||
+25
-11
@@ -2,7 +2,7 @@ package aac
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -17,16 +17,22 @@ type Producer struct {
|
||||
func Open(r io.Reader) (*Producer, error) {
|
||||
rd := bufio.NewReader(r)
|
||||
|
||||
b, err := rd.Peek(8)
|
||||
b, err := rd.Peek(ADTSHeaderSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
codec := ADTSToCodec(b)
|
||||
if codec == nil {
|
||||
return nil, errors.New("adts: wrong header")
|
||||
}
|
||||
codec.PayloadType = core.PayloadTypeRAW
|
||||
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{ADTSToCodec(b)},
|
||||
Codecs: []*core.Codec{codec},
|
||||
},
|
||||
}
|
||||
return &Producer{
|
||||
@@ -42,14 +48,25 @@ func Open(r io.Reader) (*Producer, error) {
|
||||
|
||||
func (c *Producer) Start() error {
|
||||
for {
|
||||
b, err := c.rd.Peek(6)
|
||||
if err != nil {
|
||||
// read ADTS header
|
||||
adts := make([]byte, ADTSHeaderSize)
|
||||
if _, err := io.ReadFull(c.rd, adts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
auSize := ReadADTSSize(b)
|
||||
payload := make([]byte, 2+2+auSize)
|
||||
if _, err = io.ReadFull(c.rd, payload[4:]); err != nil {
|
||||
auSize := ReadADTSSize(adts) - ADTSHeaderSize
|
||||
|
||||
if HasCRC(adts) {
|
||||
// skip CRC after header
|
||||
if _, err := c.rd.Discard(2); err != nil {
|
||||
return err
|
||||
}
|
||||
auSize -= 2
|
||||
}
|
||||
|
||||
// read AAC payload after header
|
||||
payload := make([]byte, auSize)
|
||||
if _, err := io.ReadFull(c.rd, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -59,9 +76,6 @@ func (c *Producer) Start() error {
|
||||
continue
|
||||
}
|
||||
|
||||
payload[1] = 16 // header size in bits
|
||||
binary.BigEndian.PutUint16(payload[2:], auSize<<3)
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: core.Now90000()},
|
||||
Payload: payload,
|
||||
|
||||
+7
-4
@@ -8,7 +8,6 @@ import (
|
||||
)
|
||||
|
||||
const RTPPacketVersionAAC = 0
|
||||
const ADTSHeaderSize = 7
|
||||
|
||||
func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
var timestamp uint32
|
||||
@@ -65,7 +64,8 @@ func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
}
|
||||
|
||||
func RTPPay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
sequencer := rtp.NewRandomSequencer()
|
||||
var seq uint16
|
||||
var ts uint32
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
if packet.Version != RTPPacketVersionAAC {
|
||||
@@ -85,12 +85,15 @@ func RTPPay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
Timestamp: packet.Timestamp,
|
||||
SequenceNumber: seq,
|
||||
Timestamp: ts,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
handler(&clone)
|
||||
|
||||
seq++
|
||||
ts += AUTime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -259,9 +259,9 @@ func ParseCodecString(s string) *Codec {
|
||||
codec.Name = CodecPCM
|
||||
case "pcm_s16le", "s16le", "pcml":
|
||||
codec.Name = CodecPCML
|
||||
case "pcm_alaw", "alaw", "pcma":
|
||||
case "pcm_alaw", "alaw", "pcma", "g711a":
|
||||
codec.Name = CodecPCMA
|
||||
case "pcm_mulaw", "mulaw", "pcmu":
|
||||
case "pcm_mulaw", "mulaw", "pcmu", "g711u":
|
||||
codec.Name = CodecPCMU
|
||||
case "aac", "mpeg4-generic":
|
||||
codec.Name = CodecAAC
|
||||
|
||||
+18
-3
@@ -11,9 +11,9 @@ import (
|
||||
|
||||
const (
|
||||
BufferSize = 64 * 1024 // 64K
|
||||
ConnDialTimeout = time.Second * 3
|
||||
ConnDeadline = time.Second * 5
|
||||
ProbeTimeout = time.Second * 3
|
||||
ConnDialTimeout = 5 * time.Second
|
||||
ConnDeadline = 5 * time.Second
|
||||
ProbeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// Now90000 - timestamp for Video (clock rate = 90000 samples per second)
|
||||
@@ -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)
|
||||
|
||||
+108
-73
@@ -1,40 +1,78 @@
|
||||
package expr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/vm"
|
||||
)
|
||||
|
||||
func newRequest(method, url string, headers map[string]any, body string) (*http.Request, error) {
|
||||
func newRequest(rawURL string, options map[string]any) (*http.Request, error) {
|
||||
var method, contentType string
|
||||
var rd io.Reader
|
||||
|
||||
if method == "" {
|
||||
// method from js fetch
|
||||
if s, ok := options["method"].(string); ok {
|
||||
method = s
|
||||
} else {
|
||||
method = "GET"
|
||||
}
|
||||
if body != "" {
|
||||
rd = strings.NewReader(body)
|
||||
|
||||
// params key from python requests
|
||||
if kv, ok := options["params"].(map[string]any); ok {
|
||||
rawURL += "?" + url.Values(kvToString(kv)).Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, rd)
|
||||
// json key from python requests
|
||||
// data key from python requests
|
||||
// body key from js fetch
|
||||
if v, ok := options["json"]; ok {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentType = "application/json"
|
||||
rd = bytes.NewReader(b)
|
||||
} else if kv, ok := options["data"].(map[string]any); ok {
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
rd = strings.NewReader(url.Values(kvToString(kv)).Encode())
|
||||
} else if s, ok := options["body"].(string); ok {
|
||||
rd = strings.NewReader(s)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, rawURL, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, fmt.Sprintf("%v", v))
|
||||
if kv, ok := options["headers"].(map[string]any); ok {
|
||||
req.Header = kvToString(kv)
|
||||
}
|
||||
|
||||
if contentType != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func kvToString(kv map[string]any) map[string][]string {
|
||||
dst := make(map[string][]string, len(kv))
|
||||
for k, v := range kv {
|
||||
dst[k] = []string{fmt.Sprintf("%v", v)}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func regExp(params ...any) (*regexp.Regexp, error) {
|
||||
exp := params[0].(string)
|
||||
if len(params) >= 2 {
|
||||
@@ -49,72 +87,69 @@ func regExp(params ...any) (*regexp.Regexp, error) {
|
||||
return regexp.Compile(exp)
|
||||
}
|
||||
|
||||
var Options = []expr.Option{
|
||||
expr.Function(
|
||||
"fetch",
|
||||
func(params ...any) (any, error) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
url := params[0].(string)
|
||||
|
||||
if len(params) == 2 {
|
||||
options := params[1].(map[string]any)
|
||||
method, _ := options["method"].(string)
|
||||
headers, _ := options["headers"].(map[string]any)
|
||||
body, _ := options["body"].(string)
|
||||
req, err = newRequest(method, url, headers, body)
|
||||
} else {
|
||||
req, err = http.NewRequest("GET", url, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := tcp.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
|
||||
return map[string]any{
|
||||
"ok": res.StatusCode < 400,
|
||||
"status": res.Status,
|
||||
"text": string(b),
|
||||
"json": func() (v any) {
|
||||
_ = json.Unmarshal(b, &v)
|
||||
return
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
//new(func(url string) map[string]any),
|
||||
//new(func(url string, options map[string]any) map[string]any),
|
||||
),
|
||||
expr.Function(
|
||||
"match",
|
||||
func(params ...any) (any, error) {
|
||||
re, err := regExp(params[1:]...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
str := params[0].(string)
|
||||
return re.FindStringSubmatch(str), nil
|
||||
},
|
||||
//new(func(str, expr string) []string),
|
||||
//new(func(str, expr, flags string) []string),
|
||||
),
|
||||
expr.Function(
|
||||
"RegExp",
|
||||
func(params ...any) (any, error) {
|
||||
return regExp(params)
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
func Compile(input string) (*vm.Program, error) {
|
||||
return expr.Compile(input, Options...)
|
||||
// support http sessions
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
return expr.Compile(
|
||||
input,
|
||||
expr.Function(
|
||||
"fetch",
|
||||
func(params ...any) (any, error) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
rawURL := params[0].(string)
|
||||
|
||||
if len(params) == 2 {
|
||||
options := params[1].(map[string]any)
|
||||
req, err = newRequest(rawURL, options)
|
||||
} else {
|
||||
req, err = http.NewRequest("GET", rawURL, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
|
||||
return map[string]any{
|
||||
"ok": res.StatusCode < 400,
|
||||
"status": res.Status,
|
||||
"text": string(b),
|
||||
"json": func() (v any) {
|
||||
_ = json.Unmarshal(b, &v)
|
||||
return
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
//new(func(url string) map[string]any),
|
||||
//new(func(url string, options map[string]any) map[string]any),
|
||||
),
|
||||
expr.Function(
|
||||
"match",
|
||||
func(params ...any) (any, error) {
|
||||
re, err := regExp(params[1:]...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
str := params[0].(string)
|
||||
return re.FindStringSubmatch(str), nil
|
||||
},
|
||||
//new(func(str, expr string) []string),
|
||||
//new(func(str, expr, flags string) []string),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Eval(input string, env any) (any, error) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package flv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTimeToRTP(t *testing.T) {
|
||||
// Reolink camera has 20 FPS
|
||||
// Video timestamp increases by 50ms, SampleRate 90000, RTP timestamp increases by 4500
|
||||
// Audio timestamp increases by 64ms, SampleRate 16000, RTP timestamp increases by 1024
|
||||
frameN := 1
|
||||
for i := 0; i < 32; i++ {
|
||||
// 1000ms/(90000/4500) = 50ms
|
||||
require.Equal(t, uint32(frameN*4500), TimeToRTP(uint32(frameN*50), 90000))
|
||||
// 1000ms/(16000/1024) = 64ms
|
||||
require.Equal(t, uint32(frameN*1024), TimeToRTP(uint32(frameN*64), 16000))
|
||||
frameN *= 2
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -34,7 +34,7 @@ func (m *Muxer) GetInit() []byte {
|
||||
switch codec.Name {
|
||||
case core.CodecH264:
|
||||
b[4] |= FlagsVideo
|
||||
obj["videocodecid"] = CodecAVC
|
||||
obj["videocodecid"] = CodecH264
|
||||
|
||||
case core.CodecAAC:
|
||||
b[4] |= FlagsAudio
|
||||
|
||||
+17
-8
@@ -44,7 +44,9 @@ const (
|
||||
TagData = 18
|
||||
|
||||
CodecAAC = 10
|
||||
CodecAVC = 7
|
||||
|
||||
CodecH264 = 7
|
||||
CodecHEVC = 12
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -207,15 +209,18 @@ func (c *Producer) probe() error {
|
||||
} else {
|
||||
_ = pkt.Payload[0] >> 4 // FrameType
|
||||
|
||||
if codecID := pkt.Payload[0] & 0b1111; codecID != CodecAVC {
|
||||
continue
|
||||
}
|
||||
|
||||
if packetType := pkt.Payload[1]; packetType != PacketTypeAVCHeader { // check if header
|
||||
continue
|
||||
}
|
||||
|
||||
codec = h264.ConfigToCodec(pkt.Payload[5:])
|
||||
switch codecID := pkt.Payload[0] & 0b1111; codecID {
|
||||
case CodecH264:
|
||||
codec = h264.ConfigToCodec(pkt.Payload[5:])
|
||||
case CodecHEVC:
|
||||
codec = h265.ConfigToCodec(pkt.Payload[5:])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
media := &core.Media{
|
||||
@@ -294,8 +299,12 @@ func (c *Producer) readPacket() (*rtp.Packet, error) {
|
||||
return pkt, nil
|
||||
}
|
||||
|
||||
func TimeToRTP(timeMS uint32, clockRate uint32) uint32 {
|
||||
return timeMS * clockRate / 1000
|
||||
// TimeToRTP convert time in milliseconds to RTP time
|
||||
func TimeToRTP(timeMS, clockRate uint32) uint32 {
|
||||
// for clockRates 90000, 16000, 8000, etc. - we can use:
|
||||
// return timeMS * (clockRate / 1000)
|
||||
// but for clockRates 44100, 22050, 11025 - we should use:
|
||||
return uint32(uint64(timeMS) * uint64(clockRate) / 1000)
|
||||
}
|
||||
|
||||
func isExHeader(data []byte) bool {
|
||||
|
||||
+36
-4
@@ -5,6 +5,7 @@ import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
@@ -68,13 +69,13 @@ func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(conn)
|
||||
}
|
||||
|
||||
func (c *Conn) Read(p []byte) (n int, err error) {
|
||||
func (c *Conn) read() (b []byte, err error) {
|
||||
verify := make([]byte, 4)
|
||||
if _, err = io.ReadFull(c.rd, verify); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF)
|
||||
n := int(binary.BigEndian.Uint32(verify) & 0xFFFFFF)
|
||||
|
||||
ciphertext := make([]byte, n+hap.Overhead)
|
||||
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
||||
@@ -85,15 +86,46 @@ func (c *Conn) Read(p []byte) (n int, err error) {
|
||||
binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
|
||||
c.decryptCnt++
|
||||
|
||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify)
|
||||
|
||||
c.recv += n
|
||||
|
||||
return chacha20poly1305.DecryptAndVerify(c.decryptKey, ciphertext[:0], nonce, ciphertext, verify)
|
||||
}
|
||||
|
||||
func (c *Conn) Read(p []byte) (n int, err error) {
|
||||
b, err := c.read()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n = copy(p, b)
|
||||
if len(b) > n {
|
||||
err = errors.New("hds: read buffer too small")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Conn) WriteTo(w io.Writer) (int64, error) {
|
||||
var total int64
|
||||
for {
|
||||
b, err := c.read()
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
|
||||
n, err := w.Write(b)
|
||||
total += int64(n)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
n = len(b)
|
||||
|
||||
if n > 0xFFFFFF {
|
||||
return 0, errors.New("hds: write buffer too big")
|
||||
}
|
||||
|
||||
verify := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(verify, 0x01000000|uint32(n))
|
||||
if _, err = c.wr.Write(verify); err != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package hap
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -99,6 +101,12 @@ func GenerateUUID() string {
|
||||
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
|
||||
}
|
||||
|
||||
func SetupHash(setupID, deviceID string) string {
|
||||
// should be setup_id (random 4 alphanum) + device_id (mac address)
|
||||
b := sha512.Sum512([]byte(setupID + deviceID))
|
||||
return base64.StdEncoding.EncodeToString(b[:4])
|
||||
}
|
||||
|
||||
func Append(items ...any) (b []byte) {
|
||||
for _, item := range items {
|
||||
switch v := item.(type) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package hap
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -36,13 +35,6 @@ func (s *Server) ServerPublic() []byte {
|
||||
// return StatusPaired
|
||||
//}
|
||||
|
||||
func (s *Server) SetupHash() string {
|
||||
// should be setup_id (random 4 alphanum) + device_id (mac address)
|
||||
// but device_id is random, so OK
|
||||
b := sha512.Sum512([]byte(s.DeviceID))
|
||||
return base64.StdEncoding.EncodeToString(b[:4])
|
||||
}
|
||||
|
||||
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) {
|
||||
// STEP 1. Request from iPhone
|
||||
var plainM1 struct {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagNFC = 1
|
||||
FlagIP = 2
|
||||
FlagBLE = 4
|
||||
FlagWAC = 8 // Wireless Accessory Configuration (WAC)/Apples MFi
|
||||
)
|
||||
|
||||
func GenerateSetupURI(category, pin, setupID string) string {
|
||||
c, _ := strconv.Atoi(category)
|
||||
p, _ := strconv.Atoi(strings.ReplaceAll(pin, "-", ""))
|
||||
payload := int64(c&0xFF)<<31 | int64(FlagIP&0xF)<<27 | int64(p&0x7FFFFFF)
|
||||
return "X-HM://" + FormatInt36(payload, 9) + setupID
|
||||
}
|
||||
|
||||
// FormatInt36 equal to strings.ToUpper(fmt.Sprintf("%0"+strconv.Itoa(n)+"s", strconv.FormatInt(value, 36)))
|
||||
func FormatInt36(value int64, n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := n - 1; 0 <= i; i-- {
|
||||
b[i] = digits[value%36]
|
||||
value /= 36
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
const digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
@@ -0,0 +1,18 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFormatAlphaNum(t *testing.T) {
|
||||
value := int64(999)
|
||||
n := 5
|
||||
s1 := strings.ToUpper(fmt.Sprintf("%0"+strconv.Itoa(n)+"s", strconv.FormatInt(value, 36)))
|
||||
s2 := FormatInt36(value, n)
|
||||
require.Equal(t, s1, s2)
|
||||
}
|
||||
@@ -29,9 +29,9 @@ type Client struct {
|
||||
|
||||
stream *camera.Stream
|
||||
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Bitrate int // in bits/s
|
||||
MaxWidth int `json:"-"`
|
||||
MaxHeight int `json:"-"`
|
||||
Bitrate int `json:"-"` // in bits/s
|
||||
}
|
||||
|
||||
func Dial(rawURL string, server *srtp.Server) (*Client, error) {
|
||||
|
||||
@@ -330,6 +330,7 @@ const (
|
||||
StreamTypeH264 = 0x1B
|
||||
StreamTypeH265 = 0x24
|
||||
StreamTypePCMATapo = 0x90
|
||||
StreamTypePCMUTapo = 0x91
|
||||
StreamTypePrivateOPUS = 0xEB
|
||||
)
|
||||
|
||||
@@ -392,7 +393,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) {
|
||||
|
||||
//p.Timestamp += aac.RTPTimeSize(pkt.Payload) // update next timestamp!
|
||||
|
||||
case StreamTypePCMATapo:
|
||||
case StreamTypePCMATapo, StreamTypePCMUTapo:
|
||||
p.Sequence++
|
||||
|
||||
pkt = &rtp.Packet{
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package opus
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func Log(handler core.HandlerFunc) core.HandlerFunc {
|
||||
var ts uint32
|
||||
|
||||
return func(pkt *rtp.Packet) {
|
||||
if ts == 0 {
|
||||
ts = pkt.Timestamp
|
||||
}
|
||||
|
||||
toc := pkt.Payload[0]
|
||||
//config := toc >> 3
|
||||
code := toc & 0b11
|
||||
|
||||
frame := parseFrameSize(toc)
|
||||
rate := parseSampleRate(toc)
|
||||
|
||||
log.Printf(
|
||||
"[RTP/OPUS] frame=%s rate=%5d code=%d size=%6d ts=%10d dt=%5d pt=%2d ssrc=%d seq=%d mark=%t",
|
||||
frame, rate, code, len(pkt.Payload), pkt.Timestamp, pkt.Timestamp-ts, pkt.PayloadType, pkt.SSRC, pkt.SequenceNumber, pkt.Marker,
|
||||
)
|
||||
|
||||
ts = pkt.Timestamp
|
||||
|
||||
handler(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFrameSize(toc byte) time.Duration {
|
||||
switch toc >> 3 {
|
||||
case 0, 4, 8, 12, 14, 18, 22, 26, 30:
|
||||
return 10_000_000
|
||||
case 1, 5, 9, 13, 15, 19, 23, 27, 31:
|
||||
return 20_000_000
|
||||
case 2, 6, 10:
|
||||
return 40_000_000
|
||||
case 3, 7, 11:
|
||||
return 60_000_000
|
||||
case 16, 20, 24, 28:
|
||||
return 2_500_000
|
||||
case 17, 21, 25, 29:
|
||||
return 5_000_000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseSampleRate(toc byte) uint16 {
|
||||
switch toc >> 3 {
|
||||
case 0, 1, 2, 3, 16, 17, 18, 19:
|
||||
return 8000
|
||||
case 4, 5, 6, 7:
|
||||
return 12000
|
||||
case 8, 9, 10, 11, 20, 21, 22, 23:
|
||||
return 16000
|
||||
case 12, 13, 24, 25, 26, 27:
|
||||
return 24000
|
||||
case 14, 15, 28, 29, 30, 31:
|
||||
return 48000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package opus
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Mode string
|
||||
SampleRate uint16
|
||||
FrameSize time.Duration
|
||||
Channels byte
|
||||
Frames byte
|
||||
}
|
||||
|
||||
func UnmarshalHeader(b []byte) *Header {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6716#section-3.1
|
||||
b0 := b[0]
|
||||
config := b0 >> 3
|
||||
return &Header{
|
||||
Mode: parseMode(config),
|
||||
SampleRate: parseSampleRate(config),
|
||||
FrameSize: parseFrameSize(config),
|
||||
Channels: parseChannels(b0 >> 2 & 0b1),
|
||||
Frames: parseFrames(b0 & 0b11),
|
||||
}
|
||||
}
|
||||
|
||||
func parseMode(config byte) string {
|
||||
if config <= 11 {
|
||||
return "silk"
|
||||
}
|
||||
if config <= 15 {
|
||||
return "hybrid"
|
||||
}
|
||||
return "celt"
|
||||
}
|
||||
|
||||
func parseSampleRate(config byte) uint16 {
|
||||
switch config {
|
||||
case 0, 1, 2, 3, 16, 17, 18, 19:
|
||||
return 8000 // NB (narrowband)
|
||||
case 4, 5, 6, 7:
|
||||
return 12000 // MB (medium-band)
|
||||
case 8, 9, 10, 11, 20, 21, 22, 23:
|
||||
return 16000 // WB (wideband)
|
||||
case 12, 13, 24, 25, 26, 27:
|
||||
return 24000 // SWB (super-wideband)
|
||||
case 14, 15, 28, 29, 30, 31:
|
||||
return 48000 // FB (fullband)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseFrameSize(config byte) time.Duration {
|
||||
switch config {
|
||||
case 0, 4, 8, 12, 14, 18, 22, 26, 30:
|
||||
return 10_000_000
|
||||
case 1, 5, 9, 13, 15, 19, 23, 27, 31:
|
||||
return 20_000_000
|
||||
case 2, 6, 10:
|
||||
return 40_000_000
|
||||
case 3, 7, 11:
|
||||
return 60_000_000
|
||||
case 16, 20, 24, 28:
|
||||
return 2_500_000
|
||||
case 17, 21, 25, 29:
|
||||
return 5_000_000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseChannels(s byte) byte {
|
||||
if s == 1 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func parseFrames(c byte) byte {
|
||||
switch c {
|
||||
case 0:
|
||||
return 1
|
||||
case 1, 2:
|
||||
return 2
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package pinggy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
SSH *ssh.Client
|
||||
TCP net.Listener
|
||||
API *http.Client
|
||||
}
|
||||
|
||||
func NewClient(proto string) (*Client, error) {
|
||||
switch proto {
|
||||
case "http", "tcp", "tls", "tlstcp":
|
||||
case "":
|
||||
proto = "http"
|
||||
default:
|
||||
return nil, errors.New("pinggy: unsupported proto: " + proto)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: "auth+" + proto,
|
||||
Auth: []ssh.AuthMethod{ssh.Password("nopass")},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", "a.pinggy.io:443", config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ln, err := client.Listen("tcp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
SSH: client,
|
||||
TCP: ln,
|
||||
API: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return client.Dial(network, addr)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if proto == "http" {
|
||||
if err = c.NewSession(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return errors.Join(c.SSH.Close(), c.TCP.Close())
|
||||
}
|
||||
|
||||
func (c *Client) NewSession() error {
|
||||
session, err := c.SSH.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.Shell()
|
||||
}
|
||||
|
||||
func (c *Client) GetURLs() ([]string, error) {
|
||||
res, err := c.API.Get("http://localhost:4300/urls")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var v struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(res.Body).Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v.URLs, nil
|
||||
}
|
||||
|
||||
func (c *Client) Proxy(address string) error {
|
||||
defer c.TCP.Close()
|
||||
|
||||
for {
|
||||
conn, err := c.TCP.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go proxy(conn, address)
|
||||
}
|
||||
}
|
||||
|
||||
func proxy(conn1 net.Conn, address string) {
|
||||
defer conn1.Close()
|
||||
|
||||
conn2, err := net.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn2.Close()
|
||||
|
||||
go io.Copy(conn2, conn1)
|
||||
io.Copy(conn1, conn2)
|
||||
}
|
||||
|
||||
// DialTLS like ssh.Dial but with TLS
|
||||
//func DialTLS(network, addr, sni string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||
// conn, err := net.DialTimeout(network, addr, config.Timeout)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// conn = tls.Client(conn, &tls.Config{ServerName: sni, InsecureSkipVerify: sni == ""})
|
||||
// c, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return ssh.NewClient(c, chans, reqs), nil
|
||||
//}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/pcm"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
@@ -185,6 +186,8 @@ func (c *Client) Handle() error {
|
||||
rd := multipart.NewReader(c.conn1, "--device-stream-boundary--")
|
||||
demux := mpegts.NewDemuxer()
|
||||
|
||||
var transcode func([]byte) []byte
|
||||
|
||||
for {
|
||||
p, err := rd.NextRawPart()
|
||||
if err != nil {
|
||||
@@ -226,6 +229,23 @@ func (c *Client) Handle() error {
|
||||
return err2
|
||||
}
|
||||
|
||||
if pkt.PayloadType == mpegts.StreamTypePCMUTapo {
|
||||
// TODO: rewrite this part in the future
|
||||
// Some cameras in the new firmware began to use PCMU/16000.
|
||||
// https://github.com/AlexxIT/go2rtc/issues/1954
|
||||
// I don't know why Tapo considers this an improvement. The codec is no better than the previous one.
|
||||
// Unfortunately, we don't know in advance what codec the camera will use.
|
||||
// Therefore, it's easier to transcode to a standard codec that all Tapo cameras have.
|
||||
if transcode == nil {
|
||||
transcode = pcm.Transcode(
|
||||
&core.Codec{Name: core.CodecPCMA, ClockRate: 8000},
|
||||
&core.Codec{Name: core.CodecPCMU, ClockRate: 16000},
|
||||
)
|
||||
}
|
||||
pkt.PayloadType = mpegts.StreamTypePCMATapo
|
||||
pkt.Payload = transcode(pkt.Payload)
|
||||
}
|
||||
|
||||
for _, receiver := range c.receivers {
|
||||
if receiver.ID == pkt.PayloadType {
|
||||
mpegts.TimestampToRTP(pkt, receiver.Codec)
|
||||
|
||||
+1
-5
@@ -8,7 +8,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
@@ -69,10 +68,7 @@ func Do(req *http.Request) (*http.Response, error) {
|
||||
return tlsConn, err
|
||||
}
|
||||
|
||||
client = &http.Client{
|
||||
Timeout: time.Second * 5000,
|
||||
Transport: transport,
|
||||
}
|
||||
client = &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
user := req.URL.User
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## Useful links
|
||||
|
||||
- https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se
|
||||
- https://github.com/tuya/webrtc-demo-go
|
||||
- https://github.com/bacco007/HomeAssistantConfig/blob/master/custom_components/xtend_tuya/multi_manager/tuya_iot/ipc/webrtc/xt_tuya_iot_webrtc_manager.py
|
||||
- https://github.com/tuya/tuya-device-sharing-sdk
|
||||
- https://github.com/make-all/tuya-local/blob/main/custom_components/tuya_local/cloud.py
|
||||
- https://ipc-us.ismartlife.me/
|
||||
- https://protect-us.ismartlife.me/
|
||||
@@ -0,0 +1,555 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/rtp"
|
||||
pion "github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
api TuyaAPI
|
||||
conn *webrtc.Conn
|
||||
pc *pion.PeerConnection
|
||||
connected core.Waiter
|
||||
closed bool
|
||||
|
||||
// HEVC only:
|
||||
dc *pion.DataChannel
|
||||
videoSSRC *uint32
|
||||
audioSSRC *uint32
|
||||
streamType int
|
||||
isHEVC bool
|
||||
handlersMu sync.RWMutex
|
||||
handlers map[uint32]func(*rtp.Packet)
|
||||
}
|
||||
|
||||
type DataChannelMessage struct {
|
||||
Type string `json:"type"` // "codec", "start", "recv", "complete"
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// RecvMessage contains SSRC values for video/audio streams
|
||||
type RecvMessage struct {
|
||||
Video struct {
|
||||
SSRC uint32 `json:"ssrc"`
|
||||
} `json:"video"`
|
||||
Audio struct {
|
||||
SSRC uint32 `json:"ssrc"`
|
||||
} `json:"audio"`
|
||||
}
|
||||
|
||||
func Dial(rawURL string) (core.Producer, error) {
|
||||
escapedURL := strings.ReplaceAll(rawURL, "#", "%23")
|
||||
u, err := url.Parse(escapedURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
|
||||
// Tuya Smart API
|
||||
email := query.Get("email")
|
||||
password := query.Get("password")
|
||||
|
||||
// Tuya Cloud API
|
||||
uid := query.Get("uid")
|
||||
clientId := query.Get("client_id")
|
||||
clientSecret := query.Get("client_secret")
|
||||
|
||||
// Shared params
|
||||
deviceId := query.Get("device_id")
|
||||
|
||||
// Stream params
|
||||
streamResolution := query.Get("resolution")
|
||||
|
||||
useSmartApi := deviceId != "" && email != "" && password != ""
|
||||
useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != ""
|
||||
|
||||
if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") {
|
||||
streamResolution = "hd"
|
||||
}
|
||||
|
||||
if !useSmartApi && !useCloudApi {
|
||||
return nil, errors.New("tuya: wrong query params")
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
handlers: make(map[uint32]func(*rtp.Packet)),
|
||||
}
|
||||
|
||||
if useSmartApi {
|
||||
if client.api, err = NewTuyaSmartApiClient(nil, u.Hostname(), email, password, deviceId); err != nil {
|
||||
return nil, fmt.Errorf("tuya: %w", err)
|
||||
}
|
||||
} else {
|
||||
if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret); err != nil {
|
||||
return nil, fmt.Errorf("tuya: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.api.Init(); err != nil {
|
||||
return nil, fmt.Errorf("tuya: %w", err)
|
||||
}
|
||||
|
||||
client.streamType = client.api.GetStreamType(streamResolution)
|
||||
client.isHEVC = client.api.IsHEVC(client.streamType)
|
||||
|
||||
// Create a new PeerConnection
|
||||
conf := pion.Configuration{
|
||||
ICEServers: client.api.GetICEServers(),
|
||||
ICETransportPolicy: pion.ICETransportPolicyAll,
|
||||
BundlePolicy: pion.BundlePolicyMaxBundle,
|
||||
}
|
||||
|
||||
api, err := webrtc.NewAPI()
|
||||
if err != nil {
|
||||
client.Close(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.pc, err = api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
client.Close(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// protect from sending ICE candidate before Offer
|
||||
var sendOffer core.Waiter
|
||||
|
||||
// protect from blocking on errors
|
||||
defer sendOffer.Done(nil)
|
||||
|
||||
// Create new WebRTC connection
|
||||
client.conn = webrtc.NewConn(client.pc)
|
||||
client.conn.FormatName = "tuya/webrtc"
|
||||
client.conn.Mode = core.ModeActiveProducer
|
||||
client.conn.Protocol = "mqtt"
|
||||
|
||||
mqttClient := client.api.GetMqtt()
|
||||
if mqttClient == nil {
|
||||
err = errors.New("tuya: no mqtt client")
|
||||
client.Close(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set up MQTT handlers
|
||||
mqttClient.handleAnswer = func(answer AnswerFrame) {
|
||||
// fmt.Printf("tuya: answer: %s\n", answer.Sdp)
|
||||
|
||||
desc := pion.SessionDescription{
|
||||
Type: pion.SDPTypePranswer,
|
||||
SDP: answer.Sdp,
|
||||
}
|
||||
|
||||
if err = client.pc.SetRemoteDescription(desc); err != nil {
|
||||
client.Close(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.conn.SetAnswer(answer.Sdp); err != nil {
|
||||
client.Close(err)
|
||||
return
|
||||
}
|
||||
|
||||
if client.isHEVC {
|
||||
// Tuya responds with H264/90000 even for HEVC streams
|
||||
// So we need to replace video codecs with HEVC ones from API
|
||||
for _, media := range client.conn.Medias {
|
||||
if media.Kind == core.KindVideo {
|
||||
codecs := client.api.GetVideoCodecs()
|
||||
if codecs != nil {
|
||||
media.Codecs = codecs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio codecs from API as well
|
||||
// Tuya responds with multiple audio codecs (PCMU, PCMA)
|
||||
// But the quality is bad if we use PCMU and skill only has PCMA
|
||||
for _, media := range client.conn.Medias {
|
||||
if media.Kind == core.KindAudio {
|
||||
codecs := client.api.GetAudioCodecs()
|
||||
if codecs != nil {
|
||||
media.Codecs = codecs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mqttClient.handleCandidate = func(candidate CandidateFrame) {
|
||||
// fmt.Printf("tuya: candidate: %s\n", candidate.Candidate)
|
||||
|
||||
if candidate.Candidate != "" {
|
||||
client.conn.AddCandidate(candidate.Candidate)
|
||||
if err != nil {
|
||||
client.Close(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mqttClient.handleDisconnect = func() {
|
||||
// fmt.Println("tuya: disconnect")
|
||||
client.Close(errors.New("mqtt: disconnect"))
|
||||
}
|
||||
|
||||
mqttClient.handleError = func(err error) {
|
||||
// fmt.Printf("tuya: error: %s\n", err.Error())
|
||||
client.Close(err)
|
||||
}
|
||||
|
||||
if client.isHEVC {
|
||||
maxRetransmits := uint16(5)
|
||||
ordered := true
|
||||
client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{
|
||||
MaxRetransmits: &maxRetransmits,
|
||||
Ordered: &ordered,
|
||||
})
|
||||
|
||||
// DataChannel receives two types of messages:
|
||||
// 1. String messages: Control messages (codec, recv)
|
||||
// 2. Binary messages: RTP packets with video/audio
|
||||
client.dc.OnMessage(func(msg pion.DataChannelMessage) {
|
||||
if msg.IsString {
|
||||
// Handle control messages (codec, recv, etc.)
|
||||
if connected, err := client.probe(msg); err != nil {
|
||||
client.Close(err)
|
||||
} else if connected {
|
||||
client.connected.Done(nil)
|
||||
}
|
||||
} else {
|
||||
// Handle RTP packets - Route by SSRC retrieved from "recv" message
|
||||
packet := &rtp.Packet{}
|
||||
if err := packet.Unmarshal(msg.Data); err != nil {
|
||||
// Skip invalid packets
|
||||
return
|
||||
}
|
||||
|
||||
if handler, ok := client.getHandler(packet.SSRC); ok {
|
||||
handler(packet)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client.dc.OnError(func(err error) {
|
||||
// fmt.Printf("tuya: datachannel error: %s\n", err.Error())
|
||||
client.Close(err)
|
||||
})
|
||||
|
||||
client.dc.OnClose(func() {
|
||||
// fmt.Println("tuya: datachannel closed")
|
||||
client.Close(errors.New("datachannel: closed"))
|
||||
})
|
||||
|
||||
client.dc.OnOpen(func() {
|
||||
// fmt.Println("tuya: datachannel opened")
|
||||
|
||||
codecRequest, _ := json.Marshal(DataChannelMessage{
|
||||
Type: "codec",
|
||||
Msg: "",
|
||||
})
|
||||
|
||||
if err := client.sendMessageToDataChannel(codecRequest); err != nil {
|
||||
client.Close(fmt.Errorf("failed to send codec request: %w", err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up pc handler
|
||||
client.conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
_ = sendOffer.Wait()
|
||||
if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil {
|
||||
client.Close(err)
|
||||
}
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateNew:
|
||||
break
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
break
|
||||
case pion.PeerConnectionStateConnected:
|
||||
// On HEVC, wait for DataChannel to be opened and camera to send codec info
|
||||
if !client.isHEVC {
|
||||
if streamResolution == "hd" {
|
||||
_ = mqttClient.SendResolution(0)
|
||||
}
|
||||
client.connected.Done(nil)
|
||||
}
|
||||
case pion.PeerConnectionStateClosed:
|
||||
client.Close(errors.New("webrtc: " + msg.String()))
|
||||
default:
|
||||
// client.Close(errors.New("webrtc: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Audio first, otherwise tuya will send corrupt sdp
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindAudio, Direction: core.DirectionSendRecv},
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// Create offer
|
||||
offer, err := client.conn.CreateOffer(medias)
|
||||
if err != nil {
|
||||
client.Close(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload
|
||||
// https://github.com/tuya/webrtc-demo-go/blob/04575054f18ccccb6bc9d82939dd46d449544e20/static/js/main.js#L224
|
||||
re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`)
|
||||
offer = re.ReplaceAllString(offer, "")
|
||||
|
||||
// Send offer
|
||||
if err := mqttClient.SendOffer(offer, streamResolution, client.streamType, client.isHEVC); err != nil {
|
||||
err = fmt.Errorf("tuya: %w", err)
|
||||
client.Close(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done(nil)
|
||||
|
||||
// Wait for connection
|
||||
if err = client.connected.Wait(); err != nil {
|
||||
err = fmt.Errorf("tuya: %w", err)
|
||||
client.Close(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
return c.conn.GetMedias()
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return c.conn.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
localTrack := c.conn.GetSenderTrack(media.ID)
|
||||
if localTrack == nil {
|
||||
return errors.New("webrtc: can't get track")
|
||||
}
|
||||
|
||||
// DISABLED: Speaker Protocol 312 command
|
||||
// JavaScript client doesn't send this on first call either
|
||||
// Only subsequent calls (when speakerChloron is set) send Protocol 312
|
||||
// mqttClient := c.api.GetMqtt()
|
||||
// if mqttClient != nil {
|
||||
// _ = mqttClient.SendSpeaker(1)
|
||||
// }
|
||||
|
||||
payloadType := codec.PayloadType
|
||||
|
||||
sender := core.NewSender(media, codec)
|
||||
|
||||
switch track.Codec.Name {
|
||||
case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML:
|
||||
// Frame size affects audio delay with Tuya cameras:
|
||||
// Browser sends standard 20ms frames (160 bytes for G.711), but this causes
|
||||
// up to 4s delay on some Tuya cameras. Increasing to 240 bytes (30ms) reduces
|
||||
// delay to ~2s. Higher values (320+ bytes) don't work and cause issues.
|
||||
// Using 240 bytes (30ms) as optimal balance between latency and stability.
|
||||
frameSize := 240
|
||||
|
||||
var buf []byte
|
||||
var seq uint16
|
||||
var ts uint32
|
||||
|
||||
sender.Handler = func(packet *rtp.Packet) {
|
||||
buf = append(buf, packet.Payload...)
|
||||
|
||||
for len(buf) >= frameSize {
|
||||
payload := buf[:frameSize]
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
PayloadType: payloadType,
|
||||
SequenceNumber: seq,
|
||||
Timestamp: ts,
|
||||
SSRC: packet.SSRC,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
seq++
|
||||
ts += uint32(frameSize)
|
||||
buf = buf[frameSize:]
|
||||
|
||||
c.conn.Send += pkt.MarshalSize()
|
||||
_ = localTrack.WriteRTP(payloadType, pkt)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
sender.Handler = func(packet *rtp.Packet) {
|
||||
c.conn.Send += packet.MarshalSize()
|
||||
_ = localTrack.WriteRTP(payloadType, packet)
|
||||
}
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
c.conn.Senders = append(c.conn.Senders, sender)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if len(c.conn.Receivers) == 0 {
|
||||
return errors.New("tuya: no receivers")
|
||||
}
|
||||
|
||||
var video, audio *core.Receiver
|
||||
for _, receiver := range c.conn.Receivers {
|
||||
if receiver.Codec.IsVideo() {
|
||||
video = receiver
|
||||
} else if receiver.Codec.IsAudio() {
|
||||
audio = receiver
|
||||
}
|
||||
}
|
||||
|
||||
if c.videoSSRC != nil {
|
||||
c.setHandler(*c.videoSSRC, func(packet *rtp.Packet) {
|
||||
if video != nil {
|
||||
video.WriteRTP(packet)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if c.audioSSRC != nil {
|
||||
c.setHandler(*c.audioSSRC, func(packet *rtp.Packet) {
|
||||
if audio != nil {
|
||||
audio.WriteRTP(packet)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return c.conn.Start()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.closed = true
|
||||
|
||||
c.clearHandlers()
|
||||
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Stop()
|
||||
}
|
||||
|
||||
if c.api != nil {
|
||||
c.api.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close(err error) error {
|
||||
c.connected.Done(err)
|
||||
return c.Stop()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
return c.conn.MarshalJSON()
|
||||
}
|
||||
|
||||
func (c *Client) setHandler(ssrc uint32, handler func(*rtp.Packet)) {
|
||||
c.handlersMu.Lock()
|
||||
defer c.handlersMu.Unlock()
|
||||
c.handlers[ssrc] = handler
|
||||
}
|
||||
|
||||
func (c *Client) getHandler(ssrc uint32) (func(*rtp.Packet), bool) {
|
||||
c.handlersMu.RLock()
|
||||
defer c.handlersMu.RUnlock()
|
||||
handler, ok := c.handlers[ssrc]
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (c *Client) clearHandlers() {
|
||||
c.handlersMu.Lock()
|
||||
defer c.handlersMu.Unlock()
|
||||
for ssrc := range c.handlers {
|
||||
delete(c.handlers, ssrc)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) {
|
||||
// fmt.Printf("[tuya] Received string message: %s\n", string(msg.Data))
|
||||
|
||||
var message DataChannelMessage
|
||||
if err := json.Unmarshal([]byte(msg.Data), &message); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
case "codec":
|
||||
// Camera responded to our codec request - now request frame start
|
||||
frameRequest, _ := json.Marshal(DataChannelMessage{
|
||||
Type: "start",
|
||||
Msg: "frame",
|
||||
})
|
||||
|
||||
err := c.sendMessageToDataChannel(frameRequest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
case "recv":
|
||||
// Camera sends SSRC values for video/audio streams
|
||||
// We need these to route incoming RTP packets correctly
|
||||
var recvMessage RecvMessage
|
||||
if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
videoSSRC := recvMessage.Video.SSRC
|
||||
audioSSRC := recvMessage.Audio.SSRC
|
||||
c.videoSSRC = &videoSSRC
|
||||
c.audioSSRC = &audioSSRC
|
||||
|
||||
// Send "complete" to tell camera we're ready to receive RTP packets
|
||||
completeMsg, _ := json.Marshal(DataChannelMessage{
|
||||
Type: "complete",
|
||||
Msg: "",
|
||||
})
|
||||
|
||||
err := c.sendMessageToDataChannel(completeMsg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *Client) sendMessageToDataChannel(message []byte) error {
|
||||
if c.dc != nil {
|
||||
// fmt.Printf("[tuya] sending message to data channel: %s\n", message)
|
||||
return c.dc.Send(message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
UID string `json:"uid"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
}
|
||||
|
||||
type WebRTCConfigResponse struct {
|
||||
Timestamp int64 `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Result WebRTCConfig `json:"result"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
Timestamp int64 `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Result Token `json:"result"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type OpenIoTHubConfigRequest struct {
|
||||
UID string `json:"uid"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
LinkType string `json:"link_type"`
|
||||
Topics string `json:"topics"`
|
||||
}
|
||||
|
||||
type OpenIoTHubConfig struct {
|
||||
Url string `json:"url"`
|
||||
ClientID string `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SinkTopic struct {
|
||||
IPC string `json:"ipc"`
|
||||
} `json:"sink_topic"`
|
||||
SourceSink struct {
|
||||
IPC string `json:"ipc"`
|
||||
} `json:"source_topic"`
|
||||
ExpireTime int `json:"expire_time"`
|
||||
}
|
||||
|
||||
type OpenIoTHubConfigResponse struct {
|
||||
Timestamp int `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Result OpenIoTHubConfig `json:"result"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type TuyaCloudApiClient struct {
|
||||
TuyaClient
|
||||
uid string
|
||||
clientId string
|
||||
clientSecret string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
refreshingToken bool
|
||||
}
|
||||
|
||||
func NewTuyaCloudApiClient(baseUrl, uid, deviceId, clientId, clientSecret string) (*TuyaCloudApiClient, error) {
|
||||
mqttClient := NewTuyaMqttClient(deviceId)
|
||||
|
||||
client := &TuyaCloudApiClient{
|
||||
TuyaClient: TuyaClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
mqtt: mqttClient,
|
||||
deviceId: deviceId,
|
||||
expireTime: 0,
|
||||
baseUrl: baseUrl,
|
||||
},
|
||||
uid: uid,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
refreshingToken: false,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// WebRTC Flow
|
||||
func (c *TuyaCloudApiClient) Init() error {
|
||||
if err := c.initToken(); err != nil {
|
||||
return fmt.Errorf("failed to initialize token: %w", err)
|
||||
}
|
||||
|
||||
webrtcConfig, err := c.loadWebrtcConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load webrtc config: %w", err)
|
||||
}
|
||||
|
||||
hubConfig, err := c.loadHubConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load hub config: %w", err)
|
||||
}
|
||||
|
||||
if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil {
|
||||
return fmt.Errorf("failed to start MQTT: %w", err)
|
||||
}
|
||||
|
||||
if c.skill.LowPower > 0 {
|
||||
_ = c.mqtt.WakeUp(c.localKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) {
|
||||
if err := c.initToken(); err != nil {
|
||||
return "", fmt.Errorf("failed to initialize token: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.baseUrl, c.deviceId)
|
||||
|
||||
request := &AllocateRequest{
|
||||
Type: streamType,
|
||||
}
|
||||
|
||||
body, err := c.request("POST", url, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var allocResponse AllocateResponse
|
||||
err = json.Unmarshal(body, &allocResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !allocResponse.Success {
|
||||
return "", errors.New(allocResponse.Msg)
|
||||
}
|
||||
|
||||
return allocResponse.Result.URL, nil
|
||||
}
|
||||
|
||||
func (c *TuyaCloudApiClient) initToken() (err error) {
|
||||
if c.refreshingToken {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
if (c.expireTime - 60) > now {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.refreshingToken = true
|
||||
|
||||
url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.baseUrl)
|
||||
|
||||
c.accessToken = ""
|
||||
c.refreshToken = ""
|
||||
|
||||
body, err := c.request("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
err = json.Unmarshal(body, &tokenResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !tokenResponse.Success {
|
||||
return errors.New(tokenResponse.Msg)
|
||||
}
|
||||
|
||||
c.accessToken = tokenResponse.Result.AccessToken
|
||||
c.refreshToken = tokenResponse.Result.RefreshToken
|
||||
c.expireTime = tokenResponse.Timestamp + tokenResponse.Result.ExpireTime
|
||||
c.refreshingToken = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaCloudApiClient) loadWebrtcConfig() (*WebRTCConfig, error) {
|
||||
url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.baseUrl, c.uid, c.deviceId)
|
||||
|
||||
body, err := c.request("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var webRTCConfigResponse WebRTCConfigResponse
|
||||
err = json.Unmarshal(body, &webRTCConfigResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !webRTCConfigResponse.Success {
|
||||
return nil, fmt.Errorf(webRTCConfigResponse.Msg)
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store LocalKey (not sure if cloud api provides this, but we need it for low power cameras)
|
||||
c.localKey = webRTCConfigResponse.Result.LocalKey
|
||||
|
||||
iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.iceServers, err = webrtc.UnmarshalICEServers(iceServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &webRTCConfigResponse.Result, nil
|
||||
}
|
||||
|
||||
func (c *TuyaCloudApiClient) loadHubConfig() (config *MQTTConfig, err error) {
|
||||
url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.baseUrl)
|
||||
|
||||
request := &OpenIoTHubConfigRequest{
|
||||
UID: c.uid,
|
||||
UniqueID: uuid.New().String(),
|
||||
LinkType: "mqtt",
|
||||
Topics: "ipc",
|
||||
}
|
||||
|
||||
body, err := c.request("POST", url, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var openIoTHubConfigResponse OpenIoTHubConfigResponse
|
||||
err = json.Unmarshal(body, &openIoTHubConfigResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !openIoTHubConfigResponse.Success {
|
||||
return nil, fmt.Errorf(openIoTHubConfigResponse.Msg)
|
||||
}
|
||||
|
||||
return &MQTTConfig{
|
||||
Url: openIoTHubConfigResponse.Result.Url,
|
||||
Username: openIoTHubConfigResponse.Result.Username,
|
||||
Password: openIoTHubConfigResponse.Result.Password,
|
||||
ClientID: openIoTHubConfigResponse.Result.ClientID,
|
||||
PublishTopic: openIoTHubConfigResponse.Result.SinkTopic.IPC,
|
||||
SubscribeTopic: openIoTHubConfigResponse.Result.SourceSink.IPC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TuyaCloudApiClient) request(method string, url string, body any) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ts := time.Now().UnixNano() / 1000000
|
||||
sign := c.calBusinessSign(ts)
|
||||
|
||||
req.Header.Set("Accept", "*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
req.Header.Set("Access-Control-Allow-Methods", "*")
|
||||
req.Header.Set("Access-Control-Allow-Headers", "*")
|
||||
req.Header.Set("mode", "no-cors")
|
||||
req.Header.Set("client_id", c.clientId)
|
||||
req.Header.Set("access_token", c.accessToken)
|
||||
req.Header.Set("sign", sign)
|
||||
req.Header.Set("t", strconv.FormatInt(ts, 10))
|
||||
|
||||
response, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
res, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *TuyaCloudApiClient) calBusinessSign(ts int64) string {
|
||||
data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts)
|
||||
val := md5.Sum([]byte(data))
|
||||
res := fmt.Sprintf("%X", val)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
cryptoRand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
func EncryptPassword(password, pbKey string) (string, error) {
|
||||
// Hash password with MD5
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte(password))
|
||||
hashedPassword := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Decode PEM public key
|
||||
block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + pbKey + "\n-----END PUBLIC KEY-----"))
|
||||
if block == nil {
|
||||
return "", errors.New("failed to decode PEM block")
|
||||
}
|
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
rsaPubKey, ok := pubKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return "", errors.New("not an RSA public key")
|
||||
}
|
||||
|
||||
// Encrypt with RSA
|
||||
encrypted, err := rsa.EncryptPKCS1v15(cryptoRand.Reader, rsaPubKey, []byte(hashedPassword))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert to hex string
|
||||
return hex.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
func IsEmailAddress(input string) bool {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return emailRegex.MatchString(input)
|
||||
}
|
||||
|
||||
func CreateHTTPClientWithSession() *http.Client {
|
||||
jar, err := cookiejar.New(&cookiejar.Options{
|
||||
PublicSuffixList: publicsuffix.List,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
pionWebrtc "github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type TuyaAPI interface {
|
||||
GetMqtt() *TuyaMqttClient
|
||||
|
||||
GetStreamType(streamResolution string) int
|
||||
IsHEVC(streamType int) bool
|
||||
|
||||
GetVideoCodecs() []*core.Codec
|
||||
GetAudioCodecs() []*core.Codec
|
||||
|
||||
GetStreamUrl(streamUrl string) (string, error)
|
||||
GetICEServers() []pionWebrtc.ICEServer
|
||||
|
||||
Init() error
|
||||
Close()
|
||||
}
|
||||
|
||||
type TuyaClient struct {
|
||||
TuyaAPI
|
||||
|
||||
httpClient *http.Client
|
||||
mqtt *TuyaMqttClient
|
||||
baseUrl string
|
||||
expireTime int64
|
||||
deviceId string
|
||||
localKey string
|
||||
skill *Skill
|
||||
iceServers []pionWebrtc.ICEServer
|
||||
}
|
||||
|
||||
type AudioAttributes struct {
|
||||
CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way
|
||||
HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker
|
||||
}
|
||||
|
||||
type ICEServer struct {
|
||||
Urls string `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
TTL int `json:"ttl,omitempty"`
|
||||
}
|
||||
|
||||
type WebICE struct {
|
||||
Urls string `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
type P2PConfig struct {
|
||||
Ices []ICEServer `json:"ices"`
|
||||
}
|
||||
|
||||
type AudioSkill struct {
|
||||
Channels int `json:"channels"`
|
||||
DataBit int `json:"dataBit"`
|
||||
CodecType int `json:"codecType"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
}
|
||||
|
||||
type VideoSkill struct {
|
||||
StreamType int `json:"streamType"` // 2 = main stream (HD), 4 = sub stream (SD)
|
||||
CodecType int `json:"codecType"` // 2 = H264, 4 = H265 (HEVC)
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
ProfileId string `json:"profileId,omitempty"`
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
WebRTC int `json:"webrtc"` // Bit flags: bit 4=speaker, bit 5=clarity, bit 6=record
|
||||
LowPower int `json:"lowPower,omitempty"` // 1 = battery-powered camera
|
||||
Audios []AudioSkill `json:"audios"`
|
||||
Videos []VideoSkill `json:"videos"`
|
||||
}
|
||||
|
||||
type WebRTCConfig struct {
|
||||
AudioAttributes AudioAttributes `json:"audio_attributes"`
|
||||
Auth string `json:"auth"`
|
||||
ID string `json:"id"`
|
||||
LocalKey string `json:"local_key,omitempty"`
|
||||
MotoID string `json:"moto_id"`
|
||||
P2PConfig P2PConfig `json:"p2p_config"`
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
Skill string `json:"skill"`
|
||||
SupportsWebRTCRecord bool `json:"supports_webrtc_record"`
|
||||
SupportsWebRTC bool `json:"supports_webrtc"`
|
||||
VedioClaritiy int `json:"vedio_clarity"`
|
||||
VideoClaritiy int `json:"video_clarity"`
|
||||
VideoClarities []int `json:"video_clarities"`
|
||||
}
|
||||
|
||||
type MQTTConfig struct {
|
||||
Url string `json:"url"`
|
||||
PublishTopic string `json:"publish_topic"`
|
||||
SubscribeTopic string `json:"subscribe_topic"`
|
||||
ClientID string `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Allocate struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type AllocateRequest struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type AllocateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result Allocate `json:"result"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TuyaClient) GetICEServers() []pionWebrtc.ICEServer {
|
||||
return c.iceServers
|
||||
}
|
||||
|
||||
func (c *TuyaClient) GetMqtt() *TuyaMqttClient {
|
||||
return c.mqtt
|
||||
}
|
||||
|
||||
// GetStreamType returns the Skill StreamType for the requested resolution
|
||||
// Returns Skill values (2 or 4), not MQTT values (0 or 1)
|
||||
// - "hd" → highest resolution streamType (usually 2 = mainStream)
|
||||
// - "sd" → lowest resolution streamType (usually 4 = substream)
|
||||
//
|
||||
// These values must be mapped before sending to MQTT:
|
||||
// - streamType 2 → MQTT stream_type 0
|
||||
// - streamType 4 → MQTT stream_type 1
|
||||
func (c *TuyaClient) GetStreamType(streamResolution string) int {
|
||||
// Default streamType if nothing is found
|
||||
defaultStreamType := 1
|
||||
|
||||
if c.skill == nil || len(c.skill.Videos) == 0 {
|
||||
return defaultStreamType
|
||||
}
|
||||
|
||||
// Find the highest and lowest resolution based on pixel count
|
||||
var highestResType = defaultStreamType
|
||||
var highestRes = 0
|
||||
var lowestResType = defaultStreamType
|
||||
var lowestRes = 0
|
||||
|
||||
for _, video := range c.skill.Videos {
|
||||
res := video.Width * video.Height
|
||||
|
||||
// Highest Resolution
|
||||
if res > highestRes {
|
||||
highestRes = res
|
||||
highestResType = video.StreamType
|
||||
}
|
||||
|
||||
// Lower Resolution (or first if not set yet)
|
||||
if lowestRes == 0 || res < lowestRes {
|
||||
lowestRes = res
|
||||
lowestResType = video.StreamType
|
||||
}
|
||||
}
|
||||
|
||||
// Return the streamType based on the selection
|
||||
switch streamResolution {
|
||||
case "hd":
|
||||
return highestResType
|
||||
case "sd":
|
||||
return lowestResType
|
||||
default:
|
||||
return defaultStreamType
|
||||
}
|
||||
}
|
||||
|
||||
// IsHEVC checks if the given streamType uses H265 (HEVC) codec
|
||||
// HEVC cameras use DataChannel, H264 cameras use RTP tracks
|
||||
// - codecType 4 = H265 (HEVC) → DataChannel mode
|
||||
// - codecType 2 = H264 → Normal RTP mode
|
||||
func (c *TuyaClient) IsHEVC(streamType int) bool {
|
||||
for _, video := range c.skill.Videos {
|
||||
if video.StreamType == streamType {
|
||||
return video.CodecType == 4 // 4 = H265/HEVC
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *TuyaClient) GetVideoCodecs() []*core.Codec {
|
||||
if len(c.skill.Videos) > 0 {
|
||||
codecs := make([]*core.Codec, 0)
|
||||
|
||||
for _, video := range c.skill.Videos {
|
||||
name := core.CodecH264
|
||||
if c.IsHEVC(video.StreamType) {
|
||||
name = core.CodecH265
|
||||
}
|
||||
|
||||
codec := &core.Codec{
|
||||
Name: name,
|
||||
ClockRate: uint32(video.SampleRate),
|
||||
}
|
||||
|
||||
codecs = append(codecs, codec)
|
||||
}
|
||||
|
||||
if len(codecs) > 0 {
|
||||
return codecs
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaClient) GetAudioCodecs() []*core.Codec {
|
||||
if len(c.skill.Audios) > 0 {
|
||||
codecs := make([]*core.Codec, 0)
|
||||
|
||||
for _, audio := range c.skill.Audios {
|
||||
name := getAudioCodecName(&audio)
|
||||
|
||||
codec := &core.Codec{
|
||||
Name: name,
|
||||
ClockRate: uint32(audio.SampleRate),
|
||||
Channels: uint8(audio.Channels),
|
||||
}
|
||||
codecs = append(codecs, codec)
|
||||
}
|
||||
|
||||
if len(codecs) > 0 {
|
||||
return codecs
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaClient) Close() {
|
||||
c.mqtt.Stop()
|
||||
c.httpClient.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// https://protect-us.ismartlife.me/
|
||||
func getAudioCodecName(audioSkill *AudioSkill) string {
|
||||
switch audioSkill.CodecType {
|
||||
// case 100:
|
||||
// return "ADPCM"
|
||||
case 101:
|
||||
return core.CodecPCML
|
||||
case 102, 103, 104:
|
||||
return core.CodecAAC
|
||||
case 105:
|
||||
return core.CodecPCMU
|
||||
case 106:
|
||||
return core.CodecPCMA
|
||||
// case 107:
|
||||
// return "G726-32"
|
||||
// case 108:
|
||||
// return "SPEEX"
|
||||
case 109:
|
||||
return core.CodecMP3
|
||||
default:
|
||||
return core.CodecPCML
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
type TuyaMqttClient struct {
|
||||
client mqtt.Client
|
||||
waiter core.Waiter
|
||||
wakeupWaiter core.Waiter
|
||||
speakerWaiter core.Waiter
|
||||
publishTopic string
|
||||
subscribeTopic string
|
||||
auth string
|
||||
iceServers []ICEServer
|
||||
uid string
|
||||
motoId string
|
||||
deviceId string
|
||||
sessionId string
|
||||
closed bool
|
||||
webrtcVersion int
|
||||
handleAnswer func(answer AnswerFrame)
|
||||
handleCandidate func(candidate CandidateFrame)
|
||||
handleDisconnect func()
|
||||
handleError func(err error)
|
||||
}
|
||||
|
||||
type MqttFrameHeader struct {
|
||||
Type string `json:"type"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
SubDevID string `json:"sub_dev_id"`
|
||||
SessionID string `json:"sessionid"`
|
||||
MotoID string `json:"moto_id"`
|
||||
TransactionID string `json:"tid"`
|
||||
}
|
||||
|
||||
type MqttFrame struct {
|
||||
Header MqttFrameHeader `json:"header"`
|
||||
Message json.RawMessage `json:"msg"`
|
||||
}
|
||||
|
||||
type OfferFrame struct {
|
||||
Mode string `json:"mode"`
|
||||
Sdp string `json:"sdp"`
|
||||
StreamType int `json:"stream_type"` // 0: mainStream(HD), 1: substream(SD)
|
||||
Auth string `json:"auth"`
|
||||
DatachannelEnable bool `json:"datachannel_enable"` // true for HEVC, false for H264
|
||||
Token []ICEServer `json:"token"`
|
||||
}
|
||||
|
||||
type AnswerFrame struct {
|
||||
Mode string `json:"mode"`
|
||||
Sdp string `json:"sdp"`
|
||||
}
|
||||
|
||||
type CandidateFrame struct {
|
||||
Mode string `json:"mode"`
|
||||
Candidate string `json:"candidate"`
|
||||
}
|
||||
|
||||
type ResolutionFrame struct {
|
||||
Mode string `json:"mode"`
|
||||
Value int `json:"cmdValue"` // 0: HD, 1: SD
|
||||
}
|
||||
|
||||
type SpeakerFrame struct {
|
||||
Mode string `json:"mode"`
|
||||
Value int `json:"cmdValue"` // 0: off, 1: on
|
||||
}
|
||||
|
||||
type DisconnectFrame struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type MqttLowPowerMessage struct {
|
||||
Protocol int `json:"protocol"`
|
||||
T int `json:"t"`
|
||||
S int `json:"s,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Data struct {
|
||||
DevID string `json:"devId,omitempty"`
|
||||
Online bool `json:"online,omitempty"`
|
||||
LastOnlineChangeTime int64 `json:"lastOnlineChangeTime,omitempty"`
|
||||
GwID string `json:"gwId,omitempty"`
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
Dps map[string]interface{} `json:"dps,omitempty"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type MqttMessage struct {
|
||||
Protocol int `json:"protocol"`
|
||||
Pv string `json:"pv"`
|
||||
T int64 `json:"t"`
|
||||
Data MqttFrame `json:"data"`
|
||||
}
|
||||
|
||||
func NewTuyaMqttClient(deviceId string) *TuyaMqttClient {
|
||||
return &TuyaMqttClient{
|
||||
deviceId: deviceId,
|
||||
sessionId: core.RandString(6, 62),
|
||||
waiter: core.Waiter{},
|
||||
wakeupWaiter: core.Waiter{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error {
|
||||
c.webrtcVersion = webrtcVersion
|
||||
c.motoId = webrtcConfig.MotoID
|
||||
c.auth = webrtcConfig.Auth
|
||||
c.iceServers = webrtcConfig.P2PConfig.Ices
|
||||
|
||||
c.publishTopic = hubConfig.PublishTopic
|
||||
c.subscribeTopic = hubConfig.SubscribeTopic
|
||||
|
||||
c.publishTopic = strings.Replace(c.publishTopic, "moto_id", c.motoId, 1)
|
||||
c.publishTopic = strings.Replace(c.publishTopic, "{device_id}", c.deviceId, 1)
|
||||
|
||||
parts := strings.Split(c.subscribeTopic, "/")
|
||||
c.uid = parts[3]
|
||||
|
||||
opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url).
|
||||
SetClientID(hubConfig.ClientID).
|
||||
SetUsername(hubConfig.Username).
|
||||
SetPassword(hubConfig.Password).
|
||||
SetOnConnectHandler(c.onConnect).
|
||||
SetAutoReconnect(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetConnectTimeout(30 * time.Second).
|
||||
SetKeepAlive(60 * time.Second).
|
||||
SetPingTimeout(20 * time.Second)
|
||||
|
||||
c.client = mqtt.NewClient(opts)
|
||||
|
||||
if token := c.client.Connect(); token.Wait() && token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
if err := c.waiter.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) Stop() {
|
||||
c.waiter.Done(errors.New("mqtt: stopped"))
|
||||
c.wakeupWaiter.Done(errors.New("mqtt: stopped"))
|
||||
c.speakerWaiter.Done(errors.New("mqtt: stopped"))
|
||||
|
||||
if c.client != nil {
|
||||
_ = c.SendDisconnect()
|
||||
c.client.Disconnect(100)
|
||||
}
|
||||
|
||||
c.closed = true
|
||||
}
|
||||
|
||||
// WakeUp sends a wake-up signal to battery-powered cameras (LowPower mode).
|
||||
// The camera wakes up and starts responding immediately - we don't wait for dps[149].
|
||||
// Note: LowPower cameras sleep after ~3 minutes of inactivity.
|
||||
func (c *TuyaMqttClient) WakeUp(localKey string) error {
|
||||
// Calculate CRC32 of localKey as wake-up payload
|
||||
crc := crc32.ChecksumIEEE([]byte(localKey))
|
||||
|
||||
// Convert to hex string
|
||||
hexStr := fmt.Sprintf("%08x", crc)
|
||||
|
||||
// Convert hex string to byte array (2 chars at a time)
|
||||
payload := make([]byte, len(hexStr)/2)
|
||||
for i := 0; i < len(hexStr); i += 2 {
|
||||
b, err := hex.DecodeString(hexStr[i : i+2])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode hex: %w", err)
|
||||
}
|
||||
payload[i/2] = b[0]
|
||||
}
|
||||
|
||||
// Publish to wake-up topic: m/w/{deviceId}
|
||||
wakeUpTopic := fmt.Sprintf("m/w/%s", c.deviceId)
|
||||
token := c.client.Publish(wakeUpTopic, 1, false, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return fmt.Errorf("failed to publish wake-up message: %w", token.Error())
|
||||
}
|
||||
|
||||
// Subscribe to lowPower topic to receive dps[149] status updates
|
||||
// (we don't wait for this signal - camera responds immediately)
|
||||
lowPowerTopic := fmt.Sprintf("smart/decrypt/in/%s", c.deviceId)
|
||||
if token := c.client.Subscribe(lowPowerTopic, 1, c.onLowPowerMessage); token.Wait() && token.Error() != nil {
|
||||
return fmt.Errorf("failed to subscribe to lowPower topic: %w", token.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error {
|
||||
// Map Skill StreamType to MQTT stream_type values
|
||||
// streamType comes from GetStreamType() and uses Skill StreamType values:
|
||||
// - mainStream = 2 (HD)
|
||||
// - substream = 4 (SD)
|
||||
//
|
||||
// But MQTT expects mapped stream_type values:
|
||||
// - mainStream (2) → stream_type: 0
|
||||
// - substream (4) → stream_type: 1
|
||||
|
||||
mqttStreamType := streamType
|
||||
switch streamType {
|
||||
case 2:
|
||||
mqttStreamType = 0 // mainStream (HD)
|
||||
case 4:
|
||||
mqttStreamType = 1 // substream (SD)
|
||||
}
|
||||
|
||||
return c.sendMqttMessage("offer", 302, "", OfferFrame{
|
||||
Mode: "webrtc",
|
||||
Sdp: sdp,
|
||||
StreamType: mqttStreamType,
|
||||
Auth: c.auth,
|
||||
DatachannelEnable: isHEVC, // must be true for HEVC
|
||||
Token: c.iceServers,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) SendCandidate(candidate string) error {
|
||||
return c.sendMqttMessage("candidate", 302, "", CandidateFrame{
|
||||
Mode: "webrtc",
|
||||
Candidate: candidate,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) SendResolution(resolution int) error {
|
||||
// Check if camera supports clarity switching
|
||||
isClaritySupported := (c.webrtcVersion & (1 << 5)) != 0
|
||||
if !isClaritySupported {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{
|
||||
Mode: "webrtc",
|
||||
Value: resolution, // 0: HD, 1: SD
|
||||
})
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) SendSpeaker(speaker int) error {
|
||||
if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{
|
||||
Mode: "webrtc",
|
||||
Value: speaker, // 0: off, 1: on
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for camera response
|
||||
if err := c.speakerWaiter.Wait(); err != nil {
|
||||
return fmt.Errorf("speaker wait failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) SendDisconnect() error {
|
||||
return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{
|
||||
Mode: "webrtc",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onConnect(client mqtt.Client) {
|
||||
if token := client.Subscribe(c.subscribeTopic, 1, c.onMessage); token.Wait() && token.Error() != nil {
|
||||
c.waiter.Done(token.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.waiter.Done(nil)
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) {
|
||||
var rmqtt MqttMessage
|
||||
if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil {
|
||||
c.onError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by session ID to prevent processing messages from other sessions
|
||||
if rmqtt.Data.Header.SessionID != c.sessionId {
|
||||
return
|
||||
}
|
||||
|
||||
switch rmqtt.Data.Header.Type {
|
||||
case "answer":
|
||||
c.onMqttAnswer(&rmqtt)
|
||||
case "candidate":
|
||||
c.onMqttCandidate(&rmqtt)
|
||||
case "disconnect":
|
||||
c.onMqttDisconnect()
|
||||
case "speaker":
|
||||
c.onMqttSpeaker(&rmqtt)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) {
|
||||
var message MqttLowPowerMessage
|
||||
if err := json.Unmarshal(msg.Payload(), &message); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if protocol is 4 and dps[149] is true
|
||||
// https://developer.tuya.com/en/docs/iot-device-dev/doorbell_solution?id=Kayamyivh15ox#title-2-Battery
|
||||
if message.Protocol == 4 {
|
||||
if val, ok := message.Data.Dps["149"]; ok {
|
||||
if ready, ok := val.(bool); ok && ready {
|
||||
// Camera is now ready after wake-up (dps[149]:true received).
|
||||
// However, we don't wait for this signal (like ismartlife.me doesn't either).
|
||||
// The camera starts responding immediately after WakeUp() is called,
|
||||
// so we proceed with the connection without blocking.
|
||||
// This waiter is kept for potential future use.
|
||||
c.wakeupWaiter.Done(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onMqttAnswer(msg *MqttMessage) {
|
||||
var answerFrame AnswerFrame
|
||||
if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil {
|
||||
c.onError(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.onAnswer(answerFrame)
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onMqttCandidate(msg *MqttMessage) {
|
||||
var candidateFrame CandidateFrame
|
||||
if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil {
|
||||
c.onError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// fix candidates
|
||||
candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=")
|
||||
candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n")
|
||||
|
||||
c.onCandidate(candidateFrame)
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onMqttDisconnect() {
|
||||
c.closed = true
|
||||
c.onDisconnect()
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onMqttSpeaker(msg *MqttMessage) {
|
||||
var speakerResponse struct {
|
||||
ResCode int `json:"resCode"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(msg.Data.Message, &speakerResponse); err == nil {
|
||||
if speakerResponse.ResCode != 0 {
|
||||
c.speakerWaiter.Done(fmt.Errorf("speaker failed with resCode: %d", speakerResponse.ResCode))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.speakerWaiter.Done(nil)
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onAnswer(answer AnswerFrame) {
|
||||
if c.handleAnswer != nil {
|
||||
c.handleAnswer(answer)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onCandidate(candidate CandidateFrame) {
|
||||
if c.handleCandidate != nil {
|
||||
c.handleCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onDisconnect() {
|
||||
if c.handleDisconnect != nil {
|
||||
c.handleDisconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) onError(err error) {
|
||||
if c.handleError != nil {
|
||||
c.handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TuyaMqttClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error {
|
||||
if c.closed {
|
||||
return fmt.Errorf("mqtt client is closed, send mqtt message fail")
|
||||
}
|
||||
|
||||
jsonMessage, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := &MqttMessage{
|
||||
Protocol: protocol,
|
||||
Pv: "2.2",
|
||||
T: time.Now().Unix(),
|
||||
Data: MqttFrame{
|
||||
Header: MqttFrameHeader{
|
||||
Type: messageType,
|
||||
From: c.uid,
|
||||
To: c.deviceId,
|
||||
SessionID: c.sessionId,
|
||||
MotoID: c.motoId,
|
||||
TransactionID: transactionID,
|
||||
},
|
||||
Message: jsonMessage,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := c.client.Publish(c.publishTopic, 1, false, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
package tuya
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
)
|
||||
|
||||
type LoginTokenRequest struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Username string `json:"username"`
|
||||
IsUid bool `json:"isUid"`
|
||||
}
|
||||
|
||||
type LoginTokenResponse struct {
|
||||
Result LoginToken `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type LoginToken struct {
|
||||
Token string `json:"token"`
|
||||
Exponent string `json:"exponent"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
PbKey string `json:"pbKey"`
|
||||
}
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Passwd string `json:"passwd"`
|
||||
Token string `json:"token"`
|
||||
IfEncrypt int `json:"ifencrypt"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
type PasswordLoginResponse struct {
|
||||
Result LoginResult `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
Attribute int `json:"attribute"`
|
||||
ClientId string `json:"clientId"`
|
||||
DataVersion int `json:"dataVersion"`
|
||||
Domain Domain `json:"domain"`
|
||||
Ecode string `json:"ecode"`
|
||||
Email string `json:"email"`
|
||||
Extras Extras `json:"extras"`
|
||||
HeadPic string `json:"headPic"`
|
||||
ImproveCompanyInfo bool `json:"improveCompanyInfo"`
|
||||
Nickname string `json:"nickname"`
|
||||
PartnerIdentity string `json:"partnerIdentity"`
|
||||
PhoneCode string `json:"phoneCode"`
|
||||
Receiver string `json:"receiver"`
|
||||
RegFrom int `json:"regFrom"`
|
||||
Sid string `json:"sid"`
|
||||
SnsNickname string `json:"snsNickname"`
|
||||
TempUnit int `json:"tempUnit"`
|
||||
Timezone string `json:"timezone"`
|
||||
TimezoneId string `json:"timezoneId"`
|
||||
Uid string `json:"uid"`
|
||||
UserType int `json:"userType"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
AispeechHttpsUrl string `json:"aispeechHttpsUrl"`
|
||||
AispeechQuicUrl string `json:"aispeechQuicUrl"`
|
||||
DeviceHttpUrl string `json:"deviceHttpUrl"`
|
||||
DeviceHttpsPskUrl string `json:"deviceHttpsPskUrl"`
|
||||
DeviceHttpsUrl string `json:"deviceHttpsUrl"`
|
||||
DeviceMediaMqttUrl string `json:"deviceMediaMqttUrl"`
|
||||
DeviceMediaMqttsUrl string `json:"deviceMediaMqttsUrl"`
|
||||
DeviceMqttsPskUrl string `json:"deviceMqttsPskUrl"`
|
||||
DeviceMqttsUrl string `json:"deviceMqttsUrl"`
|
||||
GwApiUrl string `json:"gwApiUrl"`
|
||||
GwMqttUrl string `json:"gwMqttUrl"`
|
||||
HttpPort int `json:"httpPort"`
|
||||
HttpsPort int `json:"httpsPort"`
|
||||
HttpsPskPort int `json:"httpsPskPort"`
|
||||
MobileApiUrl string `json:"mobileApiUrl"`
|
||||
MobileMediaMqttUrl string `json:"mobileMediaMqttUrl"`
|
||||
MobileMqttUrl string `json:"mobileMqttUrl"`
|
||||
MobileMqttsUrl string `json:"mobileMqttsUrl"`
|
||||
MobileQuicUrl string `json:"mobileQuicUrl"`
|
||||
MqttPort int `json:"mqttPort"`
|
||||
MqttQuicUrl string `json:"mqttQuicUrl"`
|
||||
MqttsPort int `json:"mqttsPort"`
|
||||
MqttsPskPort int `json:"mqttsPskPort"`
|
||||
RegionCode string `json:"regionCode"`
|
||||
}
|
||||
|
||||
type Extras struct {
|
||||
HomeId string `json:"homeId"`
|
||||
SceneType string `json:"sceneType"`
|
||||
}
|
||||
|
||||
type AppInfoResponse struct {
|
||||
Result AppInfo `json:"result"`
|
||||
T int64 `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
AppId int `json:"appId"`
|
||||
AppName string `json:"appName"`
|
||||
ClientId string `json:"clientId"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
type MQTTConfigResponse struct {
|
||||
Result SmartApiMQTTConfig `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type SmartApiMQTTConfig struct {
|
||||
Msid string `json:"msid"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type HomeListResponse struct {
|
||||
Result []Home `json:"result"`
|
||||
T int64 `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type SharedHomeListResponse struct {
|
||||
Result SharedHome `json:"result"`
|
||||
T int64 `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type SharedHome struct {
|
||||
SecurityWebCShareInfoList []struct {
|
||||
DeviceInfoList []Device `json:"deviceInfoList"`
|
||||
Nickname string `json:"nickname"`
|
||||
Username string `json:"username"`
|
||||
} `json:"securityWebCShareInfoList"`
|
||||
}
|
||||
|
||||
type Home struct {
|
||||
Admin bool `json:"admin"`
|
||||
Background string `json:"background"`
|
||||
DealStatus int `json:"dealStatus"`
|
||||
DisplayOrder int `json:"displayOrder"`
|
||||
GeoName string `json:"geoName"`
|
||||
Gid int `json:"gid"`
|
||||
GmtCreate int64 `json:"gmtCreate"`
|
||||
GmtModified int64 `json:"gmtModified"`
|
||||
GroupId int `json:"groupId"`
|
||||
GroupUserId int `json:"groupUserId"`
|
||||
Id int `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
ManagementStatus bool `json:"managementStatus"`
|
||||
Name string `json:"name"`
|
||||
OwnerId string `json:"ownerId"`
|
||||
Role int `json:"role"`
|
||||
Status bool `json:"status"`
|
||||
Uid string `json:"uid"`
|
||||
}
|
||||
|
||||
type RoomListRequest struct {
|
||||
HomeId string `json:"homeId"`
|
||||
}
|
||||
|
||||
type RoomListResponse struct {
|
||||
Result []Room `json:"result"`
|
||||
T int64 `json:"t"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
DeviceCount int `json:"deviceCount"`
|
||||
DeviceList []Device `json:"deviceList"`
|
||||
RoomId string `json:"roomId"`
|
||||
RoomName string `json:"roomName"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
Category string `json:"category"`
|
||||
DeviceId string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
P2pType int `json:"p2pType"`
|
||||
ProductId string `json:"productId"`
|
||||
SupportCloudStorage bool `json:"supportCloudStorage"`
|
||||
Uuid string `json:"uuid"`
|
||||
}
|
||||
|
||||
type SmartApiWebRTCConfigRequest struct {
|
||||
DevId string `json:"devId"`
|
||||
ClientTraceId string `json:"clientTraceId"`
|
||||
}
|
||||
|
||||
type SmartApiWebRTCConfigResponse struct {
|
||||
Result SmartApiWebRTCConfig `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type SmartApiWebRTCConfig struct {
|
||||
AudioAttributes AudioAttributes `json:"audioAttributes"`
|
||||
Auth string `json:"auth"`
|
||||
GatewayId string `json:"gatewayId"`
|
||||
Id string `json:"id"`
|
||||
LocalKey string `json:"localKey"`
|
||||
MotoId string `json:"motoId"`
|
||||
NodeId string `json:"nodeId"`
|
||||
P2PConfig P2PConfig `json:"p2pConfig"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Skill string `json:"skill"`
|
||||
Sub bool `json:"sub"`
|
||||
SupportWebrtcRecord bool `json:"supportWebrtcRecord"`
|
||||
SupportsPtz bool `json:"supportsPtz"`
|
||||
SupportsWebrtc bool `json:"supportsWebrtc"`
|
||||
VedioClarity int `json:"vedioClarity"`
|
||||
VedioClaritys []int `json:"vedioClaritys"`
|
||||
VideoClarity int `json:"videoClarity"`
|
||||
}
|
||||
|
||||
type TuyaSmartApiClient struct {
|
||||
TuyaClient
|
||||
|
||||
email string
|
||||
password string
|
||||
countryCode string
|
||||
mqttsUrl string
|
||||
}
|
||||
|
||||
type Region struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Description string `json:"description"`
|
||||
Continent string `json:"continent"`
|
||||
}
|
||||
|
||||
var AvailableRegions = []Region{
|
||||
{"eu-central", "protect-eu.ismartlife.me", "Central Europe", "EU"},
|
||||
{"eu-east", "protect-we.ismartlife.me", "East Europe", "EU"},
|
||||
{"us-west", "protect-us.ismartlife.me", "West America", "AZ"},
|
||||
{"us-east", "protect-ue.ismartlife.me", "East America", "AZ"},
|
||||
{"china", "protect.ismartlife.me", "China", "AY"},
|
||||
{"india", "protect-in.ismartlife.me", "India", "IN"},
|
||||
}
|
||||
|
||||
func NewTuyaSmartApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaSmartApiClient, error) {
|
||||
var region *Region
|
||||
for _, r := range AvailableRegions {
|
||||
if r.Host == baseUrl {
|
||||
region = &r
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if region == nil {
|
||||
return nil, fmt.Errorf("invalid region: %s", baseUrl)
|
||||
}
|
||||
|
||||
if httpClient == nil {
|
||||
httpClient = CreateHTTPClientWithSession()
|
||||
}
|
||||
|
||||
mqttClient := NewTuyaMqttClient(deviceId)
|
||||
|
||||
client := &TuyaSmartApiClient{
|
||||
TuyaClient: TuyaClient{
|
||||
httpClient: httpClient,
|
||||
mqtt: mqttClient,
|
||||
deviceId: deviceId,
|
||||
expireTime: 0,
|
||||
baseUrl: baseUrl,
|
||||
},
|
||||
email: email,
|
||||
password: password,
|
||||
countryCode: region.Continent,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// WebRTC Flow
|
||||
func (c *TuyaSmartApiClient) Init() error {
|
||||
if err := c.initToken(); err != nil {
|
||||
return fmt.Errorf("failed to initialize token: %w", err)
|
||||
}
|
||||
|
||||
webrtcConfig, err := c.loadWebrtcConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load webrtc config: %w", err)
|
||||
}
|
||||
|
||||
hubConfig, err := c.loadHubConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load hub config: %w", err)
|
||||
}
|
||||
|
||||
if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil {
|
||||
return fmt.Errorf("failed to start MQTT: %w", err)
|
||||
}
|
||||
|
||||
if c.skill.LowPower > 0 {
|
||||
_ = c.mqtt.WakeUp(c.localKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) {
|
||||
return "", errors.New("not supported")
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) GetAppInfo() (*AppInfoResponse, error) {
|
||||
url := fmt.Sprintf("https://%s/api/customized/web/app/info", c.baseUrl)
|
||||
|
||||
body, err := c.request("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var appInfoResponse AppInfoResponse
|
||||
if err := json.Unmarshal(body, &appInfoResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !appInfoResponse.Success {
|
||||
return nil, errors.New(appInfoResponse.Msg)
|
||||
}
|
||||
|
||||
return &appInfoResponse, nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) GetHomeList() (*HomeListResponse, error) {
|
||||
url := fmt.Sprintf("https://%s/api/new/common/homeList", c.baseUrl)
|
||||
|
||||
body, err := c.request("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var homeListResponse HomeListResponse
|
||||
if err := json.Unmarshal(body, &homeListResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !homeListResponse.Success {
|
||||
return nil, errors.New(homeListResponse.Msg)
|
||||
}
|
||||
|
||||
return &homeListResponse, nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) {
|
||||
url := fmt.Sprintf("https://%s/api/new/playback/shareList", c.baseUrl)
|
||||
|
||||
body, err := c.request("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sharedHomeListResponse SharedHomeListResponse
|
||||
if err := json.Unmarshal(body, &sharedHomeListResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !sharedHomeListResponse.Success {
|
||||
return nil, errors.New(sharedHomeListResponse.Msg)
|
||||
}
|
||||
|
||||
return &sharedHomeListResponse, nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) GetRoomList(homeId string) (*RoomListResponse, error) {
|
||||
url := fmt.Sprintf("https://%s/api/new/common/roomList", c.baseUrl)
|
||||
|
||||
data := RoomListRequest{
|
||||
HomeId: homeId,
|
||||
}
|
||||
|
||||
body, err := c.request("POST", url, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var roomListResponse RoomListResponse
|
||||
if err := json.Unmarshal(body, &roomListResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !roomListResponse.Success {
|
||||
return nil, errors.New(roomListResponse.Msg)
|
||||
}
|
||||
|
||||
return &roomListResponse, nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) initToken() error {
|
||||
tokenUrl := fmt.Sprintf("https://%s/api/login/token", c.baseUrl)
|
||||
|
||||
tokenReq := LoginTokenRequest{
|
||||
CountryCode: c.countryCode,
|
||||
Username: c.email,
|
||||
IsUid: false,
|
||||
}
|
||||
|
||||
body, err := c.request("POST", tokenUrl, tokenReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tokenResp LoginTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !tokenResp.Success {
|
||||
return errors.New(tokenResp.Msg)
|
||||
}
|
||||
|
||||
encryptedPassword, err := EncryptPassword(c.password, tokenResp.Result.PbKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %v", err)
|
||||
}
|
||||
var loginUrl string
|
||||
|
||||
loginReq := PasswordLoginRequest{
|
||||
CountryCode: c.countryCode,
|
||||
Passwd: encryptedPassword,
|
||||
Token: tokenResp.Result.Token,
|
||||
IfEncrypt: 1,
|
||||
Options: `{"group":1}`,
|
||||
}
|
||||
|
||||
if IsEmailAddress(c.email) {
|
||||
loginUrl = fmt.Sprintf("https://%s/api/private/email/login", c.baseUrl)
|
||||
loginReq.Email = c.email
|
||||
} else {
|
||||
loginUrl = fmt.Sprintf("https://%s/api/private/phone/login", c.baseUrl)
|
||||
loginReq.Mobile = c.email
|
||||
}
|
||||
|
||||
body, err = c.request("POST", loginUrl, loginReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var loginResp *PasswordLoginResponse
|
||||
if err := json.Unmarshal(body, &loginResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !loginResp.Success {
|
||||
return errors.New(loginResp.ErrorMsg)
|
||||
}
|
||||
|
||||
c.mqttsUrl = fmt.Sprintf("ssl://%s:%d", loginResp.Result.Domain.MobileMqttsUrl, loginResp.Result.Domain.MqttsPort)
|
||||
c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) {
|
||||
url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl)
|
||||
|
||||
data := SmartApiWebRTCConfigRequest{
|
||||
DevId: c.deviceId,
|
||||
ClientTraceId: fmt.Sprintf("%x", rand.Int63()),
|
||||
}
|
||||
|
||||
body, err := c.request("POST", url, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var webRTCConfigResponse SmartApiWebRTCConfigResponse
|
||||
err = json.Unmarshal(body, &webRTCConfigResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !webRTCConfigResponse.Success {
|
||||
return nil, errors.New(webRTCConfigResponse.Msg)
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store LocalKey
|
||||
c.localKey = webRTCConfigResponse.Result.LocalKey
|
||||
|
||||
iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.iceServers, err = webrtc.UnmarshalICEServers(iceServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WebRTCConfig{
|
||||
AudioAttributes: webRTCConfigResponse.Result.AudioAttributes,
|
||||
Auth: webRTCConfigResponse.Result.Auth,
|
||||
ID: webRTCConfigResponse.Result.Id,
|
||||
MotoID: webRTCConfigResponse.Result.MotoId,
|
||||
P2PConfig: webRTCConfigResponse.Result.P2PConfig,
|
||||
ProtocolVersion: webRTCConfigResponse.Result.ProtocolVersion,
|
||||
Skill: webRTCConfigResponse.Result.Skill,
|
||||
SupportsWebRTCRecord: webRTCConfigResponse.Result.SupportWebrtcRecord,
|
||||
SupportsWebRTC: webRTCConfigResponse.Result.SupportsWebrtc,
|
||||
VedioClaritiy: webRTCConfigResponse.Result.VedioClarity,
|
||||
VideoClaritiy: webRTCConfigResponse.Result.VideoClarity,
|
||||
VideoClarities: webRTCConfigResponse.Result.VedioClaritys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) loadHubConfig() (config *MQTTConfig, err error) {
|
||||
mqttUrl := fmt.Sprintf("https://%s/api/jarvis/mqtt", c.baseUrl)
|
||||
|
||||
mqttBody, err := c.request("POST", mqttUrl, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mqttConfigResponse MQTTConfigResponse
|
||||
err = json.Unmarshal(mqttBody, &mqttConfigResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !mqttConfigResponse.Success {
|
||||
return nil, errors.New(mqttConfigResponse.Msg)
|
||||
}
|
||||
|
||||
return &MQTTConfig{
|
||||
Url: c.mqttsUrl,
|
||||
ClientID: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid),
|
||||
Username: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid),
|
||||
Password: mqttConfigResponse.Result.Password,
|
||||
PublishTopic: "/av/moto/moto_id/u/{device_id}",
|
||||
SubscribeTopic: fmt.Sprintf("/av/u/%s", mqttConfigResponse.Result.Msid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TuyaSmartApiClient) request(method string, url string, body any) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://%s", c.baseUrl))
|
||||
|
||||
response, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
res, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -63,12 +63,12 @@ func (c *Conn) SetAnswer(answer string) (err error) {
|
||||
SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer),
|
||||
}
|
||||
if err = c.pc.SetRemoteDescription(desc); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err = sd.Unmarshal([]byte(answer)); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Medias = UnmarshalMedias(sd.MediaDescriptions)
|
||||
|
||||
+10
-10
@@ -161,16 +161,7 @@ func (c *Conn) AddCandidate(candidate string) error {
|
||||
return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate})
|
||||
}
|
||||
|
||||
func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver {
|
||||
for _, tr := range c.pc.GetTransceivers() {
|
||||
if tr.Mid() == mid {
|
||||
return tr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) getSenderTrack(mid string) *Track {
|
||||
func (c *Conn) GetSenderTrack(mid string) *Track {
|
||||
if tr := c.getTranseiver(mid); tr != nil {
|
||||
if s := tr.Sender(); s != nil {
|
||||
if t := s.Track().(*Track); t != nil {
|
||||
@@ -181,6 +172,15 @@ func (c *Conn) getSenderTrack(mid string) *Track {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver {
|
||||
for _, tr := range c.pc.GetTransceivers() {
|
||||
if tr.Mid() == mid {
|
||||
return tr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) {
|
||||
for _, tr := range c.pc.GetTransceivers() {
|
||||
// search Transeiver for this TrackRemote
|
||||
|
||||
@@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
|
||||
panic(core.Caller())
|
||||
}
|
||||
|
||||
localTrack := c.getSenderTrack(media.ID)
|
||||
localTrack := c.GetSenderTrack(media.ID)
|
||||
if localTrack == nil {
|
||||
return errors.New("webrtc: can't get track")
|
||||
}
|
||||
|
||||
+13
-12
@@ -185,8 +185,8 @@ func LookupIP(address string) (string, error) {
|
||||
}
|
||||
|
||||
// GetPublicIP example from https://github.com/pion/stun
|
||||
func GetPublicIP() (net.IP, error) {
|
||||
conn, err := net.Dial("udp", "stun.l.google.com:19302")
|
||||
func GetPublicIP(address string) (net.IP, error) {
|
||||
conn, err := net.Dial("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,18 +225,19 @@ func GetPublicIP() (net.IP, error) {
|
||||
var cachedIP net.IP
|
||||
var cachedTS time.Time
|
||||
|
||||
func GetCachedPublicIP() (net.IP, error) {
|
||||
now := time.Now()
|
||||
if now.After(cachedTS) {
|
||||
newIP, err := GetPublicIP()
|
||||
if err == nil {
|
||||
cachedIP = newIP
|
||||
cachedTS = now.Add(time.Minute * 5)
|
||||
} else if cachedIP == nil {
|
||||
return nil, err
|
||||
func GetCachedPublicIP(stuns ...string) (net.IP, error) {
|
||||
if now := time.Now(); now.After(cachedTS) {
|
||||
for _, addr := range stuns {
|
||||
if ip, _ := GetPublicIP(addr); ip != nil {
|
||||
cachedIP = ip
|
||||
cachedTS = now.Add(time.Minute * 5)
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cachedIP == nil {
|
||||
return nil, errors.New("webrtc: can't get public IP")
|
||||
}
|
||||
return cachedIP, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tls
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CreateCertificate() (*tls.Certificate, error) {
|
||||
// 1. Generate an RSA private key
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Define the certificate template
|
||||
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"home"},
|
||||
CommonName: "localhost",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour), // Valid for 1 year
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
|
||||
// Add localhost as a valid IP and DNS name
|
||||
IPAddresses: []net.IP{[]byte{127, 0, 0, 1}},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
|
||||
// 3. Create a self-signed certificate
|
||||
// The parent is the template itself, and we use the generated public and private keys.
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
derBytes = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
|
||||
cert, err := tls.X509KeyPair(derBytes, keyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+131
-2
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - Add Stream</title>
|
||||
<title>add - go2rtc</title>
|
||||
<style>
|
||||
main > button {
|
||||
background-color: #444;
|
||||
@@ -330,6 +330,53 @@
|
||||
</script>
|
||||
|
||||
|
||||
<button id="tuya">Tuya</button>
|
||||
<div>
|
||||
<form id="tuya-credentials-form">
|
||||
<select name="region" required>
|
||||
<option value="protect-eu.ismartlife.me">EU Central</option>
|
||||
<option value="protect-we.ismartlife.me">EU East</option>
|
||||
<option value="protect-us.ismartlife.me">US West</option>
|
||||
<option value="protect-ue.ismartlife.me">US East</option>
|
||||
<option value="protect.ismartlife.me">China</option>
|
||||
<option value="protect-in.ismartlife.me">India</option>
|
||||
</select>
|
||||
<input type="email" name="email" placeholder="email" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<table id="tuya-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('tuya').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
});
|
||||
|
||||
document.getElementById('tuya-credentials-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const table = document.getElementById('tuya-table');
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/tuya?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
|
||||
if (!r.ok) {
|
||||
table.innerText = (await r.text()) || 'Unknown error';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
|
||||
table.innerText = '';
|
||||
|
||||
drawTable(table, data);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="roborock">Roborock</button>
|
||||
<div>
|
||||
<form id="roborock-form">
|
||||
@@ -366,6 +413,88 @@
|
||||
</script>
|
||||
|
||||
|
||||
<button id="xiaomi">Xiaomi</button>
|
||||
<div>
|
||||
<form id="xiaomi-login-form">
|
||||
<input type="text" name="username" placeholder="username" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<form id="xiaomi-captcha-form">
|
||||
<img id="xiaomi-captcha">
|
||||
<input type="text" name="captcha" placeholder="captcha" required size="10">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
<form id="xiaomi-verify-form">
|
||||
<label id="xiaomi-verify"></label>
|
||||
<input type="text" name="verify" placeholder="verify" required size="10">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
<form id="xiaomi-devices-form">
|
||||
<select id="xiaomi-id" name="id" required></select>
|
||||
<select name="region" required>
|
||||
<option value="cn">China</option>
|
||||
<option value="de">Europe</option>
|
||||
<option value="i2">India</option>
|
||||
<option value="ru">Russia</option>
|
||||
<option value="sg">Singapore</option>
|
||||
<option value="us">United States</option>
|
||||
</select>
|
||||
<button type="submit">load devices</button>
|
||||
</form>
|
||||
<table id="xiaomi-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
async function xiaomiReload(ev) {
|
||||
if (ev) ev.target.nextElementSibling.style.display = 'grid';
|
||||
|
||||
document.getElementById('xiaomi-login-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-captcha-form').style.display = 'none';
|
||||
document.getElementById('xiaomi-verify-form').style.display = 'none';
|
||||
|
||||
const r = await fetch('api/xiaomi', {'cache': 'no-cache'});
|
||||
const data = await r.json();
|
||||
const users = document.getElementById('xiaomi-id');
|
||||
users.innerHTML = data.map(item => `<option value="${item}">${item}</option>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('xiaomi').addEventListener('click', xiaomiReload);
|
||||
|
||||
async function xiaomiLogin(ev) {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
const r = await fetch('api/xiaomi', {method: 'POST', body: params});
|
||||
if (r.status === 401) {
|
||||
/** @type {{captcha: string, verify_email: string, verify_phone: string}} */
|
||||
const data = await r.json();
|
||||
document.getElementById('xiaomi-login-form').style.display = 'none';
|
||||
if (data.captcha) {
|
||||
document.getElementById('xiaomi-captcha-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-captcha').src = 'data:image/jpeg;base64,' + data.captcha;
|
||||
} else {
|
||||
document.getElementById('xiaomi-verify-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-verify').innerText = data.verify_email || data.verify_phone;
|
||||
}
|
||||
} else if (r.ok) {
|
||||
alert('OK');
|
||||
xiaomiReload();
|
||||
} else {
|
||||
alert('ERROR: ' + await r.text());
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('xiaomi-login-form').addEventListener('submit', xiaomiLogin);
|
||||
document.getElementById('xiaomi-captcha-form').addEventListener('submit', xiaomiLogin);
|
||||
document.getElementById('xiaomi-verify-form').addEventListener('submit', xiaomiLogin);
|
||||
|
||||
document.getElementById('xiaomi-devices-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
await getSources('xiaomi-table', 'api/xiaomi?' + params.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="webtorrent">WebTorrent Shares</button>
|
||||
<div>
|
||||
<table id="webtorrent-table"></table>
|
||||
@@ -379,4 +508,4 @@
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>go2rtc - WebRTC</title>
|
||||
<title>codecs - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - Config</title>
|
||||
<title>config - go2rtc</title>
|
||||
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
||||
<style>
|
||||
html, body, #config {
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - HLS</title>
|
||||
<title>hls - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
@@ -24,6 +24,7 @@
|
||||
const url = new URL('api/stream.m3u8' + location.search, location.href);
|
||||
|
||||
const video = document.getElementById('video');
|
||||
/* global Hls */
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(url.toString());
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@
|
||||
const isChecked = checkboxStates[name] ? 'checked' : '';
|
||||
tr.innerHTML =
|
||||
`<td><label><input type="checkbox" name="${name}" ${isChecked}>${name}</label></td>` +
|
||||
`<td><a href="api/streams?src=${src}">${online} / info</a> / <a href="api/streams?src=${src}&video=all&audio=allµphone">probe</a> / <a href="network.html?src=${src}">net</a></td>` +
|
||||
`<td><a href="api/streams?src=${src}">${online} / info</a> / <a href="api/streams?src=${src}&video=all&audio=allµphone">probe</a> / <a href="net.html?src=${src}">net</a></td>` +
|
||||
`<td>${links}</td>`;
|
||||
}
|
||||
|
||||
|
||||
+37
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - links</title>
|
||||
<title>links - go2rtc</title>
|
||||
<style>
|
||||
div > li {
|
||||
list-style-type: none;
|
||||
@@ -71,6 +71,42 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="homekit" style="display: none">
|
||||
<h2>HomeKit server</h2>
|
||||
</div>
|
||||
<script>
|
||||
fetch(`api/homekit?id=${src}`, {cache: 'no-cache'}).then(async (r) => {
|
||||
if (!r.ok) return;
|
||||
|
||||
const div = document.querySelector('#homekit');
|
||||
div.innerHTML += `<div><a href="${r.url}">info.json</a> page with active connections</div>`;
|
||||
div.style = '';
|
||||
|
||||
/** @type {{name: string, category_id: string, setup_code: string, setup_id: string, setup_uri: string}} */
|
||||
const data = await r.json();
|
||||
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.async = true;
|
||||
script.onload = () => {
|
||||
/* global BigInt */
|
||||
const categoryID = BigInt(data.category_id);
|
||||
const pin = BigInt(data.setup_code.replaceAll('-', ''));
|
||||
const payload = categoryID << BigInt(31) | BigInt(2 << 27) | pin;
|
||||
const setupURI = `X-HM://${payload.toString(36).toUpperCase().padStart(9, '0')}${data.setup_id}`;
|
||||
|
||||
div.innerHTML += `<pre>Setup Name: ${data.name}
|
||||
Setup Code: ${data.setup_code}</pre>
|
||||
<div id="homekit-qrcode"></div>`;
|
||||
|
||||
/* global QRCode */
|
||||
new QRCode('homekit-qrcode', {text: setupURI, width: 128, height: 128});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2>Play audio</h2>
|
||||
<label><input type="radio" name="play" value="file" checked>
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - Logs</title>
|
||||
<title>log - go2rtc</title>
|
||||
<style>
|
||||
main > div {
|
||||
display: flex;
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
document.head.innerHTML += `
|
||||
<style>
|
||||
body {
|
||||
background-color: white; /* fix Hass black theme */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -57,7 +58,7 @@ document.head.innerHTML += `
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="text"], input[type="email"], input[type="password"], select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - Network</title>
|
||||
<title>net - go2rtc</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<style>
|
||||
html, body, #network {
|
||||
|
||||
+6
-4
@@ -2,10 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
||||
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
|
||||
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
|
||||
<title>go2rtc - Stream</title>
|
||||
<link rel="apple-touch-icon" href="https://go2rtc.org/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
||||
<link rel="icon" href="https://go2rtc.org/icons/favicon.ico">
|
||||
<link rel="manifest" href="https://go2rtc.org/manifest.json">
|
||||
<title>stream - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background: black;
|
||||
@@ -60,6 +60,8 @@
|
||||
video.src = new URL('api/ws?src=' + encodeURIComponent(streams[i]), location.href);
|
||||
document.body.appendChild(video);
|
||||
}
|
||||
|
||||
document.title = streams.join('/') + ' - go2rtc';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export class VideoRTC extends HTMLElement {
|
||||
*/
|
||||
this.pcConfig = {
|
||||
bundlePolicy: 'max-bundle',
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>go2rtc - WebRTC</title>
|
||||
<title>webrtc - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
@@ -21,7 +21,7 @@
|
||||
<script>
|
||||
async function PeerConnection(media) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}]
|
||||
});
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream([
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>go2rtc - WebRTC</title>
|
||||
<title>webrtc - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
@@ -21,7 +21,7 @@
|
||||
<script>
|
||||
async function PeerConnection(media) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}]
|
||||
});
|
||||
|
||||
const localTracks = [];
|
||||
|
||||
Reference in New Issue
Block a user