Compare commits

..

143 Commits

Author SHA1 Message Date
Alex X dfe47559d1 Update version to 1.9.13 2025-12-14 23:05:24 +03:00
Alex X eabd7d60cd Clean go.sum file 2025-12-14 22:43:18 +03:00
Alex X dfda4b11ff Add about xiaomi source to readme 2025-12-14 22:41:00 +03:00
Alex X 353262307b Update links to icons in resources 2025-12-14 22:35:58 +03:00
Alex X d734140eaf Update reamde 2025-12-14 22:31:13 +03:00
Alex X 2409bb56d7 Move tuya docs to separate page 2025-12-14 21:54:06 +03:00
Alex X df484cc904 Add eseecloud source to readme 2025-12-14 21:45:44 +03:00
Alex X 7e0c7a8173 Merge pull request #1966 from oeiber/master
Update documentation for doorbird devices
2025-12-14 21:29:38 +03:00
Alex X 7eaa4a1b55 Code refactoring for #1966 2025-12-14 21:28:37 +03:00
Alex X 03941a5691 Merge pull request #1977 from edenhaus/preload-list
Add get request to preload endpoint for listing them
2025-12-14 20:20:22 +03:00
Alex X 28821c41e0 Code refactoring for #1977 2025-12-14 20:17:13 +03:00
Alex X 8636e96379 Change ffmpeg transcoder from opus to opus/16000 2025-12-14 17:59:07 +03:00
Alex X 7119384184 Fix backchannel audio for xiaomi chuangmi.camera.72ac1 2025-12-14 17:58:17 +03:00
Alex X 57b0ace802 Add vendor name to xiaomi unsupported vendor message 2025-12-14 17:18:25 +03:00
Alex X b0f46bc919 Fix backchannel audio for xiaomi isa.camera.hlc6 2025-12-14 17:17:52 +03:00
Alex X a4d4598a13 Add support xiaomi source 2025-12-14 13:07:45 +03:00
Alex X 17c1f69f66 Update dependencies 2025-12-13 14:25:51 +03:00
Alex X fb31a251b8 Improve fetch for exec source 2025-12-13 14:00:16 +03:00
Alex X c5277daa45 Add UnmarshalHeader func to OPUS codec 2025-12-11 22:01:36 +03:00
Alex X 76a5e160c2 Increase default dial timeout and probe timeout 2025-12-11 22:00:35 +03:00
Alex X 494daed937 Add aliases to PCMA/PCMU codecs 2025-12-11 21:59:57 +03:00
Alex X a86e10446a Add PCML/8000 to ffmpeg transcoder 2025-12-11 21:59:25 +03:00
Robert Resch 209b73a0f1 Add get request to preload endpoint for listing them 2025-12-10 23:30:08 +01:00
oeiber 54473ff1de Update documentation for doorbird devices 2025-12-03 17:04:40 +01:00
Alex X 7eb5fe0355 Add second STUN server from Cloudflare 2025-12-01 14:19:48 +03:00
Alex X fbd5215669 Add custom title to stream page #1957 2025-11-26 11:24:57 +03:00
Alex X 3ebc115cc5 Update title for all WebUI pages 2025-11-26 11:24:57 +03:00
Alex X 6d1a95a4e3 Merge pull request #1730 from seydx/tuya-new
New Tuya camera support
2025-11-25 20:36:13 +03:00
Alex X 4ec2849008 Fix WebUI for tuya source 2025-11-25 20:14:00 +03:00
Alex X 4ef6a147a6 Fix panic on login for tuya source 2025-11-25 20:13:45 +03:00
Alex X 94df080bf7 Fix MQTT url for tuya source for some regions 2025-11-25 20:13:22 +03:00
Alex X 86edd814c9 Add support new audio codec for tapo source #1954 2025-11-25 15:51:55 +03:00
Alex X 5a259993d8 Update dependencies 2025-11-22 20:04:05 +03:00
Alex X b6fe8871df Add self-signed cert generator (not used yet) 2025-11-22 20:00:34 +03:00
Alex X b47a2ba73c Add mod_pinggy example 2025-11-22 19:59:46 +03:00
Alex X 4934fa4cc1 Add support tunnels via Pinggy #1853 2025-11-22 19:59:10 +03:00
seydx 61f74820bc Merge branch 'AlexxIT:master' into tuya-new 2025-11-20 21:00:41 +01:00
Alex X 248fc7a11a Remove wrong timeout for http source 2025-11-20 20:03:34 +03:00
Alex X aa0ece2d1e Fix adts producer for VLC player support #1643 2025-11-20 17:52:08 +03:00
Alex X 68036b68c1 Fix timestamp processing for HTTP-FLV 2025-11-19 12:58:23 +03:00
seydx 319dbf2c63 Refactor after merge 2025-11-19 00:10:13 +01:00
seydx 4b419309a8 Merge branch 'master' of https://github.com/AlexxIT/go2rtc into tuya-new 2025-11-19 00:09:34 +01:00
Alex X 42e7a03534 Add HomeKit QR code to WebUI #1138 by @mnakada 2025-11-18 20:55:17 +03:00
Alex X 290e8fcfda Add custom category_id for HomeKit server #985 by @Minims 2025-11-18 20:52:32 +03:00
Alex X 6c78b5cb53 Add 404 error for homekit API request 2025-11-18 20:40:30 +03:00
Alex X c72b205d87 Fix panic for HomeKit server proxy mode #1940 2025-11-18 20:38:04 +03:00
Alex X 2cd009646a Remove homekit source params from streams info API 2025-11-18 19:17:26 +03:00
Alex X 8f5fce4d73 Add support HTTP-FLV with H265 from new Reolink cameras #1938 2025-11-18 16:05:51 +03:00
Alex X b705cadc04 Merge pull request #1942 from sethyx/fix/network-url
Fix link to per-stream net/node graphs
2025-11-18 11:17:04 +03:00
Gergely Szell 2523a5ac38 Fix link to per-stream net/node graphs 2025-11-18 09:12:38 +01:00
Alex X e246e2e756 Fix WebUI for Hass black theme 2025-11-17 12:21:45 +03:00
Alex X 2dc0d58ba7 Update version to 1.9.12 2025-11-16 19:08:34 +03:00
Alex X cb22ae7833 Add security notes to readme 2025-11-16 19:07:03 +03:00
Alex X c98b0a83c4 Merge pull request #1939 from edenhaus/supportedSchemas
Add api endpoint to return supported schemas
2025-11-16 19:04:24 +03:00
Alex X 0bae158e41 Code refactoring for #1939 2025-11-16 19:03:19 +03:00
Robert Resch e2b63a4f6c Remove duplicate code 2025-11-16 16:40:04 +01:00
Robert Resch 3897f10a4d Add api endpoint to return supported schema 2025-11-16 16:33:09 +01:00
Alex X ac80f1470e Add errors output to streams API 2025-11-16 18:20:53 +03:00
Alex X 1fe602679e Update WebUI design 2025-11-15 21:08:00 +03:00
Alex X e2c7d06730 Add check for insecure uri from onvif source 2025-11-11 17:34:01 +03:00
Alex X 2133f5323c Add insecure sources logic 2025-11-11 17:33:15 +03:00
Alex X c10a06d199 Fix wrong log message for streams module 2025-11-11 17:29:10 +03:00
Alex X d053d88ce9 Add support maxwidth/maxheight settings for homekit source 2025-11-11 15:48:12 +03:00
Alex X 2ce38b4486 Add trace log for ignored api paths 2025-11-11 15:43:49 +03:00
Alex X 44d59b1696 Add config local_auth for api module 2025-11-11 15:22:28 +03:00
Alex X 15ec995ecc Add config for the list of modules to init 2025-11-11 15:10:24 +03:00
Alex X 231cab36b2 Add config allow_paths for api module 2025-11-11 15:01:27 +03:00
Alex X 640db3029e Add config allow_paths for exec module 2025-11-11 15:00:58 +03:00
Alex X 2836fdae13 Add config allow_paths for echo module 2025-11-11 14:59:05 +03:00
Alex X 964bb225fa Add support custom params for hass source 2025-11-11 10:54:13 +03:00
Alex X 5cc32197b8 Fix HomeKit proxy for hass source 2025-11-10 15:35:07 +03:00
Alex X bc1a4ac8e4 Fix API /api/homekit/accessories 2025-11-10 15:34:39 +03:00
Alex X 158f9d3a08 Code refactoring for HomeKit server 2025-11-09 21:58:44 +03:00
Alex X 81cfcf877a Fix HomeKit proxy EVENTs 2025-11-09 21:28:53 +03:00
Alex X 96919bf9e3 Add support uint64 to tlv8 2025-11-09 21:25:23 +03:00
Alex X e4359ac217 Rename HomeKit structures according to specs 2025-11-09 21:24:47 +03:00
seydx 56d7a6fee4 Add comments and improve repackaging 2025-10-28 14:54:54 +01:00
seydx 292b32af99 Optimize audio frame size handling in AddTrack to reduce latency for Tuya cameras 2025-10-28 13:53:42 +01:00
seydx 33e4527042 Revert repackaging for backchannel 2025-10-28 12:58:00 +01:00
seydx 62a9046f01 Revert configurable packet size for RepackG711 2025-10-28 12:50:36 +01:00
seydx 25e7ac531e Cleanup and update comments 2025-10-28 11:12:44 +01:00
seydx 0f27bb1124 Update SendOffer to include token and fix mqtt close 2025-10-27 23:57:54 +01:00
seydx 0ff3bf67e1 Comment out MQTT speaker message sending in AddTrack function 2025-10-27 23:57:14 +01:00
seydx 6d3d45e337 Handle speaker messages 2025-10-27 21:48:44 +01:00
seydx c6940eb0f3 Refactor AddTrack to use GetSenderTrack and improve audio handling 2025-10-27 21:48:35 +01:00
seydx 8142d2fc43 Refactor RepackG711 to use configurable packet size 2025-10-27 21:48:15 +01:00
seydx 721ed98afb webrtc: export GetSenderTrack 2025-10-27 21:47:52 +01:00
seydx fb8c6e1b1b Update comments 2025-10-26 22:21:56 +01:00
seydx 80ab32379c Merge branch 'AlexxIT:master' into tuya-new 2025-10-26 16:57:54 +01:00
seydx 863174839c Fix video/audio ssrc and low power cameras 2025-10-26 16:39:59 +01:00
Alex X ff18283d11 Improve homekit secure conn buffers 2025-10-26 15:46:11 +03:00
Alex X 994e0dc526 Improve homekit tlv8 parsing 2025-10-26 15:46:11 +03:00
Alex X 7254bd4fbc Code refactoring for tapo source 2025-10-24 17:54:55 +03:00
Alex X 9f407a754d Fix tapo source for some cameras #1918 2025-10-24 17:54:37 +03:00
Alex X cc97bc33c4 Restore simple onvif client logic 2025-10-24 17:28:49 +03:00
seydx bd8e4fa298 Merge branch 'AlexxIT:master' into tuya-new 2025-10-24 13:10:12 +02:00
Alex X 6db4dda535 Fix onvif client for some cameras 2025-10-23 14:42:38 +03:00
seydx 7abc963a50 Merge branch 'AlexxIT:master' into tuya-new 2025-10-17 03:15:43 +02:00
seydx ee5e31d3b3 Merge branch 'master' of https://github.com/AlexxIT/go2rtc into tuya-new 2025-09-30 19:14:43 +02:00
seydx 5c01cbad9e Merge branch 'AlexxIT:master' into tuya-new 2025-09-26 15:07:52 +02:00
seydx e9611769be deps 2025-07-10 16:12:07 +02:00
seydx c5dfa84ff2 readme 2025-07-10 16:09:18 +02:00
seydx da68101c09 Optimize URL decoding and update MQTT keep-alive 2025-07-10 16:09:18 +02:00
seydx 30c418542c readme 2025-07-10 16:09:18 +02:00
seydx b58c1a7ed6 Update README.md
Co-authored-by: Felipe Santos <felipecassiors@gmail.com>
2025-07-10 16:09:18 +02:00
seydx 47e87281a1 increase timeout 2025-07-10 16:09:18 +02:00
seydx 60b6b93ff8 fix concurrent writes and improve mqtt 2025-07-10 16:09:18 +02:00
seydx 3149b6f750 Update README.md
Co-authored-by: Felipe Santos <felipecassiors@gmail.com>
2025-07-10 16:09:18 +02:00
seydx e44f1ad53e Update README.md
Co-authored-by: Felipe Santos <felipecassiors@gmail.com>
2025-07-10 16:09:18 +02:00
seydx c38c8a7fce readme 2025-07-10 16:09:18 +02:00
seydx 9efc717633 ... 2025-07-10 16:09:18 +02:00
seydx a2d422f5cb format 2025-07-10 16:09:18 +02:00
seydx 3036dd7cfe refactor 2025-07-10 16:09:18 +02:00
seydx 5be5d9247c refactor, simplify api, add support for email/password auth 2025-07-10 16:09:18 +02:00
seydx 42b7eea852 fix: correct transceiver direction check in getSender method 2025-07-10 16:09:18 +02:00
seydx 7ee3f6e4f7 Update README.md
Co-authored-by: Felipe Santos <felipecassiors@gmail.com>
2025-07-10 16:09:18 +02:00
seydx fbd8d995ed refactor and increase timeout 2025-07-10 16:09:18 +02:00
seydx 998c85d6f5 - support adding cameras via interface
- support qr code auth
- support resolution change
- support h265
- refactor code
2025-07-10 16:09:18 +02:00
seydx 67dfc942a0 update useful links 2025-07-10 16:09:18 +02:00
seydx 1cc8b373de change query 2025-07-10 16:09:18 +02:00
seydx f045f3fccd dont expose raw url in stream info 2025-07-10 16:09:18 +02:00
seydx 691f6d9cdd update README 2025-07-10 16:09:18 +02:00
seydx 8a8fb66eeb Update README.md
Co-authored-by: Felipe Santos <felipecassiors@gmail.com>
2025-07-10 16:09:18 +02:00
seydx e7044a93f6 fix video/audio and minor improvements 2025-07-10 16:09:18 +02:00
seydx a9bcb46f38 refactor 2025-07-10 16:09:18 +02:00
seydx 16a812c8b8 revert webrtc 2025-07-10 16:09:18 +02:00
seydx 27fe2622ec revert demuxer 2025-07-10 16:09:18 +02:00
seydx 3d222136f9 support h265 2025-07-10 16:09:18 +02:00
seydx 524cdb7176 demuxer: support hvc and pcm 2025-07-10 16:09:18 +02:00
seydx 499dc10390 comments 2025-07-10 16:09:18 +02:00
seydx e7bd3d401f wip h265 datachannel 2025-07-10 16:09:18 +02:00
seydx 5ec942cb5e fix stream mode 2025-07-10 16:09:18 +02:00
seydx 6c255cd2f2 fix two way audio 2025-07-10 16:09:18 +02:00
seydx 4f969d750a readme 2025-07-10 16:09:18 +02:00
seydx bd2cbe20e0 add useful links 2025-07-10 16:09:18 +02:00
seydx 6d8d6a91ef format 2025-07-10 16:09:18 +02:00
seydx 05c12b34e5 refactor 2025-07-10 16:09:18 +02:00
seydx 43b7a662c1 use streamType parameter 2025-07-10 16:09:18 +02:00
seydx a7e76db464 change hls url and query and add more checks 2025-07-10 16:09:18 +02:00
seydx b797a2fcd1 add HLS support and fix skill response 2025-07-10 16:09:18 +02:00
seydx 6e35f1a389 add rtsp support 2025-07-10 16:09:18 +02:00
seydx 30d48e139c implement skill handling and media configuration 2025-07-10 16:09:18 +02:00
seydx e74fc6f198 add tuya source 2025-07-10 16:09:18 +02:00
118 changed files with 7588 additions and 2159 deletions
+85 -44
View File
@@ -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) - 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.) - [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) - 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) - 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) - 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 - mixing tracks from different sources to single stream
- auto-match client-supported codecs - 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) - can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary)
**Inspired by:** **Inspired by:**
@@ -66,6 +65,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
* [Source: DVRIP](#source-dvrip) * [Source: DVRIP](#source-dvrip)
* [Source: Tapo](#source-tapo) * [Source: Tapo](#source-tapo)
* [Source: Kasa](#source-kasa) * [Source: Kasa](#source-kasa)
* [Source: Tuya](#source-tuya)
* [Source: Xiaomi](#source-xiaomi)
* [Source: GoPro](#source-gopro) * [Source: GoPro](#source-gopro)
* [Source: Ivideon](#source-ivideon) * [Source: Ivideon](#source-ivideon)
* [Source: Hass](#source-hass) * [Source: Hass](#source-hass)
@@ -73,6 +74,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
* [Source: Nest](#source-nest) * [Source: Nest](#source-nest)
* [Source: Ring](#source-ring) * [Source: Ring](#source-ring)
* [Source: Roborock](#source-roborock) * [Source: Roborock](#source-roborock)
* [Source: Doorbird](#source-doorbird)
* [Source: WebRTC](#source-webrtc) * [Source: WebRTC](#source-webrtc)
* [Source: WebTorrent](#source-webtorrent) * [Source: WebTorrent](#source-webtorrent)
* [Incoming sources](#incoming-sources) * [Incoming sources](#incoming-sources)
@@ -202,14 +204,18 @@ Available source types:
- [homekit](#source-homekit) - streaming from HomeKit Camera - [homekit](#source-homekit) - streaming from HomeKit Camera
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
- [dvrip](#source-dvrip) - streaming from DVR-IP 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 - [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 - [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 - [kasa](#source-tapo) - TP-Link Kasa cameras
- [gopro](#source-gopro) - GoPro cameras - [gopro](#source-gopro) - GoPro cameras
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
- [hass](#source-hass) - Home Assistant integration - [hass](#source-hass) - Home Assistant integration
- [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras - [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras
- [roborock](#source-roborock) - Roborock vacuums with 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 - [webrtc](#source-webrtc) - WebRTC/WHEP sources
- [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc
@@ -224,8 +230,11 @@ Supported sources:
- [TP-Link Tapo](#source-tapo) cameras - [TP-Link Tapo](#source-tapo) cameras
- [Hikvision ISAPI](#source-isapi) cameras - [Hikvision ISAPI](#source-isapi) cameras
- [Roborock vacuums](#source-roborock) models with cameras - [Roborock vacuums](#source-roborock) models with cameras
- [Doorbird](#source-doorbird) cameras
- [Exec](#source-exec) audio on server - [Exec](#source-exec) audio on server
- [Ring](#source-ring) cameras - [Ring](#source-ring) cameras
- [Tuya](#source-tuya) cameras
- [Xiaomi](#source-xiaomi) cameras
- [Any Browser](#incoming-browser) as IP-camera - [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)). 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 - 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 #### Source: Tapo
*[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* *[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. 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 #### Source: GoPro
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* *[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. 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 #### Source: WebRTC
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
@@ -879,6 +924,7 @@ api:
listen: ":1984" # default ":1984", HTTP API port ("" - disabled) listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
username: "admin" # default "", Basic auth for WebUI username: "admin" # default "", Basic auth for WebUI
password: "pass" # default "", Basic auth for WebUI password: "pass" # default "", Basic auth for WebUI
local_auth: true # default false, Enable auth check for localhost requests
base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api) base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api)
static_dir: "www" # default "", folder for static files (custom web interface) static_dir: "www" # default "", folder for static files (custom web interface)
origin: "*" # default "", allow CORS requests (only * supported) origin: "*" # default "", allow CORS requests (only * supported)
@@ -1201,6 +1247,27 @@ log:
## Security ## Security
> [!IMPORTANT]
> If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server.
For maximum (paranoid) security, go2rtc has special settings:
```yaml
app:
# use only allowed modules
modules: [api, rtsp, webrtc, exec, ffmpeg, mjpeg]
api:
# use only allowed API paths
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
# enable auth for localhost (used together with username and password)
local_auth: true
exec:
# use only allowed exec paths
allow_paths: [ffmpeg]
```
By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on. By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on.
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config: This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
@@ -1250,25 +1317,22 @@ Some examples:
## Codecs madness ## Codecs madness
`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. 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 | | Device | WebRTC | MSE | HTTP* | HLS |
|--------------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| |--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------|
| *latency* | best | medium | bad | bad | | *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 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 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* | | 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* | | 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* | | macOS [Hass App][1] | no | no | no | H264, H265 <br/> AAC, FLAC* |
[1]: https://apps.apple.com/app/home-assistant/id1099568401 [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 - `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)
- Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) - `MSE iPhone` - supported in [iOS 17.1+](https://webkit.org/blog/14735/webkit-features-in-safari-17-1/)
- 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
**Audio** **Audio**
@@ -1279,7 +1343,7 @@ Some examples:
**Apple devices** **Apple devices**
- all Apple devices don't support HTTP progressive streaming - 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 - HLS is the worst technology for **live** streaming, it still exists only because of iPhones
**Codec names** **Codec names**
@@ -1352,7 +1416,8 @@ streams:
## Projects using go2rtc ## 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 - [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 - [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 - [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - custom firmware for Wyze cameras
@@ -1393,27 +1458,3 @@ streams:
**Snapshots to Telegram** **Snapshots to Telegram**
[read more](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-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).
+8
View File
@@ -238,6 +238,14 @@ paths:
/api/preload: /api/preload:
get:
summary: Get all preloaded streams
tags: [ Streams list ]
responses:
"200":
description: ""
content:
application/json: { example: { camera1: "video&audio", camera2: "video" } }
put: put:
summary: Preload new stream summary: Preload new stream
tags: [ Streams list ] tags: [ Streams list ]
+9
View File
@@ -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
)
+39
View File
@@ -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=
+41
View File
@@ -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()
}
+21 -20
View File
@@ -3,47 +3,48 @@ module github.com/AlexxIT/go2rtc
go 1.24.0 go 1.24.0
require ( 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/expr-lang/expr v1.17.6
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/miekg/dns v1.1.68 github.com/miekg/dns v1.1.69
github.com/pion/ice/v4 v4.0.10 github.com/pion/ice/v4 v4.1.0
github.com/pion/interceptor v0.1.41 github.com/pion/interceptor v0.1.42
github.com/pion/rtcp v1.2.16 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/sdp/v3 v3.0.16
github.com/pion/srtp/v3 v3.0.8 github.com/pion/srtp/v3 v3.0.9
github.com/pion/stun/v3 v3.0.0 github.com/pion/stun/v3 v3.0.2
github.com/pion/webrtc/v4 v4.1.6 github.com/pion/webrtc/v4 v4.1.8
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 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 gopkg.in/yaml.v3 v3.0.1
) )
require ( 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/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/pion/datachannel v1.5.10 // 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/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/randutil v0.1.0 // indirect
github.com/pion/sctp v1.8.40 // indirect github.com/pion/sctp v1.8.41 // indirect
github.com/pion/transport/v3 v3.0.8 // indirect github.com/pion/transport/v3 v3.1.1 // indirect
github.com/pion/turn/v4 v4.1.1 // indirect github.com/pion/turn/v4 v4.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/tools v0.40.0 // indirect
golang.org/x/tools v0.38.0 // indirect
) )
+47 -114
View File
@@ -1,22 +1,20 @@
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= 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.57.1 h1:fEykwH98Nny08kcRbk4uer+S8h0rKveCIpG9F6NVLuA=
github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astikit v0.57.1/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= github.com/asticode/go-astits v1.14.0 h1:zkgnZzipx2XX5mWycqsSBeEyDH58+i4HtyF4j2ROb00=
github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astits v1.14.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
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/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
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/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/datachannel v1.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.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU=
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4=
github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
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/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= 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/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.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 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 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= 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 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= 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.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc=
github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs=
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY=
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/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s=
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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= 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/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/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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= 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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+17 -3
View File
@@ -7,6 +7,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -23,6 +24,7 @@ func Init() {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
LocalAuth bool `yaml:"local_auth"`
BasePath string `yaml:"base_path"` BasePath string `yaml:"base_path"`
StaticDir string `yaml:"static_dir"` StaticDir string `yaml:"static_dir"`
Origin string `yaml:"origin"` Origin string `yaml:"origin"`
@@ -30,6 +32,8 @@ func Init() {
TLSCert string `yaml:"tls_cert"` TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"` TLSKey string `yaml:"tls_key"`
UnixListen string `yaml:"unix_listen"` UnixListen string `yaml:"unix_listen"`
AllowPaths []string `yaml:"allow_paths"`
} `yaml:"api"` } `yaml:"api"`
} }
@@ -43,6 +47,7 @@ func Init() {
return return
} }
allowPaths = cfg.Mod.AllowPaths
basePath = cfg.Mod.BasePath basePath = cfg.Mod.BasePath
log = app.GetLogger("api") log = app.GetLogger("api")
@@ -61,7 +66,7 @@ func Init() {
} }
if cfg.Mod.Username != "" { if cfg.Mod.Username != "" {
Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, Handler) // 2nd Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd
} }
if log.Trace().Enabled() { if log.Trace().Enabled() {
@@ -152,6 +157,10 @@ func HandleFunc(pattern string, handler http.HandlerFunc) {
if len(pattern) == 0 || pattern[0] != '/' { if len(pattern) == 0 || pattern[0] != '/' {
pattern = basePath + "/" + pattern pattern = basePath + "/" + pattern
} }
if allowPaths != nil && !slices.Contains(allowPaths, pattern) {
log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths")
return
}
log.Trace().Str("path", pattern).Msg("[api] register path") log.Trace().Str("path", pattern).Msg("[api] register path")
http.HandleFunc(pattern, handler) http.HandleFunc(pattern, handler)
} }
@@ -185,6 +194,7 @@ func Response(w http.ResponseWriter, body any, contentType string) {
const StreamNotFound = "stream not found" const StreamNotFound = "stream not found"
var allowPaths []string
var basePath string var basePath string
var log zerolog.Logger var log zerolog.Logger
@@ -195,9 +205,13 @@ func middlewareLog(next http.Handler) http.Handler {
}) })
} }
func middlewareAuth(username, password string, next http.Handler) http.Handler { func isLoopback(remoteAddr string) bool {
return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@"
}
func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.RemoteAddr, "127.") && !strings.HasPrefix(r.RemoteAddr, "[::1]") && r.RemoteAddr != "@" { if localAuth || !isLoopback(r.RemoteAddr) {
user, pass, ok := r.BasicAuth() user, pass, ok := r.BasicAuth()
if !ok || user != username || pass != password { if !ok || user != username || pass != password {
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`) w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
+11
View File
@@ -11,6 +11,7 @@ import (
var ( var (
Version string Version string
Modules []string
UserAgent string UserAgent string
ConfigPath string ConfigPath string
Info = make(map[string]any) Info = make(map[string]any)
@@ -76,6 +77,16 @@ func Init() {
if ConfigPath != "" { if ConfigPath != "" {
Logger.Info().Str("path", ConfigPath).Msg("config") Logger.Info().Str("path", ConfigPath).Msg("config")
} }
var cfg struct {
Mod struct {
Modules []string `yaml:"modules"`
} `yaml:"app"`
}
LoadConfig(&cfg)
Modules = cfg.Mod.Modules
} }
func readRevisionTime() (revision, vcsTime string) { func readRevisionTime() (revision, vcsTime string) {
+17
View File
@@ -2,7 +2,9 @@ package echo
import ( import (
"bytes" "bytes"
"errors"
"os/exec" "os/exec"
"slices"
"github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
@@ -10,11 +12,25 @@ import (
) )
func Init() { func Init() {
var cfg struct {
Mod struct {
AllowPaths []string `yaml:"allow_paths"`
} `yaml:"echo"`
}
app.LoadConfig(&cfg)
allowPaths := cfg.Mod.AllowPaths
log := app.GetLogger("echo") log := app.GetLogger("echo")
streams.RedirectFunc("echo", func(url string) (string, error) { streams.RedirectFunc("echo", func(url string) (string, error) {
args := shell.QuoteSplit(url[5:]) args := shell.QuoteSplit(url[5:])
if allowPaths != nil && !slices.Contains(allowPaths, args[0]) {
return "", errors.New("echo: bin not in allow_paths: " + args[0])
}
b, err := exec.Command(args[0], args[1:]...).Output() b, err := exec.Command(args[0], args[1:]...).Output()
if err != nil { if err != nil {
return "", err return "", err
@@ -26,4 +42,5 @@ func Init() {
return string(b), nil return string(b), nil
}) })
streams.MarkInsecure("echo")
} }
+18
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"net/url" "net/url"
"os" "os"
"slices"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -26,6 +27,16 @@ import (
) )
func Init() { func Init() {
var cfg struct {
Mod struct {
AllowPaths []string `yaml:"allow_paths"`
} `yaml:"exec"`
}
app.LoadConfig(&cfg)
allowPaths = cfg.Mod.AllowPaths
rtsp.HandleFunc(func(conn *pkg.Conn) bool { rtsp.HandleFunc(func(conn *pkg.Conn) bool {
waitersMu.Lock() waitersMu.Lock()
waiter := waiters[conn.URL.Path] waiter := waiters[conn.URL.Path]
@@ -45,10 +56,13 @@ func Init() {
}) })
streams.HandleFunc("exec", execHandle) streams.HandleFunc("exec", execHandle)
streams.MarkInsecure("exec")
log = app.GetLogger("exec") log = app.GetLogger("exec")
} }
var allowPaths []string
func execHandle(rawURL string) (prod core.Producer, err error) { func execHandle(rawURL string) (prod core.Producer, err error) {
rawURL, rawQuery, _ := strings.Cut(rawURL, "#") rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
query := streams.ParseQuery(rawQuery) query := streams.ParseQuery(rawQuery)
@@ -73,6 +87,10 @@ func execHandle(rawURL string) (prod core.Producer, err error) {
debug: log.Debug().Enabled(), debug: log.Debug().Enabled(),
} }
if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) {
return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0])
}
if s := query.Get("killsignal"); s != "" { if s := query.Get("killsignal"); s != "" {
sig := syscall.Signal(core.Atoi(s)) sig := syscall.Signal(core.Atoi(s))
cmd.Cancel = func() error { cmd.Cancel = func() error {
+71 -11
View File
@@ -12,34 +12,94 @@
- `fetch` - JS-like HTTP requests - `fetch` - JS-like HTTP requests
- `match` - JS-like RegExp queries - `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** **Two way audio for Dahua VTO**
```yaml ```yaml
streams: streams:
dahua_vto: | dahua_vto: |
expr: let host = "admin:password@192.168.1.123"; expr:
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 let host = 'admin:password@192.168.1.123';
? "rtsp://"+host+"/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif" : ""
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** **dom.ru**
You can get credentials via: You can get credentials from https://github.com/ad/domru
- https://github.com/alexmorbo/domru (file `/share/domru/accounts`)
- https://github.com/ad/domru
```yaml ```yaml
streams: streams:
dom_ru: | dom_ru: |
expr: let camera = "99999999"; let token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; let operator = 99; expr:
fetch("https://myhome.novotelecom.ru/rest/v1/forpost/cameras/"+camera+"/video", { let camera = '***';
headers: {Authorization: "Bearer "+token, Operator: operator} 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 }).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** **Parse HLS files from Apple**
Same example in two languages - python and expr. Same example in two languages - python and expr.
+1
View File
@@ -25,4 +25,5 @@ func Init() {
return url, nil return url, nil
}) })
streams.MarkInsecure("expr")
} }
+3 -2
View File
@@ -46,6 +46,7 @@ func NewProducer(url string) (core.Producer, error) {
{Name: core.CodecPCM, ClockRate: 16000}, {Name: core.CodecPCM, ClockRate: 16000},
{Name: core.CodecPCMA, ClockRate: 16000}, {Name: core.CodecPCMA, ClockRate: 16000},
{Name: core.CodecPCMU, ClockRate: 16000}, {Name: core.CodecPCMU, ClockRate: 16000},
{Name: core.CodecPCML, ClockRate: 8000},
{Name: core.CodecPCM, ClockRate: 8000}, {Name: core.CodecPCM, ClockRate: 8000},
{Name: core.CodecPCMA, ClockRate: 8000}, {Name: core.CodecPCMA, ClockRate: 8000},
{Name: core.CodecPCMU, ClockRate: 8000}, {Name: core.CodecPCMU, ClockRate: 8000},
@@ -95,11 +96,11 @@ func (p *Producer) newURL() string {
codec := receiver.Codec codec := receiver.Codec
switch codec.Name { switch codec.Name {
case core.CodecOpus: case core.CodecOpus:
s += "#audio=opus" s += "#audio=opus/16000"
case core.CodecAAC: case core.CodecAAC:
s += "#audio=aac/16000" s += "#audio=aac/16000"
case core.CodecPCML: case core.CodecPCML:
s += "#audio=pcml/16000" s += "#audio=pcml/" + strconv.Itoa(int(codec.ClockRate))
case core.CodecPCM: case core.CodecPCM:
s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate)) s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate))
case core.CodecPCMA: case core.CodecPCMA:
+2 -2
View File
@@ -30,10 +30,10 @@ func apiStream(w http.ResponseWriter, r *http.Request) {
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name} // 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
// 2. static link to Hass camera // 2. static link to Hass camera
// 3. dynamic link to Hass camera // 3. dynamic link to Hass camera
if streams.Patch(v.Name, v.Channels.First.Url) != nil { if _, err := streams.Patch(v.Name, v.Channels.First.Url); err == nil {
apiOK(w, r) apiOK(w, r)
} else { } else {
http.Error(w, "", http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
// /stream/{id}/channel/0/webrtc // /stream/{id}/channel/0/webrtc
+8 -2
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"strings"
"sync" "sync"
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
@@ -37,8 +38,13 @@ func Init() {
api.HandleFunc("/streams", apiOK) api.HandleFunc("/streams", apiOK)
api.HandleFunc("/stream/", apiStream) api.HandleFunc("/stream/", apiStream)
streams.RedirectFunc("hass", func(url string) (string, error) { streams.RedirectFunc("hass", func(rawURL string) (string, error) {
if location := entities[url[5:]]; location != "" { rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
if location := entities[rawURL[5:]]; location != "" {
if rawQuery != "" {
return location + "#" + rawQuery, nil
}
return location, nil return location, nil
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import (
) )
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
stream := streams.GetOrPatch(tr.Request.URL.Query()) stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
if stream == nil { if stream == nil {
return errors.New(api.StreamNotFound) return errors.New(api.StreamNotFound)
} }
+79 -37
View File
@@ -3,6 +3,7 @@ package homekit
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -14,56 +15,97 @@ import (
"github.com/AlexxIT/go2rtc/pkg/mdns" "github.com/AlexxIT/go2rtc/pkg/mdns"
) )
func apiHandler(w http.ResponseWriter, r *http.Request) { func apiDiscovery(w http.ResponseWriter, r *http.Request) {
sources, err := discovery()
if err != nil {
api.Error(w, err)
return
}
urls := findHomeKitURLs()
for id, u := range urls {
deviceID := u.Query().Get("device_id")
for _, source := range sources {
if strings.Contains(source.URL, deviceID) {
source.Location = id
break
}
}
}
for _, source := range sources {
if source.Location == "" {
source.Location = " "
}
}
api.ResponseSources(w, sources)
}
func apiHomekit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Method { switch r.Method {
case "GET": case "GET":
sources, err := discovery() if id := r.Form.Get("id"); id != "" {
if err != nil { if srv := servers[id]; srv != nil {
api.Error(w, err) api.ResponsePrettyJSON(w, srv)
return } else {
} http.Error(w, "server not found", http.StatusNotFound)
urls := findHomeKitURLs()
for id, u := range urls {
deviceID := u.Query().Get("device_id")
for _, source := range sources {
if strings.Contains(source.URL, deviceID) {
source.Location = id
break
}
} }
} else {
api.ResponsePrettyJSON(w, servers)
} }
for _, source := range sources {
if source.Location == "" {
source.Location = " "
}
}
api.ResponseSources(w, sources)
case "POST": case "POST":
if err := r.ParseMultipartForm(1024); err != nil { id := r.Form.Get("id")
api.Error(w, err) rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin")
return if err := apiPair(id, rawURL); err != nil {
} http.Error(w, err.Error(), http.StatusInternalServerError)
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
api.Error(w, err)
} }
case "DELETE": case "DELETE":
if err := r.ParseMultipartForm(1024); err != nil { id := r.Form.Get("id")
api.Error(w, err) if err := apiUnpair(id); err != nil {
return http.Error(w, err.Error(), http.StatusInternalServerError)
}
if err := apiUnpair(r.Form.Get("id")); err != nil {
api.Error(w, err)
} }
} }
} }
func apiHomekitAccessories(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
stream := streams.Get(id)
if stream == nil {
http.Error(w, "", http.StatusNotFound)
return
}
rawURL := findHomeKitURL(stream.Sources())
if rawURL == "" {
http.Error(w, "", http.StatusBadRequest)
return
}
client, err := hap.Dial(rawURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer client.Close()
res, err := client.Get(hap.PathAccessories)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", api.MimeJSON)
_, _ = io.Copy(w, res.Body)
}
func discovery() ([]*api.Source, error) { func discovery() ([]*api.Source, error) {
var sources []*api.Source var sources []*api.Source
+33 -54
View File
@@ -2,8 +2,6 @@ package homekit
import ( import (
"errors" "errors"
"io"
"net"
"net/http" "net/http"
"strings" "strings"
@@ -26,6 +24,7 @@ func Init() {
Name string `yaml:"name"` Name string `yaml:"name"`
DeviceID string `yaml:"device_id"` DeviceID string `yaml:"device_id"`
DevicePrivate string `yaml:"device_private"` DevicePrivate string `yaml:"device_private"`
CategoryID string `yaml:"category_id"`
Pairings []string `yaml:"pairings"` Pairings []string `yaml:"pairings"`
} `yaml:"homekit"` } `yaml:"homekit"`
} }
@@ -35,12 +34,15 @@ func Init() {
streams.HandleFunc("homekit", streamHandler) streams.HandleFunc("homekit", streamHandler)
api.HandleFunc("api/homekit", apiHandler) api.HandleFunc("api/homekit", apiHomekit)
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
api.HandleFunc("api/discovery/homekit", apiDiscovery)
if cfg.Mod == nil { if cfg.Mod == nil {
return return
} }
hosts = map[string]*server{}
servers = map[string]*server{} servers = map[string]*server{}
var entries []*mdns.ServiceEntry var entries []*mdns.ServiceEntry
@@ -63,36 +65,19 @@ func Init() {
deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address
name := calcName(conf.Name, deviceID) name := calcName(conf.Name, deviceID)
setupID := calcSetupID(id)
srv := &server{ srv := &server{
stream: id, stream: id,
srtp: srtp.Server,
pairings: conf.Pairings, pairings: conf.Pairings,
setupID: setupID,
} }
srv.hap = &hap.Server{ srv.hap = &hap.Server{
Pin: pin, Pin: pin,
DeviceID: deviceID, DeviceID: deviceID,
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id), DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
GetPair: srv.GetPair, GetClientPublic: srv.GetPair,
AddPair: srv.AddPair,
Handler: homekit.ServerHandler(srv),
}
if url := findHomeKitURL(stream.Sources()); url != "" {
// 1. Act as transparent proxy for HomeKit camera
dial := func() (net.Conn, error) {
client, err := homekit.Dial(url, srtp.Server)
if err != nil {
return nil, err
}
return client.Conn(), nil
}
srv.hap.Handler = homekit.ProxyHandler(srv, dial)
} else {
// 2. Act as basic HomeKit camera
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
srv.hap.Handler = homekit.ServerHandler(srv)
} }
srv.mdns = &mdns.ServiceEntry{ srv.mdns = &mdns.ServiceEntry{
@@ -106,23 +91,32 @@ func Init() {
hap.TXTProtoVersion: "1.1", hap.TXTProtoVersion: "1.1",
hap.TXTStateNumber: "1", hap.TXTStateNumber: "1",
hap.TXTStatusFlags: hap.StatusNotPaired, hap.TXTStatusFlags: hap.StatusNotPaired,
hap.TXTCategory: hap.CategoryCamera, hap.TXTCategory: calcCategoryID(conf.CategoryID),
hap.TXTSetupHash: srv.hap.SetupHash(), hap.TXTSetupHash: hap.SetupHash(setupID, deviceID),
}, },
} }
entries = append(entries, srv.mdns) entries = append(entries, srv.mdns)
srv.UpdateStatus() srv.UpdateStatus()
if url := findHomeKitURL(stream.Sources()); url != "" {
// 1. Act as transparent proxy for HomeKit camera
srv.proxyURL = url
} else {
// 2. Act as basic HomeKit camera
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
}
host := srv.mdns.Host(mdns.ServiceHAP) host := srv.mdns.Host(mdns.ServiceHAP)
servers[host] = srv hosts[host] = srv
servers[id] = srv
log.Trace().Msgf("[homekit] new server: %s", srv.mdns)
} }
api.HandleFunc(hap.PathPairSetup, hapHandler) api.HandleFunc(hap.PathPairSetup, hapHandler)
api.HandleFunc(hap.PathPairVerify, hapHandler) api.HandleFunc(hap.PathPairVerify, hapHandler)
log.Trace().Msgf("[homekit] mdns: %s", entries)
go func() { go func() {
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil { if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
@@ -131,6 +125,7 @@ func Init() {
} }
var log zerolog.Logger var log zerolog.Logger
var hosts map[string]*server
var servers map[string]*server var servers map[string]*server
func streamHandler(rawURL string) (core.Producer, error) { func streamHandler(rawURL string) (core.Producer, error) {
@@ -142,6 +137,8 @@ func streamHandler(rawURL string) (core.Producer, error) {
client, err := homekit.Dial(rawURL, srtp.Server) client, err := homekit.Dial(rawURL, srtp.Server)
if client != nil && rawQuery != "" { if client != nil && rawQuery != "" {
query := streams.ParseQuery(rawQuery) query := streams.ParseQuery(rawQuery)
client.MaxWidth = core.Atoi(query.Get("maxwidth"))
client.MaxHeight = core.Atoi(query.Get("maxheight"))
client.Bitrate = parseBitrate(query.Get("bitrate")) client.Bitrate = parseBitrate(query.Get("bitrate"))
} }
@@ -149,45 +146,27 @@ func streamHandler(rawURL string) (core.Producer, error) {
} }
func resolve(host string) *server { func resolve(host string) *server {
if len(servers) == 1 { if len(hosts) == 1 {
for _, srv := range servers { for _, srv := range hosts {
return srv return srv
} }
} }
if srv, ok := servers[host]; ok { if srv, ok := hosts[host]; ok {
return srv return srv
} }
return nil return nil
} }
func hapHandler(w http.ResponseWriter, r *http.Request) { func hapHandler(w http.ResponseWriter, r *http.Request) {
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
// Can support multiple HomeKit cameras on single port ONLY for Apple devices. // Can support multiple HomeKit cameras on single port ONLY for Apple devices.
// Doesn't support Home Assistant and any other open source projects // Doesn't support Home Assistant and any other open source projects
// because they don't send the host header in requests. // because they don't send the host header in requests.
srv := resolve(r.Host) srv := resolve(r.Host)
if srv == nil { if srv == nil {
log.Error().Msg("[homekit] unknown host: " + r.Host) log.Error().Msg("[homekit] unknown host: " + r.Host)
_ = hap.WriteBackoff(rw)
return return
} }
srv.Handle(w, r)
switch r.RequestURI {
case hap.PathPairSetup:
err = srv.hap.PairSetup(r, rw, conn)
case hap.PathPairVerify:
err = srv.hap.PairVerify(r, rw, conn)
}
if err != nil && err != io.EOF {
log.Error().Err(err).Caller().Send()
}
} }
func findHomeKitURL(sources []string) string { func findHomeKitURL(sources []string) string {
@@ -203,7 +182,7 @@ func findHomeKitURL(sources []string) string {
if strings.HasPrefix(url, "hass") { if strings.HasPrefix(url, "hass") {
location, _ := streams.Location(url) location, _ := streams.Location(url)
if strings.HasPrefix(location, "homekit") { if strings.HasPrefix(location, "homekit") {
return url return location
} }
} }
+235 -95
View File
@@ -4,10 +4,16 @@ import (
"crypto/ed25519" "crypto/ed25519"
"crypto/sha512" "crypto/sha512"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors"
"fmt" "fmt"
"io"
"net" "net"
"net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"sync"
"github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg" "github.com/AlexxIT/go2rtc/internal/ffmpeg"
@@ -16,23 +22,142 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/camera" "github.com/AlexxIT/go2rtc/pkg/hap/camera"
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/AlexxIT/go2rtc/pkg/homekit" "github.com/AlexxIT/go2rtc/pkg/homekit"
"github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mdns" "github.com/AlexxIT/go2rtc/pkg/mdns"
"github.com/AlexxIT/go2rtc/pkg/srtp"
) )
type server struct { type server struct {
stream string // stream name from YAML hap *hap.Server // server for HAP connection and encryption
hap *hap.Server // server for HAP connection and encryption mdns *mdns.ServiceEntry
mdns *mdns.ServiceEntry
srtp *srtp.Server
accessory *hap.Accessory // HAP accessory
pairings []string // pairings list
streams map[string]*homekit.Consumer pairings []string // pairings list
consumer *homekit.Consumer conns []any
mu sync.Mutex
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,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],
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)
}
func (s *server) Handle(w http.ResponseWriter, r *http.Request) {
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
// Fix reading from Body after Hijack.
r.Body = io.NopCloser(rw)
switch r.RequestURI {
case hap.PathPairSetup:
id, key, err := s.hap.PairSetup(r, rw)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
s.AddPair(id, key, hap.PermissionAdmin)
case hap.PathPairVerify:
id, key, err := s.hap.PairVerify(r, rw)
if err != nil {
log.Debug().Err(err).Caller().Send()
return
}
log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr())
controller, err := hap.NewConn(conn, rw, key, false)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
s.AddConn(controller)
defer s.DelConn(controller)
var handler homekit.HandlerFunc
switch {
case s.accessory != nil:
handler = homekit.ServerHandler(s)
case s.proxyURL != "":
client, err := hap.Dial(s.proxyURL)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
handler = homekit.ProxyHandler(s, client.Conn)
}
// If your iPhone goes to sleep, it will be an EOF error.
if err = handler(controller); err != nil && !errors.Is(err, io.EOF) {
log.Error().Err(err).Caller().Send()
return
}
}
}
type logger struct {
v any
}
func (l logger) String() string {
switch v := l.v.(type) {
case *hap.Conn:
return "hap " + v.RemoteAddr().String()
case *hds.Conn:
return "hds " + v.RemoteAddr().String()
case *homekit.Consumer:
return "rtp " + v.RemoteAddr
}
return "unknown"
}
func (s *server) AddConn(v any) {
log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v})
s.mu.Lock()
s.conns = append(s.conns, v)
s.mu.Unlock()
}
func (s *server) DelConn(v any) {
log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v})
s.mu.Lock()
if i := slices.Index(s.conns, v); i >= 0 {
s.conns = slices.Delete(s.conns, i, i+1)
}
s.mu.Unlock()
} }
func (s *server) UpdateStatus() { func (s *server) UpdateStatus() {
@@ -44,12 +169,68 @@ func (s *server) UpdateStatus() {
} }
} }
func (s *server) pairIndex(id string) int {
id = "client_id=" + id
for i, pairing := range s.pairings {
if strings.HasPrefix(pairing, id) {
return i
}
}
return -1
}
func (s *server) GetPair(id string) []byte {
s.mu.Lock()
defer s.mu.Unlock()
if i := s.pairIndex(id); i >= 0 {
query, _ := url.ParseQuery(s.pairings[i])
b, _ := hex.DecodeString(query.Get("client_public"))
return b
}
return nil
}
func (s *server) AddPair(id string, public []byte, permissions byte) {
log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions)
s.mu.Lock()
if s.pairIndex(id) < 0 {
s.pairings = append(s.pairings, fmt.Sprintf(
"client_id=%s&client_public=%x&permissions=%d", id, public, permissions,
))
s.UpdateStatus()
s.PatchConfig()
}
s.mu.Unlock()
}
func (s *server) DelPair(id string) {
log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id)
s.mu.Lock()
if i := s.pairIndex(id); i >= 0 {
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
s.UpdateStatus()
s.PatchConfig()
}
s.mu.Unlock()
}
func (s *server) PatchConfig() {
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
log.Error().Err(err).Msgf(
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
)
}
}
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory { func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
return []*hap.Accessory{s.accessory} return []*hap.Accessory{s.accessory}
} }
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
log.Trace().Msgf("[homekit] %s: get char aid=%d iid=0x%x", conn.RemoteAddr(), aid, iid) log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid)
char := s.accessory.GetCharacterByID(iid) char := s.accessory.GetCharacterByID(iid)
if char == nil { if char == nil {
@@ -59,11 +240,12 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
switch char.Type { switch char.Type {
case camera.TypeSetupEndpoints: case camera.TypeSetupEndpoints:
if s.consumer == nil { consumer := s.consumer
if consumer == nil {
return nil return nil
} }
answer := s.consumer.GetAnswer() answer := consumer.GetAnswer()
v, err := tlv8.MarshalBase64(answer) v, err := tlv8.MarshalBase64(answer)
if err != nil { if err != nil {
return nil return nil
@@ -76,7 +258,7 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
} }
func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) { func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
log.Trace().Msgf("[homekit] %s: set char aid=%d iid=0x%x value=%v", conn.RemoteAddr(), aid, iid, value) log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value)
char := s.accessory.GetCharacterByID(iid) char := s.accessory.GetCharacterByID(iid)
if char == nil { if char == nil {
@@ -86,61 +268,64 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a
switch char.Type { switch char.Type {
case camera.TypeSetupEndpoints: case camera.TypeSetupEndpoints:
var offer camera.SetupEndpoints var offer camera.SetupEndpointsRequest
if err := tlv8.UnmarshalBase64(value, &offer); err != nil { if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
return return
} }
s.consumer = homekit.NewConsumer(conn, srtp2.Server) consumer := homekit.NewConsumer(conn, srtp2.Server)
s.consumer.SetOffer(&offer) consumer.SetOffer(&offer)
s.consumer = consumer
case camera.TypeSelectedStreamConfiguration: case camera.TypeSelectedStreamConfiguration:
var conf camera.SelectedStreamConfig var conf camera.SelectedStreamConfiguration
if err := tlv8.UnmarshalBase64(value, &conf); err != nil { if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
return return
} }
log.Trace().Msgf("[homekit] %s stream id=%x cmd=%d", conn.RemoteAddr(), conf.Control.SessionID, conf.Control.Command) log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
switch conf.Control.Command { switch conf.Control.Command {
case camera.SessionCommandEnd: case camera.SessionCommandEnd:
if consumer := s.streams[conf.Control.SessionID]; consumer != nil { for _, consumer := range s.conns {
_ = consumer.Stop() if consumer, ok := consumer.(*homekit.Consumer); ok {
if consumer.SessionID() == conf.Control.SessionID {
_ = consumer.Stop()
return
}
}
} }
case camera.SessionCommandStart: case camera.SessionCommandStart:
if s.consumer == nil { consumer := s.consumer
if consumer == nil {
return return
} }
if !s.consumer.SetConfig(&conf) { if !consumer.SetConfig(&conf) {
log.Warn().Msgf("[homekit] wrong config") log.Warn().Msgf("[homekit] wrong config")
return return
} }
if s.streams == nil { s.AddConn(consumer)
s.streams = map[string]*homekit.Consumer{}
}
s.streams[conf.Control.SessionID] = s.consumer
stream := streams.Get(s.stream) stream := streams.Get(s.stream)
if err := stream.AddConsumer(s.consumer); err != nil { if err := stream.AddConsumer(consumer); err != nil {
return return
} }
go func() { go func() {
_, _ = s.consumer.WriteTo(nil) _, _ = consumer.WriteTo(nil)
stream.RemoveConsumer(s.consumer) stream.RemoveConsumer(consumer)
delete(s.streams, conf.Control.SessionID) s.DelConn(consumer)
}() }()
} }
} }
} }
func (s *server) GetImage(conn net.Conn, width, height int) []byte { func (s *server) GetImage(conn net.Conn, width, height int) []byte {
log.Trace().Msgf("[homekit] %s: get image width=%d height=%d", conn.RemoteAddr(), width, height) log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height)
stream := streams.Get(s.stream) stream := streams.Get(s.stream)
cons := magic.NewKeyframe() cons := magic.NewKeyframe()
@@ -166,69 +351,6 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte {
return b return b
} }
func (s *server) GetPair(conn net.Conn, id string) []byte {
log.Trace().Msgf("[homekit] %s: get pair id=%s", conn.RemoteAddr(), id)
for _, pairing := range s.pairings {
if !strings.Contains(pairing, id) {
continue
}
query, err := url.ParseQuery(pairing)
if err != nil {
continue
}
if query.Get("client_id") != id {
continue
}
s := query.Get("client_public")
b, _ := hex.DecodeString(s)
return b
}
return nil
}
func (s *server) AddPair(conn net.Conn, id string, public []byte, permissions byte) {
log.Trace().Msgf("[homekit] %s: add pair id=%s public=%x perm=%d", conn.RemoteAddr(), id, public, permissions)
query := url.Values{
"client_id": []string{id},
"client_public": []string{hex.EncodeToString(public)},
"permissions": []string{string('0' + permissions)},
}
if s.GetPair(conn, id) == nil {
s.pairings = append(s.pairings, query.Encode())
s.UpdateStatus()
s.PatchConfig()
}
}
func (s *server) DelPair(conn net.Conn, id string) {
log.Trace().Msgf("[homekit] %s: del pair id=%s", conn.RemoteAddr(), id)
id = "client_id=" + id
for i, pairing := range s.pairings {
if !strings.Contains(pairing, id) {
continue
}
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
s.UpdateStatus()
s.PatchConfig()
break
}
}
func (s *server) PatchConfig() {
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
log.Error().Err(err).Msgf(
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
)
}
}
func calcName(name, seed string) string { func calcName(name, seed string) string {
if name != "" { if name != "" {
return name return name
@@ -263,3 +385,21 @@ func calcDevicePrivate(private, seed string) []byte {
b := sha512.Sum512([]byte(seed)) b := sha512.Sum512([]byte(seed))
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize]) 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
}
+2 -2
View File
@@ -36,7 +36,7 @@ func Init() {
var log zerolog.Logger var log zerolog.Logger
func handlerKeyframe(w http.ResponseWriter, r *http.Request) { func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
stream := streams.GetOrPatch(r.URL.Query()) stream, _ := streams.GetOrPatch(r.URL.Query())
if stream == nil { if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound) http.Error(w, api.StreamNotFound, http.StatusNotFound)
return return
@@ -145,7 +145,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
} }
func handlerWS(tr *ws.Transport, _ *ws.Message) error { func handlerWS(tr *ws.Transport, _ *ws.Message) error {
stream := streams.GetOrPatch(tr.Request.URL.Query()) stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
if stream == nil { if stream == nil {
return errors.New(api.StreamNotFound) return errors.New(api.StreamNotFound)
} }
+1 -1
View File
@@ -91,7 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
return return
} }
stream := streams.GetOrPatch(query) stream, _ := streams.GetOrPatch(query)
if stream == nil { if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound) http.Error(w, api.StreamNotFound, http.StatusNotFound)
return return
+2 -2
View File
@@ -11,7 +11,7 @@ import (
) )
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
stream := streams.GetOrPatch(tr.Request.URL.Query()) stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
if stream == nil { if stream == nil {
return errors.New(api.StreamNotFound) return errors.New(api.StreamNotFound)
} }
@@ -43,7 +43,7 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
} }
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
stream := streams.GetOrPatch(tr.Request.URL.Query()) stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
if stream == nil { if stream == nil {
return errors.New(api.StreamNotFound) return errors.New(api.StreamNotFound)
} }
+4
View File
@@ -45,6 +45,10 @@ func streamOnvif(rawURL string) (core.Producer, error) {
log.Debug().Msgf("[onvif] new uri=%s", uri) log.Debug().Msgf("[onvif] new uri=%s", uri)
if err = streams.Validate(uri); err != nil {
return nil, err
}
return streams.GetProducer(uri) return streams.GetProducer(uri)
} }
+54
View File
@@ -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
```
+60
View File
@@ -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
}
}
+16 -13
View File
@@ -52,8 +52,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
name = src name = src
} }
if New(name, query["src"]...) == nil { if _, err := New(name, query["src"]...); err != nil {
http.Error(w, "", http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -69,8 +69,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
} }
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
if Patch(name, src) == nil { if _, err := Patch(name, src); err != nil {
http.Error(w, "", http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
case "POST": case "POST":
@@ -130,16 +130,15 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
} }
func apiPreload(w http.ResponseWriter, r *http.Request) { func apiPreload(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() // GET - return all preloads
src := query.Get("src") if r.Method == "GET" {
api.ResponseJSON(w, GetPreloads())
// check if stream exists
stream := Get(src)
if stream == nil {
http.Error(w, "", http.StatusNotFound)
return return
} }
query := r.URL.Query()
src := query.Get("src")
switch r.Method { switch r.Method {
case "PUT": case "PUT":
// it's safe to delete from map while iterating // it's safe to delete from map while iterating
@@ -153,7 +152,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) {
rawQuery := query.Encode() rawQuery := query.Encode()
if err := AddPreload(stream, rawQuery); err != nil { if err := AddPreload(src, rawQuery); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -163,7 +162,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) {
} }
case "DELETE": case "DELETE":
if err := DelPreload(stream); err != nil { if err := DelPreload(src); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -176,3 +175,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "", http.StatusMethodNotAllowed) http.Error(w, "", http.StatusMethodNotAllowed)
} }
} }
func apiSchemes(w http.ResponseWriter, r *http.Request) {
api.ResponseJSON(w, SupportedSchemes())
}
+66
View File
@@ -0,0 +1,66 @@
package streams
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/stretchr/testify/require"
)
func TestApiSchemes(t *testing.T) {
// Setup: Register some test handlers and redirects
HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil })
HandleFunc("rtmp", func(url string) (core.Producer, error) { return nil, nil })
RedirectFunc("http", func(url string) (string, error) { return "", nil })
t.Run("GET request returns schemes", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/schemes", nil)
w := httptest.NewRecorder()
apiSchemes(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "application/json", w.Header().Get("Content-Type"))
var schemes []string
err := json.Unmarshal(w.Body.Bytes(), &schemes)
require.NoError(t, err)
require.NotEmpty(t, schemes)
// Check that our test schemes are in the response
require.Contains(t, schemes, "rtsp")
require.Contains(t, schemes, "rtmp")
require.Contains(t, schemes, "http")
})
}
func TestApiSchemesNoDuplicates(t *testing.T) {
// Setup: Register a scheme in both handlers and redirects
HandleFunc("duplicate", func(url string) (core.Producer, error) { return nil, nil })
RedirectFunc("duplicate", func(url string) (string, error) { return "", nil })
req := httptest.NewRequest("GET", "/api/schemes", nil)
w := httptest.NewRecorder()
apiSchemes(w, req)
require.Equal(t, http.StatusOK, w.Code)
var schemes []string
err := json.Unmarshal(w.Body.Bytes(), &schemes)
require.NoError(t, err)
// Count occurrences of "duplicate"
count := 0
for _, scheme := range schemes {
if scheme == "duplicate" {
count++
}
}
// Should only appear once
require.Equal(t, 1, count, "scheme 'duplicate' should appear exactly once")
}
+37
View File
@@ -2,6 +2,7 @@ package streams
import ( import (
"errors" "errors"
"regexp"
"strings" "strings"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
@@ -15,6 +16,21 @@ func HandleFunc(scheme string, handler Handler) {
handlers[scheme] = handler handlers[scheme] = handler
} }
func SupportedSchemes() []string {
uniqueKeys := make(map[string]struct{}, len(handlers)+len(redirects))
for scheme := range handlers {
uniqueKeys[scheme] = struct{}{}
}
for scheme := range redirects {
uniqueKeys[scheme] = struct{}{}
}
resultKeys := make([]string, 0, len(uniqueKeys))
for key := range uniqueKeys {
resultKeys = append(resultKeys, key)
}
return resultKeys
}
func HasProducer(url string) bool { func HasProducer(url string) bool {
if i := strings.IndexByte(url, ':'); i > 0 { if i := strings.IndexByte(url, ':'); i > 0 {
scheme := url[:i] scheme := url[:i]
@@ -95,3 +111,24 @@ func GetConsumer(url string) (core.Consumer, func(), error) {
return nil, nil, errors.New("streams: unsupported scheme: " + url) return nil, nil, errors.New("streams: unsupported scheme: " + url)
} }
var insecure = map[string]bool{}
func MarkInsecure(scheme string) {
insecure[scheme] = true
}
var sanitize = regexp.MustCompile(`\s`)
func Validate(source string) error {
// TODO: Review the entire logic of insecure sources
if i := strings.IndexByte(source, ':'); i > 0 {
if insecure[source[:i]] {
return errors.New("streams: source from insecure producer")
}
}
if sanitize.MatchString(source) {
return errors.New("streams: source with spaces may be insecure")
}
return nil
}
+28 -17
View File
@@ -1,23 +1,24 @@
package streams package streams
import ( import (
"errors" "fmt"
"maps"
"net/url" "net/url"
"sync" "sync"
"github.com/AlexxIT/go2rtc/pkg/probe" "github.com/AlexxIT/go2rtc/pkg/probe"
) )
var preloads = map[*Stream]*probe.Probe{} type Preload struct {
var preloadsMu sync.Mutex stream *Stream // Don't output the stream to JSON to not worry about its secrets.
Cons *probe.Probe `json:"consumer"`
func Preload(stream *Stream, rawQuery string) { Query string `json:"query"`
if err := AddPreload(stream, rawQuery); err != nil {
log.Error().Err(err).Caller().Send()
}
} }
func AddPreload(stream *Stream, rawQuery string) error { var preloads = map[string]*Preload{}
var preloadsMu sync.Mutex
func AddPreload(name, rawQuery string) error {
if rawQuery == "" { if rawQuery == "" {
rawQuery = "video&audio" rawQuery = "video&audio"
} }
@@ -30,29 +31,39 @@ func AddPreload(stream *Stream, rawQuery string) error {
preloadsMu.Lock() preloadsMu.Lock()
defer preloadsMu.Unlock() defer preloadsMu.Unlock()
if cons := preloads[stream]; cons != nil { if p := preloads[name]; p != nil {
stream.RemoveConsumer(cons) 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) cons := probe.Create("preload", query)
if err = stream.AddConsumer(cons); err != nil { if err = stream.AddConsumer(cons); err != nil {
return err return err
} }
preloads[stream] = cons preloads[name] = &Preload{stream: stream, Cons: cons, Query: rawQuery}
return nil return nil
} }
func DelPreload(stream *Stream) error { func DelPreload(name string) error {
preloadsMu.Lock() preloadsMu.Lock()
defer preloadsMu.Unlock() defer preloadsMu.Unlock()
if cons := preloads[stream]; cons != nil { if p := preloads[name]; p != nil {
stream.RemoveConsumer(cons) p.stream.RemoveConsumer(p.Cons)
delete(preloads, stream) delete(preloads, name)
return nil 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)
} }
+22 -30
View File
@@ -3,7 +3,6 @@ package streams
import ( import (
"errors" "errors"
"net/url" "net/url"
"regexp"
"sync" "sync"
"time" "time"
@@ -30,6 +29,7 @@ func Init() {
api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams", apiStreams)
api.HandleFunc("api/streams.dot", apiStreamsDOT) api.HandleFunc("api/streams.dot", apiStreamsDOT)
api.HandleFunc("api/preload", apiPreload) api.HandleFunc("api/preload", apiPreload)
api.HandleFunc("api/schemes", apiSchemes)
if cfg.Publish == nil && cfg.Preload == nil { if cfg.Publish == nil && cfg.Preload == nil {
return return
@@ -43,27 +43,21 @@ func Init() {
} }
} }
for name, rawQuery := range cfg.Preload { for name, rawQuery := range cfg.Preload {
if stream := Get(name); stream != nil { if err := AddPreload(name, rawQuery); err != nil {
Preload(stream, rawQuery) log.Error().Err(err).Caller().Send()
} }
} }
}) })
} }
var sanitize = regexp.MustCompile(`\s`) func New(name string, sources ...string) (*Stream, error) {
// Validate - not allow creating dynamic streams with spaces in the source
func Validate(source string) error {
if sanitize.MatchString(source) {
return errors.New("streams: invalid dynamic source")
}
return nil
}
func New(name string, sources ...string) *Stream {
for _, source := range sources { for _, source := range sources {
if Validate(source) != nil { if !HasProducer(source) {
return nil return nil, errors.New("streams: source not supported")
}
if err := Validate(source); err != nil {
return nil, err
} }
} }
@@ -73,10 +67,10 @@ func New(name string, sources ...string) *Stream {
streams[name] = stream streams[name] = stream
streamsMu.Unlock() streamsMu.Unlock()
return stream return stream, nil
} }
func Patch(name string, source string) *Stream { func Patch(name string, source string) (*Stream, error) {
streamsMu.Lock() streamsMu.Lock()
defer streamsMu.Unlock() defer streamsMu.Unlock()
@@ -88,7 +82,7 @@ func Patch(name string, source string) *Stream {
// link (alias) streams[name] to streams[rtspName] // link (alias) streams[name] to streams[rtspName]
streams[name] = stream streams[name] = stream
} }
return stream return stream, nil
} }
} }
@@ -97,46 +91,44 @@ func Patch(name string, source string) *Stream {
// link (alias) streams[name] to streams[source] // link (alias) streams[name] to streams[source]
streams[name] = stream streams[name] = stream
} }
return stream return stream, nil
} }
// check if src has supported scheme // check if src has supported scheme
if !HasProducer(source) { if !HasProducer(source) {
return nil return nil, errors.New("streams: source not supported")
} }
if Validate(source) != nil { if err := Validate(source); err != nil {
return nil return nil, err
} }
// check an existing stream with this name // check an existing stream with this name
if stream, ok := streams[name]; ok { if stream, ok := streams[name]; ok {
stream.SetSource(source) stream.SetSource(source)
return stream return stream, nil
} }
// create new stream with this name // create new stream with this name
stream := NewStream(source) stream := NewStream(source)
streams[name] = stream streams[name] = stream
return stream return stream, nil
} }
func GetOrPatch(query url.Values) *Stream { func GetOrPatch(query url.Values) (*Stream, error) {
// check if src param exists // check if src param exists
source := query.Get("src") source := query.Get("src")
if source == "" { if source == "" {
return nil return nil, errors.New("streams: source empty")
} }
// check if src is stream name // check if src is stream name
if stream := Get(source); stream != nil { if stream := Get(source); stream != nil {
return stream return stream, nil
} }
// check if name param provided // check if name param provided
if name := query.Get("name"); name != "" { if name := query.Get("name"); name != "" {
log.Info().Msgf("[streams] create new stream url=%s", source)
return Patch(name, source) return Patch(name, source)
} }
+39
View File
@@ -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
```
+248
View File
@@ -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
}
+3 -1
View File
@@ -18,9 +18,11 @@ type Address struct {
Priority uint32 Priority uint32
} }
var stuns []string
func (a *Address) Host() string { func (a *Address) Host() string {
if a.host == "stun" { if a.host == "stun" {
ip, err := webrtc.GetCachedPublicIP() ip, err := webrtc.GetCachedPublicIP(stuns...)
if err != nil { if err != nil {
return "" return ""
} }
+12 -2
View File
@@ -26,7 +26,7 @@ func Init() {
cfg.Mod.Listen = ":8555" cfg.Mod.Listen = ":8555"
cfg.Mod.IceServers = []pion.ICEServer{ 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) app.LoadConfig(&cfg)
@@ -38,6 +38,16 @@ func Init() {
address, network, _ := strings.Cut(cfg.Mod.Listen, "/") address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
for _, candidate := range cfg.Mod.Candidates { for _, candidate := range cfg.Mod.Candidates {
AddCandidate(network, candidate) 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 var err error
@@ -95,7 +105,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) {
query := tr.Request.URL.Query() query := tr.Request.URL.Query()
if name := query.Get("src"); name != "" { if name := query.Get("src"); name != "" {
stream = streams.GetOrPatch(query) stream, _ = streams.GetOrPatch(query)
mode = core.ModePassiveConsumer mode = core.ModePassiveConsumer
log.Debug().Str("src", name).Msg("[webrtc] new consumer") log.Debug().Str("src", name).Msg("[webrtc] new consumer")
} else if name = query.Get("dst"); name != "" { } else if name = query.Get("dst"); name != "" {
+50
View File
@@ -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
```
+267
View File
@@ -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"
}
+66 -59
View File
@@ -1,6 +1,8 @@
package main package main
import ( import (
"slices"
"github.com/AlexxIT/go2rtc/internal/alsa" "github.com/AlexxIT/go2rtc/internal/alsa"
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/api/ws"
@@ -28,6 +30,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/nest"
"github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/ngrok"
"github.com/AlexxIT/go2rtc/internal/onvif" "github.com/AlexxIT/go2rtc/internal/onvif"
"github.com/AlexxIT/go2rtc/internal/pinggy"
"github.com/AlexxIT/go2rtc/internal/ring" "github.com/AlexxIT/go2rtc/internal/ring"
"github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/roborock"
"github.com/AlexxIT/go2rtc/internal/rtmp" "github.com/AlexxIT/go2rtc/internal/rtmp"
@@ -35,77 +38,81 @@ import (
"github.com/AlexxIT/go2rtc/internal/srtp" "github.com/AlexxIT/go2rtc/internal/srtp"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/internal/tapo" "github.com/AlexxIT/go2rtc/internal/tapo"
"github.com/AlexxIT/go2rtc/internal/tuya"
"github.com/AlexxIT/go2rtc/internal/v4l2" "github.com/AlexxIT/go2rtc/internal/v4l2"
"github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/webtorrent"
"github.com/AlexxIT/go2rtc/internal/wyoming" "github.com/AlexxIT/go2rtc/internal/wyoming"
"github.com/AlexxIT/go2rtc/internal/xiaomi"
"github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/internal/yandex"
"github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/shell"
) )
func main() { func main() {
app.Version = "1.9.11" app.Version = "1.9.13"
// 1. Core modules: app, api/ws, streams type module struct {
name string
init func()
}
app.Init() // init config and logs modules := []module{
{"", app.Init}, // init config and logs
{"api", api.Init}, // init API before all others
{"ws", ws.Init}, // init WS API endpoint
{"", streams.Init},
// Main sources and servers
{"http", http.Init}, // rtsp source, HTTP server
{"rtsp", rtsp.Init}, // rtsp source, RTSP server
{"webrtc", webrtc.Init}, // webrtc source, WebRTC server
// Main API
{"mp4", mp4.Init}, // MP4 API
{"hls", hls.Init}, // HLS API
{"mjpeg", mjpeg.Init}, // MJPEG API
// Other sources and servers
{"hass", hass.Init}, // hass source, Hass API server
{"homekit", homekit.Init}, // homekit source, HomeKit server
{"onvif", onvif.Init}, // onvif source, ONVIF API server
{"rtmp", rtmp.Init}, // rtmp source, RTMP server
{"webtorrent", webtorrent.Init}, // webtorrent source, WebTorrent module
{"wyoming", wyoming.Init},
// Exec and script sources
{"echo", echo.Init},
{"exec", exec.Init},
{"expr", expr.Init},
{"ffmpeg", ffmpeg.Init},
// Hardware sources
{"alsa", alsa.Init},
{"v4l2", v4l2.Init},
// Other sources
{"bubble", bubble.Init},
{"doorbird", doorbird.Init},
{"dvrip", dvrip.Init},
{"eseecloud", eseecloud.Init},
{"flussonic", flussonic.Init},
{"gopro", gopro.Init},
{"isapi", isapi.Init},
{"ivideon", ivideon.Init},
{"mpegts", mpegts.Init},
{"nest", nest.Init},
{"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},
}
api.Init() // init API before all others for _, m := range modules {
ws.Init() // init WS API endpoint if app.Modules == nil || m.name == "" || slices.Contains(app.Modules, m.name) {
m.init()
streams.Init() // streams module }
}
// 2. Main sources and servers
rtsp.Init() // rtsp source, RTSP server
webrtc.Init() // webrtc source, WebRTC server
// 3. Main API
mp4.Init() // MP4 API
hls.Init() // HLS API
mjpeg.Init() // MJPEG API
// 4. Other sources and servers
hass.Init() // hass source, Hass API server
onvif.Init() // onvif source, ONVIF API server
webtorrent.Init() // webtorrent source, WebTorrent module
wyoming.Init()
// 5. Other sources
rtmp.Init() // rtmp source
exec.Init() // exec source
ffmpeg.Init() // ffmpeg source
echo.Init() // echo source
ivideon.Init() // ivideon source
http.Init() // http/tcp source
dvrip.Init() // dvrip source
tapo.Init() // tapo source
isapi.Init() // isapi source
mpegts.Init() // mpegts passive source
roborock.Init() // roborock source
homekit.Init() // homekit source
ring.Init() // ring source
nest.Init() // nest source
bubble.Init() // bubble source
expr.Init() // expr source
gopro.Init() // gopro source
doorbird.Init() // doorbird source
v4l2.Init() // v4l2 source
alsa.Init() // alsa source
flussonic.Init()
eseecloud.Init()
yandex.Init()
// 6. Helper modules
ngrok.Init() // ngrok module
srtp.Init() // SRTP server
debug.Init() // debug API
// 7. Go
shell.RunUntilSignal() shell.RunUntilSignal()
} }
+13 -2
View File
@@ -8,8 +8,19 @@ import (
"github.com/pion/rtp" "github.com/pion/rtp"
) )
const ADTSHeaderSize = 7
func IsADTS(b []byte) bool { 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 { func ADTSToCodec(b []byte) *core.Codec {
@@ -58,7 +69,7 @@ func ADTSToCodec(b []byte) *core.Codec {
func ReadADTSSize(b []byte) uint16 { func ReadADTSSize(b []byte) uint16 {
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
_ = b[5] // bounds _ = 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) { func WriteADTSSize(b []byte, size uint16) {
+25 -11
View File
@@ -2,7 +2,7 @@ package aac
import ( import (
"bufio" "bufio"
"encoding/binary" "errors"
"io" "io"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
@@ -17,16 +17,22 @@ type Producer struct {
func Open(r io.Reader) (*Producer, error) { func Open(r io.Reader) (*Producer, error) {
rd := bufio.NewReader(r) rd := bufio.NewReader(r)
b, err := rd.Peek(8) b, err := rd.Peek(ADTSHeaderSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }
codec := ADTSToCodec(b)
if codec == nil {
return nil, errors.New("adts: wrong header")
}
codec.PayloadType = core.PayloadTypeRAW
medias := []*core.Media{ medias := []*core.Media{
{ {
Kind: core.KindAudio, Kind: core.KindAudio,
Direction: core.DirectionRecvonly, Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{ADTSToCodec(b)}, Codecs: []*core.Codec{codec},
}, },
} }
return &Producer{ return &Producer{
@@ -42,14 +48,25 @@ func Open(r io.Reader) (*Producer, error) {
func (c *Producer) Start() error { func (c *Producer) Start() error {
for { for {
b, err := c.rd.Peek(6) // read ADTS header
if err != nil { adts := make([]byte, ADTSHeaderSize)
if _, err := io.ReadFull(c.rd, adts); err != nil {
return err return err
} }
auSize := ReadADTSSize(b) auSize := ReadADTSSize(adts) - ADTSHeaderSize
payload := make([]byte, 2+2+auSize)
if _, err = io.ReadFull(c.rd, payload[4:]); err != nil { 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 return err
} }
@@ -59,9 +76,6 @@ func (c *Producer) Start() error {
continue continue
} }
payload[1] = 16 // header size in bits
binary.BigEndian.PutUint16(payload[2:], auSize<<3)
pkt := &rtp.Packet{ pkt := &rtp.Packet{
Header: rtp.Header{Timestamp: core.Now90000()}, Header: rtp.Header{Timestamp: core.Now90000()},
Payload: payload, Payload: payload,
+7 -4
View File
@@ -8,7 +8,6 @@ import (
) )
const RTPPacketVersionAAC = 0 const RTPPacketVersionAAC = 0
const ADTSHeaderSize = 7
func RTPDepay(handler core.HandlerFunc) core.HandlerFunc { func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
var timestamp uint32 var timestamp uint32
@@ -65,7 +64,8 @@ func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
} }
func RTPPay(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) { return func(packet *rtp.Packet) {
if packet.Version != RTPPacketVersionAAC { if packet.Version != RTPPacketVersionAAC {
@@ -85,12 +85,15 @@ func RTPPay(handler core.HandlerFunc) core.HandlerFunc {
Header: rtp.Header{ Header: rtp.Header{
Version: 2, Version: 2,
Marker: true, Marker: true,
SequenceNumber: sequencer.NextSequenceNumber(), SequenceNumber: seq,
Timestamp: packet.Timestamp, Timestamp: ts,
}, },
Payload: payload, Payload: payload,
} }
handler(&clone) handler(&clone)
seq++
ts += AUTime
} }
} }
+2 -2
View File
@@ -259,9 +259,9 @@ func ParseCodecString(s string) *Codec {
codec.Name = CodecPCM codec.Name = CodecPCM
case "pcm_s16le", "s16le", "pcml": case "pcm_s16le", "s16le", "pcml":
codec.Name = CodecPCML codec.Name = CodecPCML
case "pcm_alaw", "alaw", "pcma": case "pcm_alaw", "alaw", "pcma", "g711a":
codec.Name = CodecPCMA codec.Name = CodecPCMA
case "pcm_mulaw", "mulaw", "pcmu": case "pcm_mulaw", "mulaw", "pcmu", "g711u":
codec.Name = CodecPCMU codec.Name = CodecPCMU
case "aac", "mpeg4-generic": case "aac", "mpeg4-generic":
codec.Name = CodecAAC codec.Name = CodecAAC
+18 -3
View File
@@ -11,9 +11,9 @@ import (
const ( const (
BufferSize = 64 * 1024 // 64K BufferSize = 64 * 1024 // 64K
ConnDialTimeout = time.Second * 3 ConnDialTimeout = 5 * time.Second
ConnDeadline = time.Second * 5 ConnDeadline = 5 * time.Second
ProbeTimeout = time.Second * 3 ProbeTimeout = 5 * time.Second
) )
// Now90000 - timestamp for Video (clock rate = 90000 samples per second) // Now90000 - timestamp for Video (clock rate = 90000 samples per second)
@@ -67,6 +67,21 @@ func Atoi(s string) (i int) {
return 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) { func Assert(ok bool) {
if !ok { if !ok {
_, file, line, _ := runtime.Caller(1) _, file, line, _ := runtime.Caller(1)
+108 -73
View File
@@ -1,40 +1,78 @@
package expr package expr
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar"
"net/url"
"regexp" "regexp"
"strings" "strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/expr-lang/expr" "github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm" "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 var rd io.Reader
if method == "" { // method from js fetch
if s, ok := options["method"].(string); ok {
method = s
} else {
method = "GET" 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 { if err != nil {
return nil, err return nil, err
} }
for k, v := range headers { if kv, ok := options["headers"].(map[string]any); ok {
req.Header.Set(k, fmt.Sprintf("%v", v)) req.Header = kvToString(kv)
}
if contentType != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", contentType)
} }
return req, nil 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) { func regExp(params ...any) (*regexp.Regexp, error) {
exp := params[0].(string) exp := params[0].(string)
if len(params) >= 2 { if len(params) >= 2 {
@@ -49,72 +87,69 @@ func regExp(params ...any) (*regexp.Regexp, error) {
return regexp.Compile(exp) 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) { 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) { func Eval(input string, env any) (any, error) {
+21
View File
@@ -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
View File
@@ -34,7 +34,7 @@ func (m *Muxer) GetInit() []byte {
switch codec.Name { switch codec.Name {
case core.CodecH264: case core.CodecH264:
b[4] |= FlagsVideo b[4] |= FlagsVideo
obj["videocodecid"] = CodecAVC obj["videocodecid"] = CodecH264
case core.CodecAAC: case core.CodecAAC:
b[4] |= FlagsAudio b[4] |= FlagsAudio
+17 -8
View File
@@ -44,7 +44,9 @@ const (
TagData = 18 TagData = 18
CodecAAC = 10 CodecAAC = 10
CodecAVC = 7
CodecH264 = 7
CodecHEVC = 12
) )
const ( const (
@@ -207,15 +209,18 @@ func (c *Producer) probe() error {
} else { } else {
_ = pkt.Payload[0] >> 4 // FrameType _ = pkt.Payload[0] >> 4 // FrameType
if codecID := pkt.Payload[0] & 0b1111; codecID != CodecAVC {
continue
}
if packetType := pkt.Payload[1]; packetType != PacketTypeAVCHeader { // check if header if packetType := pkt.Payload[1]; packetType != PacketTypeAVCHeader { // check if header
continue 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{ media := &core.Media{
@@ -294,8 +299,12 @@ func (c *Producer) readPacket() (*rtp.Packet, error) {
return pkt, nil return pkt, nil
} }
func TimeToRTP(timeMS uint32, clockRate uint32) uint32 { // TimeToRTP convert time in milliseconds to RTP time
return timeMS * clockRate / 1000 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 { func isExHeader(data []byte) bool {
+3
View File
@@ -0,0 +1,3 @@
## Useful links
- https://github.com/bauer-andreas/secure-video-specification
+13 -13
View File
@@ -49,17 +49,17 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
val120, _ := tlv8.MarshalBase64(StreamingStatus{ val120, _ := tlv8.MarshalBase64(StreamingStatus{
Status: StreamingStatusAvailable, Status: StreamingStatusAvailable,
}) })
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfig{ val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{
Codecs: []VideoCodec{ Codecs: []VideoCodecConfiguration{
{ {
CodecType: VideoCodecTypeH264, CodecType: VideoCodecTypeH264,
CodecParams: []VideoParams{ CodecParams: []VideoCodecParameters{
{ {
ProfileID: []byte{VideoCodecProfileMain}, ProfileID: []byte{VideoCodecProfileMain},
Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
}, },
}, },
VideoAttrs: []VideoAttrs{ VideoAttrs: []VideoCodecAttributes{
{Width: 1920, Height: 1080, Framerate: 30}, {Width: 1920, Height: 1080, Framerate: 30},
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
{Width: 320, Height: 240, Framerate: 15}, // apple watch {Width: 320, Height: 240, Framerate: 15}, // apple watch
@@ -67,23 +67,23 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
}, },
}, },
}) })
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfig{ val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{
Codecs: []AudioCodec{ Codecs: []AudioCodecConfiguration{
{ {
CodecType: AudioCodecTypeOpus, CodecType: AudioCodecTypeOpus,
CodecParams: []AudioParams{ CodecParams: []AudioCodecParameters{
{ {
Channels: 1, Channels: 1,
Bitrate: AudioCodecBitrateVariable, BitrateMode: AudioCodecBitrateVariable,
SampleRate: []byte{AudioCodecSampleRate16Khz}, SampleRate: []byte{AudioCodecSampleRate16Khz},
}, },
}, },
}, },
}, },
ComfortNoise: 0, ComfortNoiseSupport: 0,
}) })
val116, _ := tlv8.MarshalBase64(SupportedRTPConfig{ val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
}) })
service := &hap.Service{ service := &hap.Service{
+39 -39
View File
@@ -63,19 +63,19 @@ func TestAqaraG3(t *testing.T) {
{ {
name: "114", name: "114",
value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==", value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==",
actual: &SupportedVideoStreamConfig{}, actual: &SupportedVideoStreamConfiguration{},
expect: &SupportedVideoStreamConfig{ expect: &SupportedVideoStreamConfiguration{
Codecs: []VideoCodec{ Codecs: []VideoCodecConfiguration{
{ {
CodecType: VideoCodecTypeH264, CodecType: VideoCodecTypeH264,
CodecParams: []VideoParams{ CodecParams: []VideoCodecParameters{
{ {
ProfileID: []byte{VideoCodecProfileMain}, ProfileID: []byte{VideoCodecProfileMain},
Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
CVOEnabled: []byte{0}, CVOEnabled: []byte{0},
}, },
}, },
VideoAttrs: []VideoAttrs{ VideoAttrs: []VideoCodecAttributes{
{Width: 1920, Height: 1080, Framerate: 30}, {Width: 1920, Height: 1080, Framerate: 30},
{Width: 1280, Height: 720, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30},
{Width: 640, Height: 360, Framerate: 30}, {Width: 640, Height: 360, Framerate: 30},
@@ -94,29 +94,29 @@ func TestAqaraG3(t *testing.T) {
{ {
name: "115", name: "115",
value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==", value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==",
actual: &SupportedAudioStreamConfig{}, actual: &SupportedAudioStreamConfiguration{},
expect: &SupportedAudioStreamConfig{ expect: &SupportedAudioStreamConfiguration{
Codecs: []AudioCodec{ Codecs: []AudioCodecConfiguration{
{ {
CodecType: AudioCodecTypeAACELD, CodecType: AudioCodecTypeAACELD,
CodecParams: []AudioParams{ CodecParams: []AudioCodecParameters{
{ {
Channels: 1, Channels: 1,
Bitrate: AudioCodecBitrateVariable, BitrateMode: AudioCodecBitrateVariable,
SampleRate: []byte{AudioCodecSampleRate16Khz}, SampleRate: []byte{AudioCodecSampleRate16Khz},
}, },
}, },
}, },
}, },
ComfortNoise: 0, ComfortNoiseSupport: 0,
}, },
}, },
{ {
name: "116", name: "116",
value: "AgEAAAACAQEAAAIBAg==", value: "AgEAAAACAQEAAAIBAg==",
actual: &SupportedRTPConfig{}, actual: &SupportedRTPConfiguration{},
expect: &SupportedRTPConfig{ expect: &SupportedRTPConfiguration{
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoNone}, SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoDisabled},
}, },
}, },
} }
@@ -130,18 +130,18 @@ func TestHomebridge(t *testing.T) {
{ {
name: "114", name: "114",
value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==", value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==",
actual: &SupportedVideoStreamConfig{}, actual: &SupportedVideoStreamConfiguration{},
expect: &SupportedVideoStreamConfig{ expect: &SupportedVideoStreamConfiguration{
Codecs: []VideoCodec{ Codecs: []VideoCodecConfiguration{
{ {
CodecType: VideoCodecTypeH264, CodecType: VideoCodecTypeH264,
CodecParams: []VideoParams{ CodecParams: []VideoCodecParameters{
{ {
ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh}, ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh},
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
}, },
}, },
VideoAttrs: []VideoAttrs{ VideoAttrs: []VideoCodecAttributes{
{Width: 320, Height: 180, Framerate: 30}, {Width: 320, Height: 180, Framerate: 30},
{Width: 320, Height: 240, Framerate: 15}, {Width: 320, Height: 240, Framerate: 15},
@@ -162,9 +162,9 @@ func TestHomebridge(t *testing.T) {
{ {
name: "116", name: "116",
value: "AgEA", value: "AgEA",
actual: &SupportedRTPConfig{}, actual: &SupportedRTPConfiguration{},
expect: &SupportedRTPConfig{ expect: &SupportedRTPConfiguration{
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
}, },
}, },
} }
@@ -178,18 +178,18 @@ func TestScrypted(t *testing.T) {
{ {
name: "114", name: "114",
value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP", value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP",
actual: &SupportedVideoStreamConfig{}, actual: &SupportedVideoStreamConfiguration{},
expect: &SupportedVideoStreamConfig{ expect: &SupportedVideoStreamConfiguration{
Codecs: []VideoCodec{ Codecs: []VideoCodecConfiguration{
{ {
CodecType: VideoCodecTypeH264, CodecType: VideoCodecTypeH264,
CodecParams: []VideoParams{ CodecParams: []VideoCodecParameters{
{ {
ProfileID: []byte{VideoCodecProfileMain}, ProfileID: []byte{VideoCodecProfileMain},
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
}, },
}, },
VideoAttrs: []VideoAttrs{ VideoAttrs: []VideoCodecAttributes{
{Width: 3840, Height: 2160, Framerate: 30}, {Width: 3840, Height: 2160, Framerate: 30},
{Width: 1920, Height: 1080, Framerate: 30}, {Width: 1920, Height: 1080, Framerate: 30},
{Width: 1280, Height: 720, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30},
@@ -202,15 +202,15 @@ func TestScrypted(t *testing.T) {
{ {
name: "115", name: "115",
value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=", value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=",
actual: &SupportedAudioStreamConfig{}, actual: &SupportedAudioStreamConfiguration{},
expect: &SupportedAudioStreamConfig{ expect: &SupportedAudioStreamConfiguration{
Codecs: []AudioCodec{ Codecs: []AudioCodecConfiguration{
{ {
CodecType: AudioCodecTypeOpus, CodecType: AudioCodecTypeOpus,
CodecParams: []AudioParams{ CodecParams: []AudioCodecParameters{
{ {
Channels: 1, Channels: 1,
Bitrate: AudioCodecBitrateVariable, BitrateMode: AudioCodecBitrateVariable,
SampleRate: []byte{ SampleRate: []byte{
AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz,
AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz,
@@ -220,15 +220,15 @@ func TestScrypted(t *testing.T) {
}, },
}, },
}, },
ComfortNoise: 0, ComfortNoiseSupport: 0,
}, },
}, },
{ {
name: "116", name: "116",
value: "AgEAAAACAQI=", value: "AgEAAAACAQI=",
actual: &SupportedRTPConfig{}, actual: &SupportedRTPConfiguration{},
expect: &SupportedRTPConfig{ expect: &SupportedRTPConfiguration{
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoNone}, SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled},
}, },
}, },
} }
+10 -10
View File
@@ -2,15 +2,15 @@ package camera
const TypeSupportedVideoStreamConfiguration = "114" const TypeSupportedVideoStreamConfiguration = "114"
type SupportedVideoStreamConfig struct { type SupportedVideoStreamConfiguration struct {
Codecs []VideoCodec `tlv8:"1"` Codecs []VideoCodecConfiguration `tlv8:"1"`
} }
type VideoCodec struct { type VideoCodecConfiguration struct {
CodecType byte `tlv8:"1"` CodecType byte `tlv8:"1"`
CodecParams []VideoParams `tlv8:"2"` CodecParams []VideoCodecParameters `tlv8:"2"`
VideoAttrs []VideoAttrs `tlv8:"3"` VideoAttrs []VideoCodecAttributes `tlv8:"3"`
RTPParams []RTPParams `tlv8:"4"` RTPParams []RTPParams `tlv8:"4"`
} }
//goland:noinspection ALL //goland:noinspection ALL
@@ -31,15 +31,15 @@ const (
VideoCodecCvoSuppported = 1 VideoCodecCvoSuppported = 1
) )
type VideoParams struct { type VideoCodecParameters struct {
ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high
Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0 Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0
PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved
CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported
CVOID []byte `tlv8:"5"` // ??? CVOID []byte `tlv8:"5"` // ID for CVO RTP extensio
} }
type VideoAttrs struct { type VideoCodecAttributes struct {
Width uint16 `tlv8:"1"` Width uint16 `tlv8:"1"`
Height uint16 `tlv8:"2"` Height uint16 `tlv8:"2"`
Framerate uint8 `tlv8:"3"` Framerate uint8 `tlv8:"3"`
+13 -13
View File
@@ -2,9 +2,9 @@ package camera
const TypeSupportedAudioStreamConfiguration = "115" const TypeSupportedAudioStreamConfiguration = "115"
type SupportedAudioStreamConfig struct { type SupportedAudioStreamConfiguration struct {
Codecs []AudioCodec `tlv8:"1"` Codecs []AudioCodecConfiguration `tlv8:"1"`
ComfortNoise byte `tlv8:"2"` ComfortNoiseSupport byte `tlv8:"2"`
} }
//goland:noinspection ALL //goland:noinspection ALL
@@ -31,16 +31,16 @@ const (
RTPTimeAACLD24 = 40 // 24000/1000*40=960 RTPTimeAACLD24 = 40 // 24000/1000*40=960
) )
type AudioCodec struct { type AudioCodecConfiguration struct {
CodecType byte `tlv8:"1"` CodecType byte `tlv8:"1"`
CodecParams []AudioParams `tlv8:"2"` CodecParams []AudioCodecParameters `tlv8:"2"`
RTPParams []RTPParams `tlv8:"3"` RTPParams []RTPParams `tlv8:"3"`
ComfortNoise []byte `tlv8:"4"` ComfortNoise []byte `tlv8:"4"`
} }
type AudioParams struct { type AudioCodecParameters struct {
Channels uint8 `tlv8:"1"` Channels uint8 `tlv8:"1"`
Bitrate byte `tlv8:"2"` // 0 - variable, 1 - constant BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000 SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60 RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
} }
@@ -6,9 +6,9 @@ const TypeSupportedRTPConfiguration = "116"
const ( const (
CryptoAES_CM_128_HMAC_SHA1_80 = 0 CryptoAES_CM_128_HMAC_SHA1_80 = 0
CryptoAES_CM_256_HMAC_SHA1_80 = 1 CryptoAES_CM_256_HMAC_SHA1_80 = 1
CryptoNone = 2 CryptoDisabled = 2
) )
type SupportedRTPConfig struct { type SupportedRTPConfiguration struct {
CryptoType []byte `tlv8:"2"` SRTPCryptoType []byte `tlv8:"2"`
} }
+4 -4
View File
@@ -2,10 +2,10 @@ package camera
const TypeSelectedStreamConfiguration = "117" const TypeSelectedStreamConfiguration = "117"
type SelectedStreamConfig struct { type SelectedStreamConfiguration struct {
Control SessionControl `tlv8:"1"` Control SessionControl `tlv8:"1"`
VideoCodec VideoCodec `tlv8:"2"` VideoCodec VideoCodecConfiguration `tlv8:"2"`
AudioCodec AudioCodec `tlv8:"3"` AudioCodec AudioCodecConfiguration `tlv8:"3"`
} }
//goland:noinspection ALL //goland:noinspection ALL
+20 -13
View File
@@ -2,25 +2,32 @@ package camera
const TypeSetupEndpoints = "118" const TypeSetupEndpoints = "118"
type SetupEndpoints struct { type SetupEndpointsRequest struct {
SessionID string `tlv8:"1"` SessionID string `tlv8:"1"`
Status []byte `tlv8:"2"` Address Address `tlv8:"3"`
Address Addr `tlv8:"3"` VideoCrypto SRTPCryptoSuite `tlv8:"4"`
VideoCrypto CryptoSuite `tlv8:"4"` AudioCrypto SRTPCryptoSuite `tlv8:"5"`
AudioCrypto CryptoSuite `tlv8:"5"`
VideoSSRC []uint32 `tlv8:"6"`
AudioSSRC []uint32 `tlv8:"7"`
} }
type Addr struct { type SetupEndpointsResponse struct {
SessionID string `tlv8:"1"`
Status byte `tlv8:"2"`
Address Address `tlv8:"3"`
VideoCrypto SRTPCryptoSuite `tlv8:"4"`
AudioCrypto SRTPCryptoSuite `tlv8:"5"`
VideoSSRC uint32 `tlv8:"6"`
AudioSSRC uint32 `tlv8:"7"`
}
type Address struct {
IPVersion byte `tlv8:"1"` IPVersion byte `tlv8:"1"`
IPAddr string `tlv8:"2"` IPAddr string `tlv8:"2"`
VideoRTPPort uint16 `tlv8:"3"` VideoRTPPort uint16 `tlv8:"3"`
AudioRTPPort uint16 `tlv8:"4"` AudioRTPPort uint16 `tlv8:"4"`
} }
type CryptoSuite struct { type SRTPCryptoSuite struct {
CryptoType byte `tlv8:"1"` CryptoSuite byte `tlv8:"1"`
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM) MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
MasterSalt string `tlv8:"3"` // 14 byte MasterSalt string `tlv8:"3"` // 14 byte
} }
+1 -1
View File
@@ -9,6 +9,6 @@ type StreamingStatus struct {
//goland:noinspection ALL //goland:noinspection ALL
const ( const (
StreamingStatusAvailable = 0 StreamingStatusAvailable = 0
StreamingStatusBusy = 1 StreamingStatusInUse = 1
StreamingStatusUnavailable = 2 StreamingStatusUnavailable = 2
) )
@@ -0,0 +1,11 @@
package camera
const TypeSupportedDataStreamTransportConfiguration = "130"
type SupportedDataStreamTransportConfiguration struct {
Configs []TransferTransportConfiguration `tlv8:"1"`
}
type TransferTransportConfiguration struct {
TransportType byte `tlv8:"1"`
}
+2 -2
View File
@@ -2,13 +2,13 @@ package camera
const TypeSetupDataStreamTransport = "131" const TypeSetupDataStreamTransport = "131"
type SetupDataStreamRequest struct { type SetupDataStreamTransportRequest struct {
SessionCommandType byte `tlv8:"1"` SessionCommandType byte `tlv8:"1"`
TransportType byte `tlv8:"2"` TransportType byte `tlv8:"2"`
ControllerKeySalt string `tlv8:"3"` ControllerKeySalt string `tlv8:"3"`
} }
type SetupDataStreamResponse struct { type SetupDataStreamTransportResponse struct {
Status byte `tlv8:"1"` Status byte `tlv8:"1"`
TransportTypeSessionParameters struct { TransportTypeSessionParameters struct {
TCPListeningPort uint16 `tlv8:"1"` TCPListeningPort uint16 `tlv8:"1"`
+18
View File
@@ -0,0 +1,18 @@
package camera
const TypeSupportedCameraRecordingConfiguration = "205"
type SupportedCameraRecordingConfiguration struct {
PrebufferLength uint32 `tlv8:"1"`
EventTriggerOptions uint64 `tlv8:"2"`
MediaContainerConfigurations `tlv8:"3"`
}
type MediaContainerConfigurations struct {
MediaContainerType uint8 `tlv8:"1"`
MediaContainerParameters `tlv8:"2"`
}
type MediaContainerParameters struct {
FragmentLength uint32 `tlv8:"1"`
}
+20
View File
@@ -0,0 +1,20 @@
package camera
const TypeSupportedVideoRecordingConfiguration = "206"
type SupportedVideoRecordingConfiguration struct {
CodecConfigs []VideoRecordingCodecConfiguration `tlv8:"1"`
}
type VideoRecordingCodecConfiguration struct {
CodecType uint8 `tlv8:"1"`
CodecParams VideoRecordingCodecParameters `tlv8:"2"`
CodecAttrs VideoCodecAttributes `tlv8:"3"`
}
type VideoRecordingCodecParameters struct {
ProfileID uint8 `tlv8:"1"`
Level uint8 `tlv8:"2"`
Bitrate uint32 `tlv8:"3"`
IFrameInterval uint32 `tlv8:"4"`
}
+19
View File
@@ -0,0 +1,19 @@
package camera
const TypeSupportedAudioRecordingConfiguration = "207"
type SupportedAudioRecordingConfiguration struct {
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
}
type AudioRecordingCodecConfiguration struct {
CodecType byte `tlv8:"1"`
CodecParams []AudioRecordingCodecParameters `tlv8:"2"`
}
type AudioRecordingCodecParameters struct {
Channels uint8 `tlv8:"1"`
BitrateMode []byte `tlv8:"2"`
SampleRate []byte `tlv8:"3"`
MaxAudioBitrate []uint32 `tlv8:"4"`
}
+9
View File
@@ -0,0 +1,9 @@
package camera
const TypeSelectedCameraRecordingConfiguration = "209"
type SelectedCameraRecordingConfiguration struct {
GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"`
VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"`
AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"`
}
+11 -11
View File
@@ -15,7 +15,7 @@ type Stream struct {
} }
func NewStream( func NewStream(
client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration,
videoSession, audioSession *srtp.Session, bitrate int, videoSession, audioSession *srtp.Session, bitrate int,
) (*Stream, error) { ) (*Stream, error) {
stream := &Stream{ stream := &Stream{
@@ -58,7 +58,7 @@ func NewStream(
} }
audioCodec.ComfortNoise = []byte{0} audioCodec.ComfortNoise = []byte{0}
config := &SelectedStreamConfig{ config := &SelectedStreamConfiguration{
Control: SessionControl{ Control: SessionControl{
SessionID: stream.id, SessionID: stream.id,
Command: SessionCommandStart, Command: SessionCommandStart,
@@ -103,19 +103,19 @@ func (s *Stream) GetFreeStream() error {
} }
func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error { func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error {
req := SetupEndpoints{ req := SetupEndpointsRequest{
SessionID: s.id, SessionID: s.id,
Address: Addr{ Address: Address{
IPVersion: 0, IPVersion: 0,
IPAddr: videoSession.Local.Addr, IPAddr: videoSession.Local.Addr,
VideoRTPPort: videoSession.Local.Port, VideoRTPPort: videoSession.Local.Port,
AudioRTPPort: audioSession.Local.Port, AudioRTPPort: audioSession.Local.Port,
}, },
VideoCrypto: CryptoSuite{ VideoCrypto: SRTPCryptoSuite{
MasterKey: string(videoSession.Local.MasterKey), MasterKey: string(videoSession.Local.MasterKey),
MasterSalt: string(videoSession.Local.MasterSalt), MasterSalt: string(videoSession.Local.MasterSalt),
}, },
AudioCrypto: CryptoSuite{ AudioCrypto: SRTPCryptoSuite{
MasterKey: string(audioSession.Local.MasterKey), MasterKey: string(audioSession.Local.MasterKey),
MasterSalt: string(audioSession.Local.MasterSalt), MasterSalt: string(audioSession.Local.MasterSalt),
}, },
@@ -129,7 +129,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
return err return err
} }
var res SetupEndpoints var res SetupEndpointsResponse
if err := s.client.GetCharacter(char); err != nil { if err := s.client.GetCharacter(char); err != nil {
return err return err
} }
@@ -142,7 +142,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
Port: res.Address.VideoRTPPort, Port: res.Address.VideoRTPPort,
MasterKey: []byte(res.VideoCrypto.MasterKey), MasterKey: []byte(res.VideoCrypto.MasterKey),
MasterSalt: []byte(res.VideoCrypto.MasterSalt), MasterSalt: []byte(res.VideoCrypto.MasterSalt),
SSRC: res.VideoSSRC[0], SSRC: res.VideoSSRC,
} }
audioSession.Remote = &srtp.Endpoint{ audioSession.Remote = &srtp.Endpoint{
@@ -150,13 +150,13 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
Port: res.Address.AudioRTPPort, Port: res.Address.AudioRTPPort,
MasterKey: []byte(res.AudioCrypto.MasterKey), MasterKey: []byte(res.AudioCrypto.MasterKey),
MasterSalt: []byte(res.AudioCrypto.MasterSalt), MasterSalt: []byte(res.AudioCrypto.MasterSalt),
SSRC: res.AudioSSRC[0], SSRC: res.AudioSSRC,
} }
return nil return nil
} }
func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error { func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error {
char := s.service.GetCharacter(TypeSelectedStreamConfiguration) char := s.service.GetCharacter(TypeSelectedStreamConfiguration)
if err := char.Write(config); err != nil { if err := char.Write(config); err != nil {
return err return err
@@ -169,7 +169,7 @@ func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
} }
func (s *Stream) Close() error { func (s *Stream) Close() error {
config := &SelectedStreamConfig{ config := &SelectedStreamConfiguration{
Control: SessionControl{ Control: SessionControl{
SessionID: s.id, SessionID: s.id,
Command: SessionCommandEnd, Command: SessionCommandEnd,
+11 -5
View File
@@ -18,7 +18,6 @@ import (
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519" "github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519" "github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/AlexxIT/go2rtc/pkg/mdns" "github.com/AlexxIT/go2rtc/pkg/mdns"
) )
@@ -46,7 +45,7 @@ type Client struct {
err error err error
} }
func NewClient(rawURL string) (*Client, error) { func Dial(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -61,6 +60,10 @@ func NewClient(rawURL string) (*Client, error) {
ClientPrivate: DecodeKey(query.Get("client_private")), ClientPrivate: DecodeKey(query.Get("client_private")),
} }
if err = c.Dial(); err != nil {
return nil, err
}
return c, nil return c, nil
} }
@@ -96,6 +99,7 @@ func (c *Client) Dial() (err error) {
return false return false
}) })
// TODO: close conn on error
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil { if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
return return
} }
@@ -124,7 +128,7 @@ func (c *Client) Dial() (err error) {
EncryptedData string `tlv8:"5"` EncryptedData string `tlv8:"5"`
State byte `tlv8:"6"` State byte `tlv8:"6"`
} }
if err = tlv8.UnmarshalReader(res.Body, &cipherM2); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM2); err != nil {
return err return err
} }
if cipherM2.State != StateM2 { if cipherM2.State != StateM2 {
@@ -209,15 +213,17 @@ func (c *Client) Dial() (err error) {
var plainM4 struct { var plainM4 struct {
State byte `tlv8:"6"` State byte `tlv8:"6"`
} }
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil {
return return
} }
if plainM4.State != StateM4 { if plainM4.State != StateM4 {
return newResponseError(cipherM3, plainM4) return newResponseError(cipherM3, plainM4)
} }
rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn))
// like tls.Client wrapper over net.Conn // like tls.Client wrapper over net.Conn
if c.Conn, err = secure.Client(c.Conn, sessionShared, true); err != nil { if c.Conn, err = NewConn(c.Conn, rw, sessionShared, true); err != nil {
return return
} }
// new reader for new conn // new reader for new conn
+17
View File
@@ -82,3 +82,20 @@ func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) {
return res, nil return res, nil
} }
func WriteEvent(w io.Writer, res *http.Response) error {
return res.Write(&eventWriter{w: w})
}
type eventWriter struct {
w io.Writer
done bool
}
func (e *eventWriter) Write(p []byte) (n int, err error) {
if !e.done {
p = append([]byte("EVENT/1.0"), p[8:]...)
e.done = true
}
return e.w.Write(p)
}
+8 -9
View File
@@ -107,7 +107,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
State byte `tlv8:"6"` State byte `tlv8:"6"`
Error byte `tlv8:"7"` Error byte `tlv8:"7"`
} }
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
return return
} }
if plainM2.State != StateM2 { if plainM2.State != StateM2 {
@@ -121,9 +121,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
username := []byte("Pair-Setup") username := []byte("Pair-Setup")
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
pake, err := srp.NewSRP( pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
)
if err != nil { if err != nil {
return return
} }
@@ -132,6 +130,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
// username: "Pair-Setup", password: PIN (with dashes) // username: "Pair-Setup", password: PIN (with dashes)
session := pake.NewClientSession(username, []byte(pin)) session := pake.NewClientSession(username, []byte(pin))
sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey)) sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey))
if err != nil { if err != nil {
return return
@@ -159,7 +158,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices) EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices)
} }
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil {
return return
} }
if plainM4.State != StateM4 { if plainM4.State != StateM4 {
@@ -232,7 +231,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
State byte `tlv8:"6"` State byte `tlv8:"6"`
Error byte `tlv8:"7"` Error byte `tlv8:"7"`
}{} }{}
if err = tlv8.UnmarshalReader(res.Body, &cipherM6); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM6); err != nil {
return return
} }
if cipherM6.State != StateM6 || cipherM6.Error != 0 { if cipherM6.State != StateM6 || cipherM6.Error != 0 {
@@ -296,7 +295,7 @@ func (c *Client) ListPairings() error {
State byte `tlv8:"6"` State byte `tlv8:"6"`
Permission byte `tlv8:"11"` Permission byte `tlv8:"11"`
} }
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
return err return err
} }
@@ -329,7 +328,7 @@ func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) e
State byte `tlv8:"6"` State byte `tlv8:"6"`
Unknown byte `tlv8:"7"` Unknown byte `tlv8:"7"`
} }
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
return err return err
} }
@@ -354,7 +353,7 @@ func (c *Client) DeletePairing(id string) error {
var plainM2 struct { var plainM2 struct {
State byte `tlv8:"6"` State byte `tlv8:"6"`
} }
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
return err return err
} }
if plainM2.State != StateM2 { if plainM2.State != StateM2 {
+44 -20
View File
@@ -1,32 +1,50 @@
package secure package hap
import ( import (
"bufio" "bufio"
"encoding/binary" "encoding/binary"
"encoding/json"
"errors" "errors"
"io" "io"
"net" "net"
"sync"
"time" "time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
) )
type Conn struct { type Conn struct {
conn net.Conn conn net.Conn
rw *bufio.ReadWriter
rd *bufio.Reader wmu sync.Mutex
wr *bufio.Writer
encryptKey []byte encryptKey []byte
decryptKey []byte decryptKey []byte
encryptCnt uint64 encryptCnt uint64
decryptCnt uint64 decryptCnt uint64
//ClientID string
SharedKey []byte SharedKey []byte
recv int
send int
} }
func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { func (c *Conn) MarshalJSON() ([]byte, error) {
conn := core.Connection{
ID: core.ID(c),
FormatName: "homekit",
Protocol: "hap",
RemoteAddr: c.conn.RemoteAddr().String(),
Recv: c.recv,
Send: c.send,
}
return json.Marshal(conn)
}
func NewConn(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) {
key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key") key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -39,8 +57,7 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
c := &Conn{ c := &Conn{
conn: conn, conn: conn,
rd: bufio.NewReaderSize(conn, 32*1024), rw: rw,
wr: bufio.NewWriterSize(conn, 32*1024),
SharedKey: sharedKey, SharedKey: sharedKey,
} }
@@ -55,8 +72,8 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
} }
const ( const (
// PacketSizeMax is the max length of encrypted packets // packetSizeMax is the max length of encrypted packets
PacketSizeMax = 0x400 packetSizeMax = 0x400
VerifySize = 2 VerifySize = 2
NonceSize = 8 NonceSize = 8
@@ -64,19 +81,19 @@ const (
) )
func (c *Conn) Read(b []byte) (n int, err error) { func (c *Conn) Read(b []byte) (n int, err error) {
if cap(b) < PacketSizeMax { if cap(b) < packetSizeMax {
return 0, errors.New("hap: read buffer is too small") return 0, errors.New("hap: read buffer is too small")
} }
verify := make([]byte, 2) // verify = plain message size verify := make([]byte, VerifySize) // verify = plain message size
if _, err = io.ReadFull(c.rd, verify); err != nil { if _, err = io.ReadFull(c.rw, verify); err != nil {
return return
} }
n = int(binary.LittleEndian.Uint16(verify)) n = int(binary.LittleEndian.Uint16(verify))
ciphertext := make([]byte, n+Overhead)
if _, err = io.ReadFull(c.rd, ciphertext); err != nil { ciphertext := make([]byte, n+Overhead)
if _, err = io.ReadFull(c.rw, ciphertext); err != nil {
return return
} }
@@ -85,22 +102,27 @@ func (c *Conn) Read(b []byte) (n int, err error) {
c.decryptCnt++ c.decryptCnt++
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify) _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify)
c.recv += n
return return
} }
func (c *Conn) Write(b []byte) (n int, err error) { func (c *Conn) Write(b []byte) (n int, err error) {
buf := make([]byte, 0, PacketSizeMax+Overhead) c.wmu.Lock()
defer c.wmu.Unlock()
buf := make([]byte, 0, packetSizeMax+Overhead)
nonce := make([]byte, NonceSize) nonce := make([]byte, NonceSize)
verify := make([]byte, VerifySize) verify := make([]byte, VerifySize)
for len(b) > 0 { for len(b) > 0 {
size := len(b) size := len(b)
if size > PacketSizeMax { if size > packetSizeMax {
size = PacketSizeMax size = packetSizeMax
} }
binary.LittleEndian.PutUint16(verify, uint16(size)) binary.LittleEndian.PutUint16(verify, uint16(size))
if _, err = c.wr.Write(verify); err != nil { if _, err = c.rw.Write(verify); err != nil {
return return
} }
@@ -112,7 +134,7 @@ func (c *Conn) Write(b []byte) (n int, err error) {
return return
} }
if _, err = c.wr.Write(buf[:size+Overhead]); err != nil { if _, err = c.rw.Write(buf[:size+Overhead]); err != nil {
return return
} }
@@ -120,7 +142,9 @@ func (c *Conn) Write(b []byte) (n int, err error) {
n += size n += size
} }
err = c.wr.Flush() err = c.rw.Flush()
c.send += n
return return
} }
+62 -9
View File
@@ -4,16 +4,19 @@ package hds
import ( import (
"bufio" "bufio"
"encoding/binary" "encoding/binary"
"encoding/json"
"errors"
"io" "io"
"net" "net"
"time" "time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
) )
func Client(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) { func NewConn(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) {
writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key") writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -49,43 +52,91 @@ type Conn struct {
encryptKey []byte encryptKey []byte
decryptCnt uint64 decryptCnt uint64
encryptCnt uint64 encryptCnt uint64
recv int
send int
} }
func (c *Conn) Read(p []byte) (n int, err error) { func (c *Conn) MarshalJSON() ([]byte, error) {
conn := core.Connection{
ID: core.ID(c),
FormatName: "homekit",
Protocol: "hds",
RemoteAddr: c.conn.RemoteAddr().String(),
Recv: c.recv,
Send: c.send,
}
return json.Marshal(conn)
}
func (c *Conn) read() (b []byte, err error) {
verify := make([]byte, 4) verify := make([]byte, 4)
if _, err = io.ReadFull(c.rd, verify); err != nil { if _, err = io.ReadFull(c.rd, verify); err != nil {
return return
} }
n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF) n := int(binary.BigEndian.Uint32(verify) & 0xFFFFFF)
ciphertext := make([]byte, n+secure.Overhead) ciphertext := make([]byte, n+hap.Overhead)
if _, err = io.ReadFull(c.rd, ciphertext); err != nil { if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
return return
} }
nonce := make([]byte, secure.NonceSize) nonce := make([]byte, hap.NonceSize)
binary.LittleEndian.PutUint64(nonce, c.decryptCnt) binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
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 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) { func (c *Conn) Write(b []byte) (n int, err error) {
n = len(b) n = len(b)
if n > 0xFFFFFF {
return 0, errors.New("hds: write buffer too big")
}
verify := make([]byte, 4) verify := make([]byte, 4)
binary.BigEndian.PutUint32(verify, 0x01000000|uint32(n)) binary.BigEndian.PutUint32(verify, 0x01000000|uint32(n))
if _, err = c.wr.Write(verify); err != nil { if _, err = c.wr.Write(verify); err != nil {
return return
} }
nonce := make([]byte, secure.NonceSize) nonce := make([]byte, hap.NonceSize)
binary.LittleEndian.PutUint64(nonce, c.encryptCnt) binary.LittleEndian.PutUint64(nonce, c.encryptCnt)
c.encryptCnt++ c.encryptCnt++
buf := make([]byte, n+secure.Overhead) buf := make([]byte, n+hap.Overhead)
if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil { if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil {
return return
} }
@@ -95,6 +146,8 @@ func (c *Conn) Write(b []byte) (n int, err error) {
} }
err = c.wr.Flush() err = c.wr.Flush()
c.send += n
return return
} }
+8
View File
@@ -3,6 +3,8 @@ package hap
import ( import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/sha512"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -99,6 +101,12 @@ func GenerateUUID() string {
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] 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) { func Append(items ...any) (b []byte) {
for _, item := range items { for _, item := range items {
switch v := item.(type) { switch v := item.(type) {
+273 -52
View File
@@ -3,32 +3,25 @@ package hap
import ( import (
"bufio" "bufio"
"crypto/sha512" "crypto/sha512"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io"
"net"
"net/http" "net/http"
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519" "github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519" "github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/tadglines/go-pkgs/crypto/srp"
) )
type HandlerFunc func(net.Conn) error
type Server struct { type Server struct {
Pin string Pin string
DeviceID string DeviceID string
DevicePrivate []byte DevicePrivate []byte
GetPair func(conn net.Conn, id string) []byte // GetClientPublic may be nil, so client validation will be disabled
AddPair func(conn net.Conn, id string, public []byte, permissions byte) GetClientPublic func(id string) []byte
Handler HandlerFunc
} }
func (s *Server) ServerPublic() []byte { func (s *Server) ServerPublic() []byte {
@@ -42,44 +35,240 @@ func (s *Server) ServerPublic() []byte {
// return StatusPaired // return StatusPaired
//} //}
func (s *Server) SetupHash() string { func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) {
// should be setup_id (random 4 alphanum) + device_id (mac address) // STEP 1. Request from iPhone
// but device_id is random, so OK
b := sha512.Sum512([]byte(s.DeviceID))
return base64.StdEncoding.EncodeToString(b[:4])
}
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
// Request from iPhone
var plainM1 struct { var plainM1 struct {
PublicKey string `tlv8:"3"` State byte `tlv8:"6"`
State byte `tlv8:"6"` Method byte `tlv8:"0"`
Flags uint32 `tlv8:"19"`
} }
if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil { if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
return err return
} }
if plainM1.State != StateM1 { if plainM1.State != StateM1 {
return newRequestError(plainM1) err = newRequestError(plainM1)
return
}
username := []byte("Pair-Setup")
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
if err != nil {
return
}
pake.SaltLength = 16
salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin))
if err != nil {
return
}
session := pake.NewServerSession(username, salt, verifier)
// STEP 2. Response to iPhone
plainM2 := struct {
State byte `tlv8:"6"`
PublicKey string `tlv8:"3"`
Salt string `tlv8:"2"`
}{
State: StateM2,
PublicKey: string(session.GetB()),
Salt: string(salt),
}
body, err := tlv8.Marshal(plainM2)
if err != nil {
return
}
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return
}
// STEP 3. Request from iPhone
if req, err = http.ReadRequest(rw.Reader); err != nil {
return
}
var plainM3 struct {
State byte `tlv8:"6"`
PublicKey string `tlv8:"3"`
Proof string `tlv8:"4"`
}
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil {
return
}
if plainM3.State != StateM3 {
err = newRequestError(plainM3)
return
}
// important to compute key before verify client
sessionShared, err := session.ComputeKey([]byte(plainM3.PublicKey))
if err != nil {
return
}
if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) {
err = errors.New("hap: VerifyClientAuthenticator")
return
}
proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof
// STEP 4. Response to iPhone
payloadM4 := struct {
State byte `tlv8:"6"`
Proof string `tlv8:"4"`
}{
State: StateM4,
Proof: string(proof),
}
if body, err = tlv8.Marshal(payloadM4); err != nil {
return
}
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return
}
// STEP 5. Request from iPhone
if req, err = http.ReadRequest(rw.Reader); err != nil {
return
}
var cipherM5 struct {
State byte `tlv8:"6"`
EncryptedData string `tlv8:"5"`
}
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil {
return
}
if cipherM5.State != StateM5 {
err = newRequestError(cipherM5)
return
}
// decrypt message using session shared
encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")
if err != nil {
return
}
b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData))
if err != nil {
return
}
// unpack message from TLV8
var plainM5 struct {
Identifier string `tlv8:"1"`
PublicKey string `tlv8:"3"`
Signature string `tlv8:"10"`
}
if err = tlv8.Unmarshal(b, &plainM5); err != nil {
return
}
// 3. verify client ID and Public
remoteSign, err := hkdf.Sha512(
sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info",
)
if err != nil {
return
}
b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey)
if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) {
err = errors.New("hap: ValidateSignature")
return
}
// 4. generate signature to our ID and Public
localSign, err := hkdf.Sha512(
sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info",
)
if err != nil {
return
}
b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic
signature, err := ed25519.Signature(s.DevicePrivate, b)
if err != nil {
return
}
// 5. pack our ID and Public
plainM6 := struct {
Identifier string `tlv8:"1"`
PublicKey string `tlv8:"3"`
Signature string `tlv8:"10"`
}{
Identifier: s.DeviceID,
PublicKey: string(s.ServerPublic()),
Signature: string(signature),
}
if b, err = tlv8.Marshal(plainM6); err != nil {
return
}
// 6. encrypt message
b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b)
if err != nil {
return
}
// STEP 6. Response to iPhone
cipherM6 := struct {
State byte `tlv8:"6"`
EncryptedData string `tlv8:"5"`
}{
State: StateM6,
EncryptedData: string(b),
}
if body, err = tlv8.Marshal(cipherM6); err != nil {
return
}
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return
}
id = plainM5.Identifier
publicKey = []byte(plainM5.PublicKey)
return
}
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter) (id string, sessionKey []byte, err error) {
// Request from iPhone
var plainM1 struct {
State byte `tlv8:"6"`
PublicKey string `tlv8:"3"`
}
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
return
}
if plainM1.State != StateM1 {
err = newRequestError(plainM1)
return
} }
// Generate the key pair // Generate the key pair
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair() sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey)) sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey))
if err != nil { if err != nil {
return err return
} }
encryptKey, err := hkdf.Sha512( encryptKey, err := hkdf.Sha512(
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
) )
if err != nil { if err != nil {
return err return
} }
b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey) b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey)
signature, err := ed25519.Signature(s.DevicePrivate, b) signature, err := ed25519.Signature(s.DevicePrivate, b)
if err != nil { if err != nil {
return err return
} }
// STEP M2. Response to iPhone // STEP M2. Response to iPhone
@@ -91,12 +280,12 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
Signature: string(signature), Signature: string(signature),
} }
if b, err = tlv8.Marshal(plainM2); err != nil { if b, err = tlv8.Marshal(plainM2); err != nil {
return err return
} }
b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b) b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b)
if err != nil { if err != nil {
return err return
} }
cipherM2 := struct { cipherM2 := struct {
@@ -110,30 +299,32 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
} }
body, err := tlv8.Marshal(cipherM2) body, err := tlv8.Marshal(cipherM2)
if err != nil { if err != nil {
return err return
} }
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return err return
} }
// STEP M3. Request from iPhone // STEP M3. Request from iPhone
if req, err = http.ReadRequest(rw.Reader); err != nil { if req, err = http.ReadRequest(rw.Reader); err != nil {
return err return
} }
var cipherM3 struct { var cipherM3 struct {
EncryptedData string `tlv8:"5"`
State byte `tlv8:"6"` State byte `tlv8:"6"`
EncryptedData string `tlv8:"5"`
} }
if err = tlv8.UnmarshalReader(req.Body, &cipherM3); err != nil { if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil {
return err return
} }
if cipherM3.State != StateM3 { if cipherM3.State != StateM3 {
return newRequestError(cipherM3) err = newRequestError(cipherM3)
return
} }
if b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)); err != nil { b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData))
return err if err != nil {
return
} }
var plainM3 struct { var plainM3 struct {
@@ -141,17 +332,21 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
Signature string `tlv8:"10"` Signature string `tlv8:"10"`
} }
if err = tlv8.Unmarshal(b, &plainM3); err != nil { if err = tlv8.Unmarshal(b, &plainM3); err != nil {
return err return
} }
clientPublic := s.GetPair(conn, plainM3.Identifier) if s.GetClientPublic != nil {
if clientPublic == nil { clientPublic := s.GetClientPublic(plainM3.Identifier)
return fmt.Errorf("hap: PairVerify from: %s, with unknown client_id: %s", conn.RemoteAddr(), plainM3.Identifier) if clientPublic == nil {
} err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier)
return
}
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic) b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) { if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
return errors.New("new: ValidateSignature") err = errors.New("hap: ValidateSignature")
return
}
} }
// STEP M4. Response to iPhone // STEP M4. Response to iPhone
@@ -161,15 +356,41 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
State: StateM4, State: StateM4,
} }
if body, err = tlv8.Marshal(payloadM4); err != nil { if body, err = tlv8.Marshal(payloadM4); err != nil {
return err return
} }
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return err return
} }
if conn, err = secure.Client(conn, sessionShared, false); err != nil { id = plainM3.Identifier
return err sessionKey = sessionShared
}
return s.Handler(conn) return
} }
func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error {
header := fmt.Sprintf(
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
statusCode, http.StatusText(statusCode), contentType, len(body),
)
body = append([]byte(header), body...)
if _, err := w.Write(body); err != nil {
return err
}
return w.Flush()
}
//func WriteBackoff(rw *bufio.ReadWriter) error {
// plainM2 := struct {
// State byte `tlv8:"6"`
// Error byte `tlv8:"7"`
// }{
// State: StateM2,
// Error: 3, // BackoffError
// }
// body, err := tlv8.Marshal(plainM2)
// if err != nil {
// return err
// }
// return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
//}
-252
View File
@@ -1,252 +0,0 @@
package hap
import (
"bufio"
"crypto/sha512"
"errors"
"fmt"
"io"
"net"
"net/http"
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/tadglines/go-pkgs/crypto/srp"
)
const (
PairMethodSetup = iota
PairMethodSetupWithAuth
PairMethodVerify
PairMethodAdd
PairMethodRemove
PairMethodList
)
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
if req.Header.Get("Content-Type") != MimeTLV8 {
return errors.New("hap: wrong content type")
}
// STEP 1. Request from iPhone
var plainM1 struct {
Method byte `tlv8:"0"`
State byte `tlv8:"6"`
Flags uint32 `tlv8:"19"`
}
if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil {
return err
}
if plainM1.State != StateM1 {
return newRequestError(plainM1)
}
username := []byte("Pair-Setup")
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
pake, err := srp.NewSRP(
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
)
if err != nil {
return err
}
pake.SaltLength = 16
salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin))
session := pake.NewServerSession(username, salt, verifier)
// STEP 2. Response to iPhone
plainM2 := struct {
Salt string `tlv8:"2"`
PublicKey string `tlv8:"3"`
State byte `tlv8:"6"`
}{
State: StateM2,
PublicKey: string(session.GetB()),
Salt: string(salt),
}
body, err := tlv8.Marshal(plainM2)
if err != nil {
return err
}
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return err
}
// STEP 3. Request from iPhone
if req, err = http.ReadRequest(rw.Reader); err != nil {
return err
}
var plainM3 struct {
SessionKey string `tlv8:"3"`
Proof string `tlv8:"4"`
State byte `tlv8:"6"`
}
if err = tlv8.UnmarshalReader(req.Body, &plainM3); err != nil {
return err
}
if plainM3.State != StateM3 {
return newRequestError(plainM3)
}
// important to compute key before verify client
sessionShared, err := session.ComputeKey([]byte(plainM3.SessionKey))
if err != nil {
return err
}
if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) {
return errors.New("hap: VerifyClientAuthenticator")
}
proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof
// STEP 4. Response to iPhone
payloadM4 := struct {
Proof string `tlv8:"4"`
State byte `tlv8:"6"`
}{
Proof: string(proof),
State: StateM4,
}
if body, err = tlv8.Marshal(payloadM4); err != nil {
return err
}
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return err
}
// STEP 5. Request from iPhone
if req, err = http.ReadRequest(rw.Reader); err != nil {
return err
}
var cipherM5 struct {
EncryptedData string `tlv8:"5"`
State byte `tlv8:"6"`
}
if err = tlv8.UnmarshalReader(req.Body, &cipherM5); err != nil {
return err
}
if cipherM5.State != StateM5 {
return newRequestError(cipherM5)
}
// decrypt message using session shared
encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")
if err != nil {
return err
}
b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData))
if err != nil {
return err
}
// unpack message from TLV8
var plainM5 struct {
Identifier string `tlv8:"1"`
PublicKey string `tlv8:"3"`
Signature string `tlv8:"10"`
}
if err = tlv8.Unmarshal(b, &plainM5); err != nil {
return err
}
// 3. verify client ID and Public
remoteSign, err := hkdf.Sha512(
sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info",
)
if err != nil {
return err
}
b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey)
if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) {
return errors.New("hap: ValidateSignature")
}
// 4. generate signature to our ID and Public
localSign, err := hkdf.Sha512(
sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info",
)
if err != nil {
return err
}
b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic
signature, err := ed25519.Signature(s.DevicePrivate, b)
if err != nil {
return err
}
// 5. pack our ID and Public
plainM6 := struct {
Identifier string `tlv8:"1"`
PublicKey string `tlv8:"3"`
Signature string `tlv8:"10"`
}{
Identifier: s.DeviceID,
PublicKey: string(s.ServerPublic()),
Signature: string(signature),
}
if b, err = tlv8.Marshal(plainM6); err != nil {
return err
}
// 6. encrypt message
b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b)
if err != nil {
return err
}
// STEP 6. Response to iPhone
cipherM6 := struct {
EncryptedData string `tlv8:"5"`
State byte `tlv8:"6"`
}{
State: StateM6,
EncryptedData: string(b),
}
if body, err = tlv8.Marshal(cipherM6); err != nil {
return err
}
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
return err
}
s.AddPair(conn, plainM5.Identifier, []byte(plainM5.PublicKey), PermissionAdmin)
return nil
}
func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error {
header := fmt.Sprintf(
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
statusCode, http.StatusText(statusCode), contentType, len(body),
)
body = append([]byte(header), body...)
if _, err := w.Write(body); err != nil {
return err
}
return w.Flush()
}
func WriteBackoff(rw *bufio.ReadWriter) error {
plainM2 := struct {
State byte `tlv8:"6"`
Error byte `tlv8:"7"`
}{
State: StateM2,
Error: 3, // BackoffError
}
body, err := tlv8.Marshal(plainM2)
if err != nil {
return err
}
return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
}
+32
View File
@@ -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"
+18
View File
@@ -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)
}
+21 -2
View File
@@ -112,6 +112,10 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) {
v := value.Uint() v := value.Uint()
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
case reflect.Uint64:
v := value.Uint()
return binary.LittleEndian.AppendUint64(append(b, tag, 8), v), nil
case reflect.Float32: case reflect.Float32:
v := math.Float32bits(float32(value.Float())) v := math.Float32bits(float32(value.Float()))
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
@@ -170,11 +174,20 @@ func UnmarshalBase64(in any, out any) error {
return Unmarshal(data, out) return Unmarshal(data, out)
} }
func UnmarshalReader(r io.Reader, v any) error { func UnmarshalReader(r io.Reader, n int64, v any) error {
data, err := io.ReadAll(r) var data []byte
var err error
if n > 0 {
data = make([]byte, n)
_, err = io.ReadFull(r, data)
} else {
data, err = io.ReadAll(r)
}
if err != nil { if err != nil {
return err return err
} }
return Unmarshal(data, v) return Unmarshal(data, v)
} }
@@ -301,6 +314,12 @@ func unmarshalValue(v []byte, value reflect.Value) error {
} }
value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24) value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24)
case reflect.Uint64:
if len(v) != 8 {
return errors.New("tlv8: wrong size: " + value.Type().Name())
}
value.SetUint(binary.LittleEndian.Uint64(v))
case reflect.Float32: case reflect.Float32:
f := math.Float32frombits(binary.LittleEndian.Uint32(v)) f := math.Float32frombits(binary.LittleEndian.Uint32(v))
value.SetFloat(float64(f)) value.SetFloat(float64(f))
+15 -11
View File
@@ -49,7 +49,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
Connection: core.Connection{ Connection: core.Connection{
ID: core.NewID(), ID: core.NewID(),
FormatName: "homekit", FormatName: "homekit",
Protocol: "udp", Protocol: "rtp",
RemoteAddr: conn.RemoteAddr().String(), RemoteAddr: conn.RemoteAddr().String(),
Medias: medias, Medias: medias,
Transport: conn, Transport: conn,
@@ -59,7 +59,11 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
} }
} }
func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) { func (c *Consumer) SessionID() string {
return c.sessionID
}
func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) {
c.sessionID = offer.SessionID c.sessionID = offer.SessionID
c.videoSession = &srtp.Session{ c.videoSession = &srtp.Session{
Remote: &srtp.Endpoint{ Remote: &srtp.Endpoint{
@@ -79,32 +83,32 @@ func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) {
} }
} }
func (c *Consumer) GetAnswer() *camera.SetupEndpoints { func (c *Consumer) GetAnswer() *camera.SetupEndpointsResponse {
c.videoSession.Local = c.srtpEndpoint() c.videoSession.Local = c.srtpEndpoint()
c.audioSession.Local = c.srtpEndpoint() c.audioSession.Local = c.srtpEndpoint()
return &camera.SetupEndpoints{ return &camera.SetupEndpointsResponse{
SessionID: c.sessionID, SessionID: c.sessionID,
Status: []byte{0}, Status: camera.StreamingStatusAvailable,
Address: camera.Addr{ Address: camera.Address{
IPAddr: c.videoSession.Local.Addr, IPAddr: c.videoSession.Local.Addr,
VideoRTPPort: c.videoSession.Local.Port, VideoRTPPort: c.videoSession.Local.Port,
AudioRTPPort: c.audioSession.Local.Port, AudioRTPPort: c.audioSession.Local.Port,
}, },
VideoCrypto: camera.CryptoSuite{ VideoCrypto: camera.SRTPCryptoSuite{
MasterKey: string(c.videoSession.Local.MasterKey), MasterKey: string(c.videoSession.Local.MasterKey),
MasterSalt: string(c.videoSession.Local.MasterSalt), MasterSalt: string(c.videoSession.Local.MasterSalt),
}, },
AudioCrypto: camera.CryptoSuite{ AudioCrypto: camera.SRTPCryptoSuite{
MasterKey: string(c.audioSession.Local.MasterKey), MasterKey: string(c.audioSession.Local.MasterKey),
MasterSalt: string(c.audioSession.Local.MasterSalt), MasterSalt: string(c.audioSession.Local.MasterSalt),
}, },
VideoSSRC: []uint32{c.videoSession.Local.SSRC}, VideoSSRC: c.videoSession.Local.SSRC,
AudioSSRC: []uint32{c.audioSession.Local.SSRC}, AudioSSRC: c.audioSession.Local.SSRC,
} }
} }
func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfig) bool { func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool {
if c.sessionID != conf.Control.SessionID { if c.sessionID != conf.Control.SessionID {
return false return false
} }
+13 -10
View File
@@ -13,7 +13,7 @@ var videoCodecs = [...]string{core.CodecH264}
var videoProfiles = [...]string{"4200", "4D00", "6400"} var videoProfiles = [...]string{"4200", "4D00", "6400"}
var videoLevels = [...]string{"1F", "20", "28"} var videoLevels = [...]string{"1F", "20", "28"}
func videoToMedia(codecs []camera.VideoCodec) *core.Media { func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media {
media := &core.Media{ media := &core.Media{
Kind: core.KindVideo, Direction: core.DirectionRecvonly, Kind: core.KindVideo, Direction: core.DirectionRecvonly,
} }
@@ -39,7 +39,7 @@ func videoToMedia(codecs []camera.VideoCodec) *core.Media {
var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus} var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus}
var audioSampleRates = [...]uint32{8000, 16000, 24000} var audioSampleRates = [...]uint32{8000, 16000, 24000}
func audioToMedia(codecs []camera.AudioCodec) *core.Media { func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media {
media := &core.Media{ media := &core.Media{
Kind: core.KindAudio, Direction: core.DirectionRecvonly, Kind: core.KindAudio, Direction: core.DirectionRecvonly,
} }
@@ -67,10 +67,10 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media {
return media return media
} }
func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.VideoCodec { func trackToVideo(track *core.Receiver, video0 *camera.VideoCodecConfiguration, maxWidth, maxHeight int) *camera.VideoCodecConfiguration {
profileID := video0.CodecParams[0].ProfileID[0] profileID := video0.CodecParams[0].ProfileID[0]
level := video0.CodecParams[0].Level[0] level := video0.CodecParams[0].Level[0]
attrs := video0.VideoAttrs[0] var attrs camera.VideoCodecAttributes
if track != nil { if track != nil {
profile := h264.GetProfileLevelID(track.Codec.FmtpLine) profile := h264.GetProfileLevelID(track.Codec.FmtpLine)
@@ -90,25 +90,28 @@ func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.Video
} }
for _, s := range video0.VideoAttrs { for _, s := range video0.VideoAttrs {
if (maxWidth > 0 && int(s.Width) > maxWidth) || (maxHeight > 0 && int(s.Height) > maxHeight) {
continue
}
if s.Width > attrs.Width || s.Height > attrs.Height { if s.Width > attrs.Width || s.Height > attrs.Height {
attrs = s attrs = s
} }
} }
} }
return &camera.VideoCodec{ return &camera.VideoCodecConfiguration{
CodecType: video0.CodecType, CodecType: video0.CodecType,
CodecParams: []camera.VideoParams{ CodecParams: []camera.VideoCodecParameters{
{ {
ProfileID: []byte{profileID}, ProfileID: []byte{profileID},
Level: []byte{level}, Level: []byte{level},
}, },
}, },
VideoAttrs: []camera.VideoAttrs{attrs}, VideoAttrs: []camera.VideoCodecAttributes{attrs},
} }
} }
func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.AudioCodec { func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodecConfiguration) *camera.AudioCodecConfiguration {
codecType := audio0.CodecType codecType := audio0.CodecType
channels := audio0.CodecParams[0].Channels channels := audio0.CodecParams[0].Channels
sampleRate := audio0.CodecParams[0].SampleRate[0] sampleRate := audio0.CodecParams[0].SampleRate[0]
@@ -131,9 +134,9 @@ func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.Audio
} }
} }
return &camera.AudioCodec{ return &camera.AudioCodecConfiguration{
CodecType: codecType, CodecType: codecType,
CodecParams: []camera.AudioParams{ CodecParams: []camera.AudioCodecParameters{
{ {
Channels: channels, Channels: channels,
SampleRate: []byte{sampleRate}, SampleRate: []byte{sampleRate},
+45
View File
@@ -0,0 +1,45 @@
package log
import (
"bytes"
"io"
"log"
"net/http"
)
func Debug(v any) {
switch v := v.(type) {
case *http.Request:
if v == nil {
return
}
if v.ContentLength != 0 {
b, err := io.ReadAll(v.Body)
if err != nil {
panic(err)
}
v.Body = io.NopCloser(bytes.NewReader(b))
log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b)
} else {
log.Printf("[homekit] request: %s %s <nobody>", v.Method, v.RequestURI)
}
case *http.Response:
if v == nil {
return
}
if v.Header.Get("Content-Type") == "image/jpeg" {
log.Printf("[homekit] response: %d <jpeg>", v.StatusCode)
return
}
if v.ContentLength != 0 {
b, err := io.ReadAll(v.Body)
if err != nil {
panic(err)
}
v.Body = io.NopCloser(bytes.NewReader(b))
log.Printf("[homekit] response: %s %d\n%s", v.Proto, v.StatusCode, b)
} else {
log.Printf("[homekit] response: %s %d <nobody>", v.Proto, v.StatusCode)
}
}
}
+7 -19
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"math/rand" "math/rand"
"net" "net"
"net/url"
"time" "time"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
@@ -22,36 +21,25 @@ type Client struct {
hap *hap.Client hap *hap.Client
srtp *srtp.Server srtp *srtp.Server
videoConfig camera.SupportedVideoStreamConfig videoConfig camera.SupportedVideoStreamConfiguration
audioConfig camera.SupportedAudioStreamConfig audioConfig camera.SupportedAudioStreamConfiguration
videoSession *srtp.Session videoSession *srtp.Session
audioSession *srtp.Session audioSession *srtp.Session
stream *camera.Stream stream *camera.Stream
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) { func Dial(rawURL string, server *srtp.Server) (*Client, error) {
u, err := url.Parse(rawURL) conn, err := hap.Dial(rawURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
query := u.Query()
conn := &hap.Client{
DeviceAddress: u.Host,
DeviceID: query.Get("device_id"),
DevicePublic: hap.DecodeKey(query.Get("device_public")),
ClientID: query.Get("client_id"),
ClientPrivate: hap.DecodeKey(query.Get("client_private")),
}
if err = conn.Dial(); err != nil {
return nil, err
}
client := &Client{ client := &Client{
Connection: core.Connection{ Connection: core.Connection{
ID: core.NewID(), ID: core.NewID(),
@@ -129,7 +117,7 @@ func (c *Client) Start() error {
} }
videoTrack := c.trackByKind(core.KindVideo) videoTrack := c.trackByKind(core.KindVideo)
videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0]) videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0], c.MaxWidth, c.MaxHeight)
audioTrack := c.trackByKind(core.KindAudio) audioTrack := c.trackByKind(core.KindAudio)
audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0]) audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0])
+33 -27
View File
@@ -4,31 +4,30 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"time"
"github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/camera" "github.com/AlexxIT/go2rtc/pkg/hap/camera"
"github.com/AlexxIT/go2rtc/pkg/hap/hds" "github.com/AlexxIT/go2rtc/pkg/hap/hds"
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
) )
func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFunc { type ServerProxy interface {
ServerPair
AddConn(conn any)
DelConn(conn any)
}
func ProxyHandler(srv ServerProxy, acc net.Conn) HandlerFunc {
return func(con net.Conn) error { return func(con net.Conn) error {
defer con.Close() defer con.Close()
acc, err := dial()
if err != nil {
return err
}
defer acc.Close()
pr := &Proxy{ pr := &Proxy{
con: con.(*secure.Conn), con: con.(*hap.Conn),
acc: acc.(*secure.Conn), acc: acc.(*hap.Conn),
res: make(chan *http.Response), res: make(chan *http.Response),
} }
@@ -36,17 +35,17 @@ func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFun
go pr.handleAcc() go pr.handleAcc()
// controller => accessory // controller => accessory
return pr.handleCon(pair) return pr.handleCon(srv)
} }
} }
type Proxy struct { type Proxy struct {
con *secure.Conn con *hap.Conn
acc *secure.Conn acc *hap.Conn
res chan *http.Response res chan *http.Response
} }
func (p *Proxy) handleCon(pair ServerPair) error { func (p *Proxy) handleCon(srv ServerProxy) error {
var hdsCharIID uint64 var hdsCharIID uint64
rd := bufio.NewReader(p.con) rd := bufio.NewReader(p.con)
@@ -61,7 +60,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
switch { switch {
case req.Method == "POST" && req.URL.Path == hap.PathPairings: case req.Method == "POST" && req.URL.Path == hap.PathPairings:
var res *http.Response var res *http.Response
if res, err = handlePairings(p.con, req, pair); err != nil { if res, err = handlePairings(req, srv); err != nil {
return err return err
} }
if err = res.Write(p.con); err != nil { if err = res.Write(p.con); err != nil {
@@ -74,7 +73,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
_ = json.Unmarshal(body, &v) _ = json.Unmarshal(body, &v)
for _, char := range v.Value { for _, char := range v.Value {
if char.IID == hdsCharIID { if char.IID == hdsCharIID {
var hdsReq camera.SetupDataStreamRequest var hdsReq camera.SetupDataStreamTransportRequest
_ = tlv8.UnmarshalBase64(char.Value, &hdsReq) _ = tlv8.UnmarshalBase64(char.Value, &hdsReq)
hdsConSalt = hdsReq.ControllerKeySalt hdsConSalt = hdsReq.ControllerKeySalt
break break
@@ -110,14 +109,14 @@ func (p *Proxy) handleCon(pair ServerPair) error {
_ = json.Unmarshal(body, &v) _ = json.Unmarshal(body, &v)
for i, char := range v.Value { for i, char := range v.Value {
if char.IID == hdsCharIID { if char.IID == hdsCharIID {
var hdsRes camera.SetupDataStreamResponse var hdsRes camera.SetupDataStreamTransportResponse
_ = tlv8.UnmarshalBase64(char.Value, &hdsRes) _ = tlv8.UnmarshalBase64(char.Value, &hdsRes)
hdsAccSalt := hdsRes.AccessoryKeySalt hdsAccSalt := hdsRes.AccessoryKeySalt
hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort) hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort)
// swtich accPort to conPort // swtich accPort to conPort
hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt) hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt)
if err != nil { if err != nil {
return err return err
} }
@@ -149,7 +148,7 @@ func (p *Proxy) handleAcc() error {
} }
if res.Proto == hap.ProtoEvent { if res.Proto == hap.ProtoEvent {
if err = res.Write(p.con); err != nil { if err = hap.WriteEvent(p.con, res); err != nil {
return err return err
} }
continue continue
@@ -166,7 +165,8 @@ func (p *Proxy) handleAcc() error {
} }
} }
func (p *Proxy) listenHDS(accPort int, salt string) (int, error) { func (p *Proxy) listenHDS(srv ServerProxy, accPort int, salt string) (int, error) {
// The TCP port range for HDS must be >= 32768.
ln, err := net.ListenTCP("tcp", nil) ln, err := net.ListenTCP("tcp", nil)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -175,30 +175,36 @@ func (p *Proxy) listenHDS(accPort int, salt string) (int, error) {
go func() { go func() {
defer ln.Close() defer ln.Close()
_ = ln.SetDeadline(time.Now().Add(30 * time.Second))
// raw controller conn // raw controller conn
con, err := ln.Accept() conn1, err := ln.Accept()
if err != nil { if err != nil {
return return
} }
defer con.Close()
defer conn1.Close()
// secured controller conn (controlle=false because we are accessory) // secured controller conn (controlle=false because we are accessory)
con, err = hds.Client(con, p.con.SharedKey, salt, false) con, err := hds.NewConn(conn1, p.con.SharedKey, salt, false)
if err != nil { if err != nil {
return return
} }
srv.AddConn(con)
defer srv.DelConn(con)
accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP
// raw accessory conn // raw accessory conn
acc, err := net.Dial("tcp", fmt.Sprintf("%s:%d", accIP, accPort)) conn2, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: accIP, Port: accPort})
if err != nil { if err != nil {
return return
} }
defer acc.Close() defer conn2.Close()
// secured accessory conn (controller=true because we are controller) // secured accessory conn (controller=true because we are controller)
acc, err = hds.Client(acc, p.acc.SharedKey, salt, true) acc, err := hds.NewConn(conn2, p.acc.SharedKey, salt, true)
if err != nil { if err != nil {
return return
} }
+12 -47
View File
@@ -15,15 +15,17 @@ import (
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
) )
type HandlerFunc func(net.Conn) error
type Server interface { type Server interface {
ServerPair ServerPair
ServerAccessory ServerAccessory
} }
type ServerPair interface { type ServerPair interface {
GetPair(conn net.Conn, id string) []byte GetPair(id string) []byte
AddPair(conn net.Conn, id string, public []byte, permissions byte) AddPair(id string, public []byte, permissions byte)
DelPair(conn net.Conn, id string) DelPair(id string)
} }
type ServerAccessory interface { type ServerAccessory interface {
@@ -33,11 +35,11 @@ type ServerAccessory interface {
GetImage(conn net.Conn, width, height int) []byte GetImage(conn net.Conn, width, height int) []byte
} }
func ServerHandler(server Server) hap.HandlerFunc { func ServerHandler(server Server) HandlerFunc {
return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) { return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) {
switch req.URL.Path { switch req.URL.Path {
case hap.PathPairings: case hap.PathPairings:
return handlePairings(conn, req, server) return handlePairings(req, server)
case hap.PathAccessories: case hap.PathAccessories:
body := hap.JSONAccessories{Value: server.GetAccessories(conn)} body := hap.JSONAccessories{Value: server.GetAccessories(conn)}
@@ -103,7 +105,7 @@ func ServerHandler(server Server) hap.HandlerFunc {
}) })
} }
func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) hap.HandlerFunc { func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) HandlerFunc {
return func(conn net.Conn) error { return func(conn net.Conn) error {
rw := bufio.NewReaderSize(conn, 16*1024) rw := bufio.NewReaderSize(conn, 16*1024)
wr := bufio.NewWriterSize(conn, 16*1024) wr := bufio.NewWriterSize(conn, 16*1024)
@@ -130,7 +132,7 @@ func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response
} }
} }
func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Response, error) { func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) {
cmd := struct { cmd := struct {
Method byte `tlv8:"0"` Method byte `tlv8:"0"`
Identifier string `tlv8:"1"` Identifier string `tlv8:"1"`
@@ -139,15 +141,15 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re
Permissions byte `tlv8:"11"` Permissions byte `tlv8:"11"`
}{} }{}
if err := tlv8.UnmarshalReader(req.Body, &cmd); err != nil { if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &cmd); err != nil {
return nil, err return nil, err
} }
switch cmd.Method { switch cmd.Method {
case 3: // add case 3: // add
pair.AddPair(conn, cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions) srv.AddPair(cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions)
case 4: // delete case 4: // delete
pair.DelPair(conn, cmd.Identifier) srv.DelPair(cmd.Identifier)
} }
body := struct { body := struct {
@@ -190,40 +192,3 @@ func makeResponse(mime string, v any) (*http.Response, error) {
} }
return res, nil return res, nil
} }
//func debug(v any) {
// switch v := v.(type) {
// case *http.Request:
// if v == nil {
// return
// }
// if v.ContentLength != 0 {
// b, err := io.ReadAll(v.Body)
// if err != nil {
// panic(err)
// }
// v.Body = io.NopCloser(bytes.NewReader(b))
// log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b)
// } else {
// log.Printf("[homekit] request: %s %s <nobody>", v.Method, v.RequestURI)
// }
// case *http.Response:
// if v == nil {
// return
// }
// if v.Header.Get("Content-Type") == "image/jpeg" {
// log.Printf("[homekit] response: %d <jpeg>", v.StatusCode)
// return
// }
// if v.ContentLength != 0 {
// b, err := io.ReadAll(v.Body)
// if err != nil {
// panic(err)
// }
// v.Body = io.NopCloser(bytes.NewReader(b))
// log.Printf("[homekit] response: %d\n%s", v.StatusCode, b)
// } else {
// log.Printf("[homekit] response: %d <nobody>", v.StatusCode)
// }
// }
//}
+2 -1
View File
@@ -330,6 +330,7 @@ const (
StreamTypeH264 = 0x1B StreamTypeH264 = 0x1B
StreamTypeH265 = 0x24 StreamTypeH265 = 0x24
StreamTypePCMATapo = 0x90 StreamTypePCMATapo = 0x90
StreamTypePCMUTapo = 0x91
StreamTypePrivateOPUS = 0xEB StreamTypePrivateOPUS = 0xEB
) )
@@ -392,7 +393,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) {
//p.Timestamp += aac.RTPTimeSize(pkt.Payload) // update next timestamp! //p.Timestamp += aac.RTPTimeSize(pkt.Payload) // update next timestamp!
case StreamTypePCMATapo: case StreamTypePCMATapo, StreamTypePCMUTapo:
p.Sequence++ p.Sequence++
pkt = &rtp.Packet{ pkt = &rtp.Packet{
+14 -61
View File
@@ -3,10 +3,9 @@ package onvif
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"html" "html"
"io" "io"
"net" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
@@ -32,26 +31,18 @@ func NewClient(rawURL string) (*Client, error) {
baseURL := "http://" + u.Host baseURL := "http://" + u.Host
client := &Client{url: u} client := &Client{url: u}
if u.Path == "" { client.deviceURL = baseURL + GetPath(u.Path, PathDevice)
client.deviceURL = baseURL + PathDevice
} else {
client.deviceURL = baseURL + u.Path
}
b, err := client.DeviceRequest(DeviceGetCapabilities) b, err := client.DeviceRequest(DeviceGetCapabilities)
if err != nil { if err != nil {
return nil, err return nil, err
} }
client.mediaURL = FindTagValue(b, "Media.+?XAddr") s := FindTagValue(b, "Media.+?XAddr")
if client.mediaURL == "" { client.mediaURL = baseURL + GetPath(s, "/onvif/media_service")
client.mediaURL = baseURL + "/onvif/media_service"
}
client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") s = FindTagValue(b, "Imaging.+?XAddr")
if client.imaginURL == "" { client.imaginURL = baseURL + GetPath(s, "/onvif/imaging_service")
client.imaginURL = baseURL + "/onvif/imaging_service"
}
return client, nil return client, nil
} }
@@ -183,62 +174,24 @@ func (c *Client) MediaRequest(operation string) ([]byte, error) {
return c.Request(c.mediaURL, operation) return c.Request(c.mediaURL, operation)
} }
func (c *Client) Request(rawUrl, body string) ([]byte, error) { func (c *Client) Request(url, body string) ([]byte, error) {
if rawUrl == "" { if url == "" {
return nil, errors.New("onvif: unsupported service") return nil, errors.New("onvif: unsupported service")
} }
e := NewEnvelopeWithUser(c.url.User) e := NewEnvelopeWithUser(c.url.User)
e.Append(body) e.Append(body)
u, err := url.Parse(rawUrl) client := &http.Client{Timeout: time.Second * 5000}
res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer res.Body.Close()
host := u.Host if res.StatusCode != http.StatusOK {
if u.Port() == "" { return nil, errors.New("onvif: wrong response " + res.Status)
host += ":80"
} }
conn, err := net.DialTimeout("tcp", host, 5*time.Second) return io.ReadAll(res.Body)
if err != nil {
return nil, err
}
defer conn.Close()
reqBody := e.Bytes()
rawReq := fmt.Appendf(nil, "POST %s HTTP/1.1\r\n"+
"Host: %s\r\n"+
"Content-Type: application/soap+xml;charset=utf-8\r\n"+
"Content-Length: %d\r\n"+
"Connection: close\r\n"+
"\r\n", u.Path, u.Host, len(reqBody))
rawReq = append(rawReq, reqBody...)
if _, err = conn.Write(rawReq); err != nil {
return nil, err
}
rawRes, err := io.ReadAll(conn)
if err != nil {
return nil, err
}
// Look for XML in complete response
if i := bytes.Index(rawRes, []byte("<?xml")); i > 0 {
return rawRes[i:], nil
}
// No XML found - might be an error response
if i := bytes.Index(rawRes, []byte("\r\n\r\n")); i > 0 {
if bytes.Contains(rawRes[:i], []byte("chunked")) {
return nil, errors.New("onvif: TODO: support chunked encoding")
}
// Return body after headers
return rawRes[i+4:], nil
}
return rawRes, nil
} }
+12
View File
@@ -3,6 +3,7 @@ package onvif
import ( import (
"fmt" "fmt"
"net" "net"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -129,3 +130,14 @@ func GetPosixTZ(current time.Time) string {
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60) return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
} }
func GetPath(urlOrPath, defPath string) string {
if urlOrPath == "" || urlOrPath[0] == '/' {
return defPath
}
u, err := url.Parse(urlOrPath)
if err != nil {
return defPath
}
return GetPath(u.Path, defPath)
}
-68
View File
@@ -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
}
+118
View File
@@ -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
}
+137
View File
@@ -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
//}
+31 -4
View File
@@ -20,6 +20,7 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mpegts" "github.com/AlexxIT/go2rtc/pkg/mpegts"
"github.com/AlexxIT/go2rtc/pkg/pcm"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/tcp"
) )
@@ -140,6 +141,12 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
username = "admin" username = "admin"
} }
if strings.Contains(exchange, `username="none"`) {
// https://nvd.nist.gov/vuln/detail/CVE-2022-37255
username = "none"
password = "TPL075526460603"
}
key := md5.Sum([]byte(nonce + ":" + password)) key := md5.Sum([]byte(nonce + ":" + password))
iv := md5.Sum([]byte(username + ":" + nonce)) iv := md5.Sum([]byte(username + ":" + nonce))
@@ -158,8 +165,9 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
cbc.CryptBlocks(b, b) cbc.CryptBlocks(b, b)
// unpad // unpad
padSize := int(b[len(b)-1]) n := len(b)
return b[:len(b)-padSize] padSize := int(b[n-1])
return b[:n-padSize]
} }
} }
@@ -178,6 +186,8 @@ func (c *Client) Handle() error {
rd := multipart.NewReader(c.conn1, "--device-stream-boundary--") rd := multipart.NewReader(c.conn1, "--device-stream-boundary--")
demux := mpegts.NewDemuxer() demux := mpegts.NewDemuxer()
var transcode func([]byte) []byte
for { for {
p, err := rd.NextRawPart() p, err := rd.NextRawPart()
if err != nil { if err != nil {
@@ -219,6 +229,23 @@ func (c *Client) Handle() error {
return err2 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 { for _, receiver := range c.receivers {
if receiver.ID == pkt.PayloadType { if receiver.ID == pkt.PayloadType {
mpegts.TimestampToRTP(pkt, receiver.Codec) mpegts.TimestampToRTP(pkt, receiver.Codec)
@@ -292,12 +319,12 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http.
return nil, nil, err return nil, nil, err
} }
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers _, _ = io.Copy(io.Discard, res.Body) // discard leftovers
_ = res.Body.Close() // ignore response body _ = res.Body.Close() // ignore response body
auth := res.Header.Get("WWW-Authenticate") auth := res.Header.Get("WWW-Authenticate")
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") { if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode) return nil, nil, errors.New("tapo: wrond status: " + res.Status)
} }
if brand == "tapo" && password == "" { if brand == "tapo" && password == "" {
+1 -5
View File
@@ -8,7 +8,6 @@ import (
"net" "net"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
) )
@@ -69,10 +68,7 @@ func Do(req *http.Request) (*http.Response, error) {
return tlsConn, err return tlsConn, err
} }
client = &http.Client{ client = &http.Client{Transport: transport}
Timeout: time.Second * 5000,
Transport: transport,
}
} }
user := req.URL.User user := req.URL.User
+9
View File
@@ -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/
+555
View File
@@ -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
}
+322
View File
@@ -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
}
+69
View File
@@ -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,
}
}
+270
View File
@@ -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
}
}
+436
View File
@@ -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
}
+597
View File
@@ -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
}
+2 -2
View File
@@ -63,12 +63,12 @@ func (c *Conn) SetAnswer(answer string) (err error) {
SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer), SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer),
} }
if err = c.pc.SetRemoteDescription(desc); err != nil { if err = c.pc.SetRemoteDescription(desc); err != nil {
return return err
} }
sd := &sdp.SessionDescription{} sd := &sdp.SessionDescription{}
if err = sd.Unmarshal([]byte(answer)); err != nil { if err = sd.Unmarshal([]byte(answer)); err != nil {
return return err
} }
c.Medias = UnmarshalMedias(sd.MediaDescriptions) c.Medias = UnmarshalMedias(sd.MediaDescriptions)
+10 -10
View File
@@ -161,16 +161,7 @@ func (c *Conn) AddCandidate(candidate string) error {
return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate})
} }
func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { func (c *Conn) GetSenderTrack(mid string) *Track {
for _, tr := range c.pc.GetTransceivers() {
if tr.Mid() == mid {
return tr
}
}
return nil
}
func (c *Conn) getSenderTrack(mid string) *Track {
if tr := c.getTranseiver(mid); tr != nil { if tr := c.getTranseiver(mid); tr != nil {
if s := tr.Sender(); s != nil { if s := tr.Sender(); s != nil {
if t := s.Track().(*Track); t != nil { if t := s.Track().(*Track); t != nil {
@@ -181,6 +172,15 @@ func (c *Conn) getSenderTrack(mid string) *Track {
return nil 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) { func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) {
for _, tr := range c.pc.GetTransceivers() { for _, tr := range c.pc.GetTransceivers() {
// search Transeiver for this TrackRemote // search Transeiver for this TrackRemote
+1 -1
View File
@@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
panic(core.Caller()) panic(core.Caller())
} }
localTrack := c.getSenderTrack(media.ID) localTrack := c.GetSenderTrack(media.ID)
if localTrack == nil { if localTrack == nil {
return errors.New("webrtc: can't get track") return errors.New("webrtc: can't get track")
} }
+13 -12
View File
@@ -185,8 +185,8 @@ func LookupIP(address string) (string, error) {
} }
// GetPublicIP example from https://github.com/pion/stun // GetPublicIP example from https://github.com/pion/stun
func GetPublicIP() (net.IP, error) { func GetPublicIP(address string) (net.IP, error) {
conn, err := net.Dial("udp", "stun.l.google.com:19302") conn, err := net.Dial("udp", address)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -225,18 +225,19 @@ func GetPublicIP() (net.IP, error) {
var cachedIP net.IP var cachedIP net.IP
var cachedTS time.Time var cachedTS time.Time
func GetCachedPublicIP() (net.IP, error) { func GetCachedPublicIP(stuns ...string) (net.IP, error) {
now := time.Now() if now := time.Now(); now.After(cachedTS) {
if now.After(cachedTS) { for _, addr := range stuns {
newIP, err := GetPublicIP() if ip, _ := GetPublicIP(addr); ip != nil {
if err == nil { cachedIP = ip
cachedIP = newIP cachedTS = now.Add(time.Minute * 5)
cachedTS = now.Add(time.Minute * 5) return ip, nil
} else if cachedIP == nil { }
return nil, err
} }
} }
if cachedIP == nil {
return nil, errors.New("webrtc: can't get public IP")
}
return cachedIP, nil return cachedIP, nil
} }
+71
View File
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More