diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 739c4e17..6015efa0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: { go-version: '1.24' } + with: { go-version: '1.25' } - name: Build go2rtc_win64 env: { GOOS: windows, GOARCH: amd64 } @@ -29,7 +29,7 @@ jobs: with: { name: go2rtc_win64, path: go2rtc.exe } - name: Build go2rtc_win32 - env: { GOOS: windows, GOARCH: 386, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: windows, GOARCH: 386 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_win32 uses: actions/upload-artifact@v4 @@ -85,7 +85,7 @@ jobs: with: { name: go2rtc_linux_mipsel, path: go2rtc } - name: Build go2rtc_mac_amd64 - env: { GOOS: darwin, GOARCH: amd64, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: darwin, GOARCH: amd64 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_mac_amd64 uses: actions/upload-artifact@v4 @@ -124,7 +124,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ github.repository }} + name=${{ github.repository }},enable=${{ github.event.repository.fork == false }} ghcr.io/${{ github.repository }} tags: | type=ref,event=branch @@ -138,14 +138,14 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - if: github.event_name != 'pull_request' + if: github.event_name == 'push' && github.event.repository.fork == false uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io @@ -156,6 +156,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: docker/Dockerfile platforms: | linux/amd64 linux/386 @@ -180,7 +181,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ github.repository }} + name=${{ github.repository }},enable=${{ github.event.repository.fork == false }} ghcr.io/${{ github.repository }} flavor: | suffix=-hardware,onlatest=true @@ -197,14 +198,14 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - if: github.event_name != 'pull_request' + if: github.event_name == 'push' && github.event.repository.fork == false uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - + - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io @@ -215,10 +216,65 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: hardware.Dockerfile + file: docker/hardware.Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-hw.outputs.tags }} labels: ${{ steps.meta-hw.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + docker-rockchip: + name: Build docker rockchip + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta-rk + uses: docker/metadata-action@v5 + with: + images: | + name=${{ github.repository }},enable=${{ github.event.repository.fork == false }} + ghcr.io/${{ github.repository }} + flavor: | + suffix=-rockchip,onlatest=true + latest=auto + tags: | + type=ref,event=branch + type=semver,pattern={{version}},enable=false + type=match,pattern=v(.*),group=1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + if: github.event_name == 'push' && github.event.repository.fork == false + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: docker/rockchip.Dockerfile + platforms: linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-rk.outputs.tags }} + labels: ${{ steps.meta-rk.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2089dec..5d9e7e25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,6 +79,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: docker/Dockerfile platforms: linux/${{ matrix.platform }} push: false load: true @@ -92,7 +93,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: hardware.Dockerfile + file: docker/hardware.Dockerfile platforms: linux/amd64 push: false load: true diff --git a/.gitignore b/.gitignore index 52fe9c86..8713e925 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ go2rtc_linux* go2rtc_mac* go2rtc_win* +/go2rtc + 0_test.go .DS_Store diff --git a/README.md b/README.md index 90a2537f..1416b810 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![goreport](https://goreportcard.com/badge/github.com/AlexxIT/go2rtc)](https://goreportcard.com/report/github.com/AlexxIT/go2rtc) -Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc. +Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc. ![](assets/go2rtc.png) @@ -20,11 +20,11 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg - [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram, etc.) - first project in the World with support streaming from [HomeKit Cameras](#source-homekit) - support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5)) -- on the fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) +- on-the-fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) - play audio files and live streams on some cameras with [speaker](#stream-to-camera) - multi-source 2-way [codecs negotiation](#codecs-negotiation) - 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 - streaming from private networks via [ngrok](#module-ngrok) - can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary) @@ -39,6 +39,9 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg - HomeKit Accessory Protocol from [@brutella](https://github.com/brutella/hap) - creator of the project's logo [@v_novoseltsev](https://www.instagram.com/v_novoseltsev) +> [!CAUTION] +> There is NO existing website for go2rtc project other than this GitHub repository. The website go2rtc[.]com is in no way associated with the authors of this project. + --- * [Fast start](#fast-start) @@ -69,12 +72,14 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg * [Source: Hass](#source-hass) * [Source: ISAPI](#source-isapi) * [Source: Nest](#source-nest) + * [Source: Ring](#source-ring) * [Source: Roborock](#source-roborock) * [Source: WebRTC](#source-webrtc) * [Source: WebTorrent](#source-webtorrent) * [Incoming sources](#incoming-sources) * [Stream to camera](#stream-to-camera) * [Publish stream](#publish-stream) + * [Preload stream](#preload-stream) * [Module: API](#module-api) * [Module: RTSP](#module-rtsp) * [Module: RTMP](#module-rtmp) @@ -116,7 +121,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): - `go2rtc_win64.zip` - Windows 10+ 64-bit -- `go2rtc_win32.zip` - Windows 7+ 32-bit +- `go2rtc_win32.zip` - Windows 10+ 32-bit - `go2rtc_win_arm64.zip` - Windows ARM 64-bit - `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_i386` - Linux 32-bit @@ -124,7 +129,7 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 - `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS) - `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero) - `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks)) -- `go2rtc_mac_amd64.zip` - macOS 10.13+ Intel 64-bit +- `go2rtc_mac_amd64.zip` - macOS 11+ Intel 64-bit - `go2rtc_mac_arm64.zip` - macOS ARM 64-bit - `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit - `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit @@ -182,11 +187,11 @@ Available modules: ### Module: Streams -**go2rtc** support different stream source types. You can config one or multiple links of any type as stream source. +**go2rtc** supports different stream source types. You can config one or multiple links of any type as a stream source. Available source types: -- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two way audio](#two-way-audio) support +- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two-way audio](#two-way-audio) support - [rtmp](#source-rtmp) - `RTMP` streams - [http](#source-http) - `HTTP-FLV`, `MPEG-TS`, `JPEG` (snapshots), `MJPEG` streams - [onvif](#source-onvif) - get camera `RTSP` link and snapshot link using `ONVIF` protocol @@ -199,20 +204,21 @@ Available source types: - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support +- [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support - [kasa](#source-tapo) - TP-Link Kasa cameras - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service - [hass](#source-hass) - Home Assistant integration -- [isapi](#source-isapi) - two way audio for Hikvision (ISAPI) cameras +- [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras - [roborock](#source-roborock) - Roborock vacuums with cameras - [webrtc](#source-webrtc) - WebRTC/WHEP sources - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc Read more about [incoming sources](#incoming-sources) -#### Two way audio +#### Two-way audio -Supported for sources: +Supported sources: - [RTSP cameras](#source-rtsp) with [ONVIF Profile T](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) (back channel connection) - [DVRIP](#source-dvrip) cameras @@ -220,11 +226,12 @@ Supported for sources: - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras - [Exec](#source-exec) audio on server +- [Ring](#source-ring) cameras - [Any Browser](#incoming-browser) as IP-camera -Two way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). +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)). -go2rtc also support [play audio](#stream-to-camera) files and live streams on this cameras. +go2rtc also supports [play audio](#stream-to-camera) files and live streams on this cameras. #### Source: RTSP @@ -242,13 +249,13 @@ streams: **Recommendations** -- **Amcrest Doorbell** users may want to disable two way audio, because with an active stream you won't have a call button working. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file +- **Amcrest Doorbell** users may want to disable two-way audio, because with an active stream, you won't have a working call button. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file - **Dahua Doorbell** users may want to change [audio codec](https://github.com/AlexxIT/go2rtc/issues/49#issuecomment-2127107379) for proper 2-way audio. Make sure not to request backchannel multiple times by adding `#backchannel=0` to other stream sources of the same doorbell. The `unicast=true&proto=Onvif` is preferred for 2-way audio as this makes the doorbell accept multiple codecs for the incoming audio -- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful unusable stream implementation +- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful, unusable stream implementation - **Ubiquiti UniFi** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81) - **TP-Link Tapo** users may skip login and password, because go2rtc support login [without them](https://drmnsamoliu.github.io/video.html) -- If your camera has two RTSP links - you can add both of them as sources. This is useful when streams has different codecs, as example AAC audio with main stream and PCMU/PCMA audio with second stream -- If the stream from your camera is glitchy, try using [ffmpeg source](#source-ffmpeg). It will not add CPU load if you won't use transcoding +- If your camera has two RTSP links, you can add both as sources. This is useful when streams have different codecs, for example AAC audio with main stream and PCMU/PCMA audio with second stream +- If the stream from your camera is glitchy, try using [ffmpeg source](#source-ffmpeg). It will not add CPU load if you don't use transcoding - If the stream from your camera is very glitchy, try to use transcoding with [ffmpeg source](#source-ffmpeg) **Other options** @@ -257,7 +264,7 @@ Format: `rtsp...#{param1}#{param2}#{param3}` - Add custom timeout `#timeout=30` (in seconds) - Ignore audio - `#media=video` or ignore video - `#media=audio` -- Ignore two way audio API `#backchannel=0` - important for some glitchy cameras +- Ignore two-way audio API `#backchannel=0` - important for some glitchy cameras - Use WebSocket transport `#transport=ws...` **RTSP over WebSocket** @@ -272,7 +279,7 @@ streams: #### Source: RTMP -You can get stream from RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). +You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). ```yaml streams: @@ -288,7 +295,7 @@ Support Content-Type: - **HTTP-MJPEG** (`multipart/x`) - simple MJPEG stream over HTTP - **MPEG-TS** (`video/mpeg`) - legacy [streaming format](https://en.wikipedia.org/wiki/MPEG_transport_stream) -Source also support HTTP and TCP streams with autodetection for different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. +Source also supports HTTP and TCP streams with autodetection for different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. ```yaml streams: @@ -308,7 +315,7 @@ streams: custom_header: "https://mjpeg.sanford.io/count.mjpeg#header=Authorization: Bearer XXX" ``` -**PS.** Dahua camera has bug: if you select MJPEG codec for RTSP second stream - snapshot won't work. +**PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work. #### Source: ONVIF @@ -316,7 +323,7 @@ streams: The source is not very useful if you already know RTSP and snapshot links for your camera. But it can be useful if you don't. -**WebUI > Add** webpage support ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use docker, you must use "network host". +**WebUI > Add** webpage support ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use Docker, you must use "network host". ```yaml streams: @@ -327,7 +334,7 @@ streams: #### Source: FFmpeg -You can get any stream or file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. +You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. - FFmpeg preistalled for **Docker** and **Hass Add-on** users - **Hass Add-on** users can target files from [/media](https://www.home-assistant.io/more-info/local-media/setup-media/) folder @@ -342,7 +349,7 @@ streams: # [FILE] video will be transcoded to H264, audio will be skipped file2: ffmpeg:/media/BigBuckBunny.mp4#video=h264 - # [FILE] video will be copied, audio will be transcoded to pcmu + # [FILE] video will be copied, audio will be transcoded to PCMU file3: ffmpeg:/media/BigBuckBunny.mp4#video=copy#audio=pcmu # [HLS] video will be copied, audio will be skipped @@ -355,9 +362,9 @@ streams: rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 ``` -All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. +All transcoding formats have [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. -But you can override them via YAML config. You can also add your own formats to config and use them with source params. +But you can override them via YAML config. You can also add your own formats to the config and use them with source params. ```yaml ffmpeg: @@ -385,12 +392,12 @@ Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/H #### Source: FFmpeg Device -You can get video from any USB-camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. +You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. -- check available devices in Web interface +- check available devices in web interface - `video_size` and `framerate` must be supported by your camera! - for Linux supported only video for now -- for macOS you can stream Facetime camera or whole Desktop! +- for macOS you can stream FaceTime camera or whole desktop! - for macOS important to set right framerate Format: `ffmpeg:device?{input-params}#{param1}#{param2}#{param3}` @@ -408,7 +415,7 @@ streams: Exec source can run any external application and expect data from it. Two transports are supported - **pipe** (*from [v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*) and **RTSP**. -If you want to use **RTSP** transport - the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server. +If you want to use **RTSP** transport, the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server. **pipe** reads data from app stdout in different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. Also pipe can write data to app stdin in two formats: **PCMA** and **PCM/48000**. @@ -418,11 +425,11 @@ The source can be used with: - [FFplay](https://ffmpeg.org/ffplay.html) - play audio on your server - [GStreamer](https://gstreamer.freedesktop.org/) - [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/computers/camera_software.html) -- any your own software +- any of your own software Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`): -- `killsignal` - signal which will be send to stop the process (numeric form) +- `killsignal` - signal which will be sent to stop the process (numeric form) - `killtimeout` - time in seconds for forced termination with sigkill - `backchannel` - enable backchannel for two-way audio @@ -439,7 +446,7 @@ streams: #### Source: Echo -Some sources may have a dynamic link. And you will need to get it using a bash or python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). +Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). **Docker** and **Hass Add-on** users has preinstalled `python3`, `curl`, `jq`. @@ -461,20 +468,20 @@ Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/ex **Important:** - You can use HomeKit Cameras **without Apple devices** (iPhone, iPad, etc.), it's just a yet another protocol -- HomeKit device can be paired with only one ecosystem. So, if you have paired it to an iPhone (Apple Home) - you can't pair it with Home Assistant or go2rtc. Or if you have paired it to go2rtc - you can't pair it with iPhone -- HomeKit device should be in same network with working [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS) between device and go2rtc +- HomeKit device can be paired with only one ecosystem. So, if you have paired it to an iPhone (Apple Home), you can't pair it with Home Assistant or go2rtc. Or if you have paired it to go2rtc, you can't pair it with an iPhone +- HomeKit device should be on the same network with working [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS) between the device and go2rtc -go2rtc support import paired HomeKit devices from [Home Assistant](#source-hass). So you can use HomeKit camera with Hass and go2rtc simultaneously. If you using Hass, I recommend pairing devices with it, it will give you more options. +go2rtc supports importing paired HomeKit devices from [Home Assistant](#source-hass). So you can use HomeKit camera with Hass and go2rtc simultaneously. If you are using Hass, I recommend pairing devices with it; it will give you more options. -You can pair device with go2rtc on the HomeKit page. If you can't see your devices - reload the page. Also try reboot your HomeKit device (power off). If you still can't see it - you have a problems with mDNS. +You can pair device with go2rtc on the HomeKit page. If you can't see your devices, reload the page. Also, try rebooting your HomeKit device (power off). If you still can't see it, you have a problem with mDNS. -If you see a device but it does not have a pair button - it is paired to some ecosystem (Apple Home, Home Assistant, HomeBridge etc). You need to delete device from that ecosystem, and it will be available for pairing. If you cannot unpair device, you will have to reset it. +If you see a device but it does not have a pairing button, it is paired to some ecosystem (Apple Home, Home Assistant, HomeBridge etc). You need to delete the device from that ecosystem, and it will be available for pairing. If you cannot unpair the device, you will have to reset it. **Important:** -- HomeKit audio uses very non-standard **AAC-ELD** codec with very non-standard params and specification violation +- HomeKit audio uses very non-standard **AAC-ELD** codec with very non-standard params and specification violations - Audio can't be played in `VLC` and probably any other player -- Audio should be transcoded for using with MSE, WebRTC, etc. +- Audio should be transcoded for use with MSE, WebRTC, etc. Recommended settings for using HomeKit Camera with WebRTC, MSE, MP4, RTSP: @@ -496,7 +503,7 @@ RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g Other names: [ESeeCloud](http://www.eseecloud.com/), [dvr163](http://help.dvr163.com/). - you can skip `username`, `password`, `port`, `ch` and `stream` if they are default -- setup separate streams for different channels and streams +- set up separate streams for different channels and streams ```yaml streams: @@ -510,7 +517,7 @@ streams: Other names: DVR-IP, NetSurveillance, Sofia protocol (NETsurveillance ActiveX plugin XMeye SDK). - you can skip `username`, `password`, `port`, `channel` and `subtype` if they are default -- setup separate streams for different channels +- set up separate streams for different channels - use `subtype=0` for Main stream, and `subtype=1` for Extra1 stream - only the TCP protocol is supported @@ -531,8 +538,8 @@ streams: - stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/) - use the **cloud password**, this is not the RTSP password! you do not need to add a login! -- you can also use UPPERCASE MD5 hash from your cloud password with `admin` username -- some new camera firmwares requires SHA256 instead of MD5 +- you can also use **UPPERCASE** MD5 hash from your cloud password with `admin` username +- some new camera firmwares require SHA256 instead of MD5 ```yaml streams: @@ -542,6 +549,10 @@ streams: camera2: tapo://admin:UPPERCASE-MD5@192.168.1.123 # admin username and UPPERCASE SHA256 cloud-password hash camera3: tapo://admin:UPPERCASE-SHA256@192.168.1.123 + # VGA stream (the so called substream, the lower resolution one) + camera4: tapo://cloud-password@192.168.1.123?subtype=1 + # HD stream (default) + camera5: tapo://cloud-password@192.168.1.123?subtype=0 ``` ```bash @@ -573,7 +584,7 @@ Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or #### Source: Ivideon -Support public cameras from service [Ivideon](https://tv.ivideon.com/). +Support public cameras from the service [Ivideon](https://tv.ivideon.com/). ```yaml streams: @@ -591,7 +602,7 @@ Support import camera links from [Home Assistant](https://www.home-assistant.io/ ```yaml hass: - config: "/config" # skip this setting if you Hass Add-on user + config: "/config" # skip this setting if you Hass add-on user streams: generic_camera: hass:Camera1 # Settings > Integrations > Integration Name @@ -600,9 +611,9 @@ streams: **WebRTC Cameras** (*from [v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)*) -Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this fomat. +Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this format. -**Important.** The Nest API only allows you to get a link to a stream for 5 minutes. Do not use this with Frigate! If the stream expires, Frigate will consume all available ram on your machine within seconds. It's recommended to use [Nest source](#source-nest) - it supports extending the stream. +**Important.** The Nest API only allows you to get a link to a stream for 5 minutes. Do not use this with Frigate! If the stream expires, Frigate will consume all available RAM on your machine within seconds. It's recommended to use [Nest source](#source-nest) - it supports extending the stream. ```yaml streams: @@ -614,13 +625,13 @@ streams: **RTSP Cameras** -By default, the Home Assistant API does not allow you to get dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). +By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others, can also be imported using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). #### Source: ISAPI *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This source type support only backchannel audio for Hikvision ISAPI protocol. So it should be used as second source in addition to the RTSP protocol. +This source type supports only backchannel audio for the Hikvision ISAPI protocol. So it should be used as a second source in addition to the RTSP protocol. ```yaml streams: @@ -633,42 +644,52 @@ streams: *[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)* -Currently only WebRTC cameras are supported. +Currently, only WebRTC cameras are supported. -For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](#source-hass). But if you can somehow get the below parameters - Nest/WebRTC source will work without Hass. +For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](#source-hass). But if you can somehow get the below parameters, Nest/WebRTC source will work without Hass. ```yaml streams: nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** ``` +#### Source: Ring + +This source type support Ring cameras with [two way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. + +```yaml +streams: + ring: ring:?device_id=XXX&refresh_token=XXX + ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot +``` + #### Source: Roborock *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This source type support Roborock vacuums with cameras. Known working models: +This source type supports Roborock vacuums with cameras. Known working models: - Roborock S6 MaxV - only video (the vacuum has no microphone) -- Roborock S7 MaxV - video and two way audio -- Roborock Qrevo MaxV - video and two way audio +- Roborock S7 MaxV - video and two-way audio +- Roborock Qrevo MaxV - video and two-way audio -Source support load Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config. +Source supports loading Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config. -If you have graphic pin for your vacuum - add it as 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: WebRTC *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This source type support four connection formats. +This source type supports four connection formats. **whep** -[WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) - is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc. +[WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc. **go2rtc** -This format is only supported in go2rtc. Unlike WHEP it supports asynchronous WebRTC connection and two way audio. +This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous WebRTC connections and two-way audio. **openipc** (*from [v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)*) @@ -676,15 +697,15 @@ Support connection to [OpenIPC](https://openipc.org/) cameras. **wyze** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. +Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use the [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. **kinesis** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify signalling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer). +Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify the signalling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer). **switchbot** -Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available. +Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k) and [Smart Video Doorbell](https://www.switchbot.jp/products/switchbot-smart-video-doorbell). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available. ```yaml streams: @@ -693,10 +714,10 @@ streams: webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}] webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}] - webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#client_id=...#ice_servers=[{...},{...}] + webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#play_type=0#client_id=...#ice_servers=[{...},{...}] ``` -**PS.** For `kinesis` sources you can use [echo](#source-echo) to get connection params using `bash`/`python` or any other script language. +**PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language. #### Source: WebTorrent @@ -715,9 +736,9 @@ By default, go2rtc establishes a connection to the source when any client reques - Go2rtc also can accepts incoming sources in [RTSP](#module-rtsp), [RTMP](#module-rtmp), [HTTP](#source-http) and **WebRTC/WHIP** formats - Go2rtc won't stop such a source if it has no clients -- You can push data only to existing stream (create stream with empty source in config) -- You can push multiple incoming sources to same stream -- You can push data to non empty stream, so it will have additional codecs inside +- You can push data only to an existing stream (create a stream with empty source in config) +- You can push multiple incoming sources to the same stream +- You can push data to a non-empty stream, so it will have additional codecs inside **Examples** @@ -742,11 +763,11 @@ By default, go2rtc establishes a connection to the source when any client reques *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -You can turn the browser of any PC or mobile into an IP-camera with support video and two way audio. Or even broadcast your PC screen: +You can turn the browser of any PC or mobile into an IP camera with support for video and two-way audio. Or even broadcast your PC screen: 1. Create empty stream in the `go2rtc.yaml` 2. Go to go2rtc WebUI -3. Open `links` page for you stream +3. Open `links` page for your stream 4. Select `camera+microphone` or `display+speaker` option 5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](#module-webtorrent) technology (work over HTTPS by default) @@ -762,7 +783,7 @@ You can use **OBS Studio** or any other broadcast software with [WHIP](https://w *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -go2rtc support play audio files (ex. music or [TTS](https://www.home-assistant.io/integrations/#text-to-speech)) and live streams (ex. radio) on cameras with [two way audio](#two-way-audio) support (RTSP/ONVIF cameras, TP-Link Tapo, Hikvision ISAPI, Roborock vacuums, any Browser). +go2rtc supports playing audio files (ex. music or [TTS](https://www.home-assistant.io/integrations/#text-to-speech)) and live streams (ex. radio) on cameras with [two-way audio](#two-way-audio) support (RTSP/ONVIF cameras, TP-Link Tapo, Hikvision ISAPI, Roborock vacuums, any Browser). API example: @@ -775,7 +796,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com - you can check camera codecs on the go2rtc WebUI info page when the stream is active - some cameras support only low quality `PCMA/8000` codec (ex. [Tapo](#source-tapo)) - it is recommended to choose higher quality formats if your camera supports them (ex. `PCMA/48000` for some Dahua cameras) -- if you play files over http-link, you need to add `#input=file` params for transcoding, so file will be transcoded and played in real time +- if you play files over `http` link, you need to add `#input=file` params for transcoding, so the file will be transcoded and played in real time - if you play live streams, you should skip `#input` param, because it is already in real time - you can stop active playback by calling the API with the empty `src` parameter - you will see one active producer and one active consumer in go2rtc WebUI info page during streaming @@ -787,10 +808,10 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important: - Supported codecs: H264 for video and AAC for audio -- AAC audio is required for YouTube, videos without audio will not work +- AAC audio is required for YouTube; videos without audio will not work - You don't need to enable [RTMP module](#module-rtmp) listening for this task -You can use API: +You can use the API: ``` POST http://localhost:1984/api/streams?src=camera1&dst=rtmps://... @@ -818,11 +839,31 @@ streams: - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. - **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. +### Preload stream + +You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. + +```yaml +preload: + camera1: # default: video&audio = ANY + camera2: "video" # preload only video track + camera3: "video=h264&audio=opus" # preload H264 video and OPUS audio + +streams: + camera1: + - rtsp://192.168.1.100/stream + camera2: + - rtsp://192.168.1.101/stream + camera3: + - rtsp://192.168.1.102/h265stream + - ffmpeg:camera3#video=h264#audio=opus#hardware +``` + ### Module: API The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. -**Important!** go2rtc passes requests from localhost and from unix socket without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to API. If not properly configured, an attacker can gain access to your cameras and even your server. +**Important!** go2rtc passes requests from localhost and from Unix sockets without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server. [API description](https://github.com/AlexxIT/go2rtc/tree/master/api). @@ -830,7 +871,7 @@ The HTTP API is the main part for interacting with the application. Default addr - you can disable HTTP API with `listen: ""` and use, for example, only RTSP client/server protocol - you can enable HTTP API only on localhost with `listen: "127.0.0.1:1984"` setting -- you can change API `base_path` and host go2rtc on your main app webserver suburl +- you can change the API `base_path` and host go2rtc on your main app webserver suburl - all files from `static_dir` hosted on root path: `/` - you can use raw TLS cert/key content or path to files @@ -839,7 +880,7 @@ api: listen: ":1984" # default ":1984", HTTP API port ("" - disabled) username: "admin" # default "", Basic auth for WebUI password: "pass" # default "", Basic auth for WebUI - base_path: "/rtc" # default "", API prefix for serve 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) origin: "*" # default "", allow CORS requests (only * supported) tls_listen: ":443" # default "", enable HTTPS server @@ -863,7 +904,7 @@ api: You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}` -You can enable external password protection for your RTSP streams. Password protection always disabled for localhost calls (ex. FFmpeg or Hass on same server). +You can enable external password protection for your RTSP streams. Password protection is always disabled for localhost calls (ex. FFmpeg or Hass on the same server). ```yaml rtsp: @@ -888,7 +929,7 @@ Read more about [codecs filters](#codecs-filters). You can get any stream as RTMP-stream: `rtmp://192.168.1.123/{stream_name}`. Only H264/AAC codecs supported right now. -[Incoming stream](#incoming-sources) in RTMP-format tested only with [OBS Studio](https://obsproject.com/) and Dahua camera. Different FFmpeg versions has different problems with this format. +[Incoming stream](#incoming-sources) in RTMP format tested only with [OBS Studio](https://obsproject.com/) and a Dahua camera. Different FFmpeg versions have different problems with this format. ```yaml rtmp: @@ -897,12 +938,12 @@ rtmp: ### Module: WebRTC -In most cases [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. +In most cases, [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses a direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. It **can't pass** media data through your Nginx or Cloudflare or [Nabu Casa](https://www.nabucasa.com/) HTTP TCP connection! -It can automatically detects your external IP via public [STUN](https://en.wikipedia.org/wiki/STUN) server. -It can establish a external direct connection via [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology even if you not open your server to the World. +It can automatically detect your external IP via a public [STUN](https://en.wikipedia.org/wiki/STUN) server. +It can establish an external direct connection via [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology even if you do not open your server to the World. -But about 10-20% of users may need to configure additional settings for external access if **mobile phone** or **go2rtc server** behing [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/). +But about 10-20% of users may need to configure additional settings for external access if **mobile phone** or **go2rtc server** is behind [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/). - by default, WebRTC uses both TCP and UDP on port 8555 for connections - you can use this port for external access @@ -915,25 +956,25 @@ webrtc: **Static public IP** -- forward the port 8555 on your router (you can use same 8555 port or any other as external port) -- add your external IP-address and external port to YAML config +- forward the port 8555 on your router (you can use the same 8555 port or any other as external port) +- add your external IP address and external port to the YAML config ```yaml webrtc: candidates: - - 216.58.210.174:8555 # if you have static public IP-address + - 216.58.210.174:8555 # if you have a static public IP address ``` **Dynamic public IP** -- forward the port 8555 on your router (you can use same 8555 port or any other as the external port) +- forward the port 8555 on your router (you can use the same 8555 port or any other as the external port) - add `stun` word and external port to YAML config - - go2rtc automatically detects your external address with STUN-server + - go2rtc automatically detects your external address with STUN server ```yaml webrtc: candidates: - - stun:8555 # if you have dynamic public IP-address + - stun:8555 # if you have a dynamic public IP address ``` **Private IP** @@ -947,7 +988,7 @@ ngrok: **Hard tech way 1. Own TCP-tunnel** -If you have personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create TCP-tunnel and setup in the same way as "Static public IP". But use your VPS IP-address in YAML config. +If you have a personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create a TCP tunnel and setup in the same way as "Static public IP". But use your VPS IP address in the YAML config. **Hard tech way 2. Using TURN-server** @@ -973,7 +1014,7 @@ HomeKit module can work in two modes: **Important** -- HomeKit cameras supports only H264 video and OPUS audio +- HomeKit cameras support only H264 video and OPUS audio **Minimal config** @@ -1020,17 +1061,17 @@ homekit: *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This module support: +This module supports: - Share any local stream via [WebTorrent](https://webtorrent.io/) technology - Get any [incoming stream](#incoming-browser) from PC or mobile via [WebTorrent](https://webtorrent.io/) technology - Get any remote [go2rtc source](#source-webtorrent) via [WebTorrent](https://webtorrent.io/) technology -Securely and free. You do not need to open a public access to the go2rtc server. But in some cases (Symmetric NAT) you may need to set up external access to [WebRTC module](#module-webrtc). +Securely and freely. You do not need to open a public access to the go2rtc server. But in some cases (Symmetric NAT), you may need to set up external access to [WebRTC module](#module-webrtc). -To generate sharing link or incoming link - goto go2rtc WebUI (stream links page). This link is **temporary** and will stop working after go2rtc is restarted! +To generate a sharing link or incoming link, go to the go2rtc WebUI (stream links page). This link is **temporary** and will stop working after go2rtc is restarted! -You can create permanent external links in go2rtc config: +You can create permanent external links in the go2rtc config: ```yaml webtorrent: @@ -1042,22 +1083,22 @@ webtorrent: Link example: https://alexxit.github.io/go2rtc/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio -TODO: article how it works... +TODO: article on how it works... ### Module: ngrok -With ngrok integration you can get external access to your streams in situations when you have Internet with private IP-address. +With ngrok integration, you can get external access to your streams in situations when you have Internet with a private IP address. -- ngrok is pre-installed for **Docker** and **Hass Add-on** users +- ngrok is pre-installed for **Docker** and **Hass add-on** users - you may need external access for two different things: - - WebRTC stream, so you need tunnel WebRTC TCP port (ex. 8555) - - go2rtc web interface, so you need tunnel API HTTP port (ex. 1984) -- ngrok support authorization for your web interface + - WebRTC stream, so you need a tunnel WebRTC TCP port (ex. 8555) + - go2rtc web interface, so you need a tunnel API HTTP port (ex. 1984) +- ngrok supports authorization for your web interface - ngrok automatically adds HTTPS to your web interface The ngrok free subscription has the following limitations: -- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and change with each restart of the ngrok agent (not a problem for webrtc stream) +- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and change with each restart of the ngrok agent (not a problem for WebRTC stream) - You can forward multiple ports from a single agent, but you can only run one ngrok agent on the free plan go2rtc will automatically get your external TCP address (if you enable it in ngrok config) and use it with WebRTC connection (if you enable it in webrtc config). @@ -1075,7 +1116,7 @@ ngrok: **Tunnel for WebRTC and Web interface** -You need to create `ngrok.yaml` config file and add it to go2rtc config: +You need to create `ngrok.yaml` config file and add it to the go2rtc config: ```yaml ngrok: @@ -1089,12 +1130,12 @@ version: "2" authtoken: eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw tunnels: api: - addr: 1984 # use the same port as in go2rtc config + addr: 1984 # use the same port as in the go2rtc config proto: http basic_auth: - admin:password # you can set login/pass for your web interface webrtc: - addr: 8555 # use the same port as in go2rtc config + addr: 8555 # use the same port as in the go2rtc config proto: tcp ``` @@ -1102,9 +1143,9 @@ See the [ngrok agent documentation](https://ngrok.com/docs/agent/config/) for mo ### Module: Hass -The best and easiest way to use go2rtc inside the Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom lovelace card. +The best and easiest way to use go2rtc inside Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom Lovelace card. -But go2rtc is also compatible and can be used with [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) built-in integration. +But go2rtc is also compatible and can be used with the [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) built-in integration. You have several options on how to add a camera to Home Assistant: @@ -1122,10 +1163,10 @@ You have several options on how to watch the stream from the cameras in Home Ass - Install any [go2rtc](#fast-start) - Hass > Settings > Integrations > Add Integration > [RTSPtoWebRTC](https://my.home-assistant.io/redirect/config_flow_start/?domain=rtsp_to_webrtc) > `http://127.0.0.1:1984/` - RTSPtoWebRTC > Configure > STUN server: `stun.l.google.com:19302` - - Use Picture Entity or Picture Glance lovelace card + - Use Picture Entity or Picture Glance Lovelace card 3. `Camera Entity` or `Camera URL` => [WebRTC Camera](https://github.com/AlexxIT/WebRTC) => Technology: `WebRTC/MSE/MP4/MJPEG`, codecs: `H264/H265/AAC/PCMU/PCMA/OPUS`, best latency, best compatibility. - Install and add [WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom integration - - Use WebRTC Camera custom lovelace card + - Use WebRTC Camera custom Lovelace card You can add camera `entity_id` to [go2rtc config](#configuration) if you need transcoding: @@ -1134,7 +1175,7 @@ streams: "camera.hall": ffmpeg:{input}#video=copy#audio=opus ``` -**PS.** Default Home Assistant lovelace cards don't support 2-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons). But you need use HTTPS to access the microphone. This is a browser restriction and cannot be avoided. +**PS.** Default Home Assistant lovelace cards don't support two-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons), but you need to use HTTPS to access the microphone. This is a browser restriction and cannot be avoided. **PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card). @@ -1144,7 +1185,7 @@ Provides several features: 1. MSE stream (fMP4 over WebSocket) 2. Camera snapshots in MP4 format (single frame), can be sent to [Telegram](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram) -3. HTTP progressive streaming (MP4 file stream) - bad format for streaming because of high start delay. This format doesn't work in all Safari browsers, but go2rtc will automatically redirect it to HLS/fMP4 it this case. +3. HTTP progressive streaming (MP4 file stream) - bad format for streaming because of high start delay. This format doesn't work in all Safari browsers, but go2rtc will automatically redirect it to HLS/fMP4 in this case. API examples: @@ -1178,13 +1219,13 @@ Read more about [codecs filters](#codecs-filters). ### Module: MJPEG -**Important.** For stream as MJPEG format, your source MUST contain the MJPEG codec. If your stream has a MJPEG codec - you can receive **MJPEG stream** or **JPEG snapshots** via API. +**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. You can receive an MJPEG stream in several ways: - some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) -- some cameras has HTTP link with [MJPEG stream](#source-http) -- some cameras has HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) +- some cameras have an HTTP link with [MJPEG stream](#source-http) +- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) - you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) With this example, your stream will have both H264 and MJPEG codecs: @@ -1225,7 +1266,7 @@ log: ## Security -By default `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as use 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: @@ -1241,13 +1282,13 @@ webrtc: ``` - local access to RTSP is not a problem for [FFmpeg](#source-ffmpeg) integration, because it runs locally on your server -- local access to API is not a problem for [Home Assistant Add-on](#go2rtc-home-assistant-add-on), because Hass runs locally on same server and Add-on Web UI protected with Hass authorization ([Ingress feature](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/)) -- external access to WebRTC TCP port is not a problem, because it used only for transmit encrypted media data - - anyway you need to open this port to your local network and to the Internet in order for WebRTC to work +- local access to API is not a problem for the [Home Assistant add-on](#go2rtc-home-assistant-add-on), because Hass runs locally on the same server, and the add-on web UI is protected with Hass authorization ([Ingress feature](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/)) +- external access to WebRTC TCP port is not a problem, because it is used only for transmitting encrypted media data + - anyway you need to open this port to your local network and to the Internet for WebRTC to work -If you need Web interface protection without Home Assistant Add-on - you need to use reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [ngrok](https://ngrok.com/), etc. +If you need web interface protection without the Home Assistant add-on, you need to use a reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [ngrok](https://ngrok.com/), etc. -PS. Additionally WebRTC will try to use the 8555 UDP port for transmit encrypted media. It works without problems on the local network. And sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. +PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. ## Codecs filters @@ -1270,11 +1311,11 @@ Some examples: - `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` - HLS stream with MP4 compatible codecs (HLS/fMP4) - `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4=flac` - HLS stream with PCMA/PCMU/PCM audio support (HLS/fMP4), won't work on old devices - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) -- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non standard audio codecs, won't work on some players +- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players ## Codecs madness -`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with 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. It's all about patents and money; you can't do anything about it. | Device | WebRTC | MSE | HTTP* | HLS | |--------------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| @@ -1287,7 +1328,7 @@ Some examples: [1]: https://apps.apple.com/app/home-assistant/id1099568401 -`HTTP*` - HTTP Progressive Streaming, not related with [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 - Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) - Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/) @@ -1298,7 +1339,7 @@ Some examples: - Go2rtc support [automatic repack](#built-in-transcoding) `PCMA/PCMU/PCM` codecs to `FLAC` for MSE/MP4/HLS so they will work almost anywhere - **WebRTC** audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2` -- `OPUS` and `MP3` inside **MP4** is part of the standard, but some players do not support them anyway (especially Apple) +- `OPUS` and `MP3` inside **MP4** are part of the standard, but some players do not support them anyway (especially Apple) **Apple devices** @@ -1320,7 +1361,7 @@ Some examples: There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. -But go2rtc has some simple algorithms. They are turned on automatically, you do not need to set them up additionally. +But go2rtc has some simple algorithms. They are turned on automatically; you do not need to set them up additionally. **PCM for MSE/MP4/HLS** @@ -1332,7 +1373,7 @@ PCMA/PCMU => PCM => FLAC => MSE/MP4/HLS **Resample PCMA/PCMU for WebRTC** -By default WebRTC support only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codec with with a different sample rate. Also go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it: +By default WebRTC supports only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codecs with a different sample rate. Also, go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it: ``` PCM/xxx => PCMA/8000 => WebRTC @@ -1342,24 +1383,24 @@ PCMU/xxx => PCMU/8000 => WebRTC **Important** -- FLAC codec not supported in a RTSP stream. If you using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio - you should setup transcoding to AAC codec. -- PCMA and PCMU are VERY low quality codecs. Them support only 256! different sounds. Use them only when you have no other options. +- FLAC codec not supported in an RTSP stream. If you are using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec. +- PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options. ## Codecs negotiation For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. -- this camera support 2-way audio standard **ONVIF Profile T** -- this camera support codecs **H264, H265** for send video, and you select `H264` in camera settings -- this camera support codecs **AAC, PCMU, PCMA** for send audio (from mic), and you select `AAC/16000` in camera settings -- this camera support codecs **AAC, PCMU, PCMA** for receive audio (to speaker), you don't need to select them -- your browser support codecs **H264, VP8, VP9, AV1** for receive video, you don't need to select them -- your browser support codecs **OPUS, PCMU, PCMA** for send and receive audio, you don't need to select them -- you can't get camera audio directly, because its audio codecs doesn't match with your browser codecs - - so you decide to use transcoding via FFmpeg and add this setting to config YAML file +- this camera supports two-way audio standard **ONVIF Profile T** +- this camera supports codecs **H264, H265** for send video, and you select `H264` in camera settings +- this camera supports codecs **AAC, PCMU, PCMA** for sending audio (from mic), and you select `AAC/16000` in camera settings +- this camera supports codecs **AAC, PCMU, PCMA** for receiving audio (to speaker), you don't need to select them +- your browser supports codecs **H264, VP8, VP9, AV1** for receiving video, you don't need to select them +- your browser supports codecs **OPUS, PCMU, PCMA** for sending and receiving audio, you don't need to select them +- you can't get camera audio directly, because its audio codecs don't match with your browser codecs + - so you decide to use transcoding via FFmpeg and add this setting to the config YAML file - you have chosen `OPUS/48000/2` codec, because it is higher quality than the `PCMU/8000` or `PCMA/8000` -Now you have stream with two sources - **RTSP and FFmpeg**: +Now you have a stream with two sources - **RTSP and FFmpeg**: ```yaml streams: @@ -1368,22 +1409,23 @@ streams: - ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0#audio=opus ``` -**go2rtc** automatically match codecs for you browser and all your stream sources. This called **multi-source 2-way codecs negotiation**. And this is one of the main features of this app. +**go2rtc** automatically matches codecs for your browser and all your stream sources. This is called **multi-source two-way codec negotiation**. And this is one of the main features of this app. ![](assets/codecs.svg) -**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. +**PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. ## Projects using go2rtc -- [Frigate 12+](https://frigate.video/) - open source NVR built around real-time AI object detection +- [Frigate](https://frigate.video/) 0.12+ - open-source NVR built around real-time AI object detection - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card) - custom card for Home Assistant -- [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - Alternative IP Camera firmware from an open community -- [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - Custom firmware for Wyze cameras -- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - A small project that provides a Video/Audio Stream from Eufy cameras that don't directly support RTSP -- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for control Eufy security devices -- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² Module -- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring devices to MQTT Bridge +- [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 +- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - a small project that provides a video/audio stream from Eufy cameras that don't directly support RTSP +- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices +- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module +- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge +- [lightNVR](https://github.com/opensensor/lightNVR) **Distributions** @@ -1391,20 +1433,20 @@ streams: - [Arch User Repository](https://linux-packages.com/aur/package/go2rtc) - [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc) - [NixOS](https://search.nixos.org/packages?query=go2rtc) -- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/) +- [Proxmox Helper Scripts](https://github.com/community-scripts/ProxmoxVE/) - [QNAP](https://www.myqnap.org/product/go2rtc/) - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) -## Cameras experience +## Camera experience - [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients -- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol realisation, many bugs in SDP +- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP - [Hikvision](https://www.hikvision.com/) - a lot of proprietary streaming technologies -- [Reolink](https://reolink.com/) - some models has awful unusable RTSP realisation and not best RTMP alternative (I recommend that you contact Reolink support for new firmware), few settings -- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not best protocol implementation +- [Reolink](https://reolink.com/) - some models have an awful, unusable RTSP implementation and not the best RTMP alternative (I recommend that you contact Reolink support for new firmware), few settings +- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not the best protocol implementation - [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss? -- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usual has `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol realisation, low stream quality, few settings, packet loss? +- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss? ## TIPS @@ -1421,22 +1463,22 @@ streams: **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. +**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 go2rtc addon or WebRTC Camera integration?** +**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 the Hass is rebooted - all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks. +**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 **WebRTC Camera** integration. Advanced users can use go2rtc addon or Frigate 12+ addon. +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 direct link to your cameras there (as you always do). **go2rtc** support 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**. +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 addon. With additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connection - some from Hass via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols. +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 what you like. +Use any config that you like. -**Q. What about lovelace card with support 2-way audio?** +**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). +At this moment, I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html). diff --git a/api/openapi.yaml b/api/openapi.yaml index 618acb48..a2d66a87 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -237,6 +237,54 @@ paths: + /api/preload: + put: + summary: Preload new stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + - name: video + in: query + description: Video codecs filter + required: false + schema: { type: string } + example: all,h264,h265,... + - name: audio + in: query + description: Audio codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + - name: microphone + in: query + description: Microphone codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + responses: + default: + description: Default response + delete: + summary: Delete preloaded stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + responses: + default: + description: Default response + + + /api/streams?src={src}: get: summary: Get stream info in JSON format diff --git a/Dockerfile b/docker/Dockerfile similarity index 77% rename from Dockerfile rename to docker/Dockerfile index ba436825..8d064f21 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -1,20 +1,11 @@ # syntax=docker/dockerfile:labs # 0. Prepare images -ARG PYTHON_VERSION="3.11" -ARG GO_VERSION="1.24" +ARG PYTHON_VERSION="3.13" +ARG GO_VERSION="1.25" -# 1. Download ngrok binary (for support arm/v6) -FROM alpine AS ngrok -ARG TARGETARCH -ARG TARGETOS - -ADD https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-${TARGETOS}-${TARGETARCH}.tgz / -RUN tar -xzf /ngrok-v3-stable-${TARGETOS}-${TARGETARCH}.tgz -C /bin - - -# 2. Build go2rtc binary +# 1. Build go2rtc binary FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build ARG TARGETPLATFORM ARG TARGETOS @@ -35,7 +26,7 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -# 3. Final image +# 2. Final image FROM python:${PYTHON_VERSION}-alpine AS base # Install ffmpeg, tini (for signal handling), @@ -55,7 +46,6 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver # RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total) COPY --from=build /build/go2rtc /usr/local/bin/ -COPY --from=ngrok /bin/ngrok /usr/local/bin/ ENTRYPOINT ["/sbin/tini", "--"] VOLUME /config diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..b8119efc --- /dev/null +++ b/docker/README.md @@ -0,0 +1,50 @@ +## Versions + +- `alexxit/go2rtc:latest` - latest release based on `alpine` (`amd64`, `386`, `arm/v6`, `arm/v7`, `arm64`) with support hardware transcoding for Intel iGPU and Raspberry +- `alexxit/go2rtc:latest-hardware` - latest release based on `debian 13` (`amd64`) with support hardware transcoding for Intel iGPU, AMD GPU and NVidia GPU +- `alexxit/go2rtc:latest-rockchip` - latest release based on `debian 12` (`arm64`) with support hardware transcoding for Rockchip RK35xx +- `alexxit/go2rtc:master` - latest unstable version based on `alpine` +- `alexxit/go2rtc:master-hardware` - latest unstable version based on `debian 13` (`amd64`) +- `alexxit/go2rtc:master-rockchip` - latest unstable version based on `debian 12` (`arm64`) + +## Docker compose + +```yaml +services: + go2rtc: + image: alexxit/go2rtc + network_mode: host # important for WebRTC, HomeKit, UDP cameras + privileged: true # only for FFmpeg hardware transcoding + restart: unless-stopped # autorestart on fail or config change from WebUI + environment: + - TZ=Atlantic/Bermuda # timezone in logs + volumes: + - "~/go2rtc:/config" # folder for go2rtc.yaml file (edit from WebUI) +``` + +## Basic Deployment + +```bash +docker run -d \ + --name go2rtc \ + --network host \ + --privileged \ + --restart unless-stopped \ + -e TZ=Atlantic/Bermuda \ + -v ~/go2rtc:/config \ + alexxit/go2rtc +``` + +## Deployment with GPU Acceleration + +```bash +docker run -d \ + --name go2rtc \ + --network host \ + --privileged \ + --restart unless-stopped \ + -e TZ=Atlantic/Bermuda \ + --gpus all \ + -v ~/go2rtc:/config \ + alexxit/go2rtc:latest-hardware +``` diff --git a/hardware.Dockerfile b/docker/hardware.Dockerfile similarity index 74% rename from hardware.Dockerfile rename to docker/hardware.Dockerfile index e75a97cd..a80d08d7 100644 --- a/hardware.Dockerfile +++ b/docker/hardware.Dockerfile @@ -4,16 +4,11 @@ # only debian 13 (trixie) has latest ffmpeg # https://packages.debian.org/trixie/ffmpeg ARG DEBIAN_VERSION="trixie-slim" -ARG GO_VERSION="1.24-bookworm" -ARG NGROK_VERSION="3" - -FROM debian:${DEBIAN_VERSION} AS base -FROM golang:${GO_VERSION} AS go -FROM ngrok/ngrok:${NGROK_VERSION} AS ngrok +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary -FROM --platform=$BUILDPLATFORM go AS build +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build ARG TARGETPLATFORM ARG TARGETOS ARG TARGETARCH @@ -31,34 +26,28 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -# 2. Collect all files -FROM scratch AS rootfs +# 2. Final image +FROM debian:${DEBIAN_VERSION} -COPY --link --from=build /build/go2rtc /usr/local/bin/ -COPY --link --from=ngrok /bin/ngrok /usr/local/bin/ - -# 3. Final image -FROM base # Prepare apt for buildkit cache RUN rm -f /etc/apt/apt.conf.d/docker-clean \ && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache -# Install ffmpeg, bash (for run.sh), tini (for signal handling), + +# Install ffmpeg, tini (for signal handling), # and other common tools for the echo source. # non-free for Intel QSV support (not used by go2rtc, just for tests) # mesa-va-drivers for AMD APU # libasound2-plugins for ALSA support RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ echo 'deb http://deb.debian.org/debian trixie non-free' > /etc/apt/sources.list.d/debian-non-free.list && \ - apt-get -y update && apt-get -y install tini ffmpeg \ + apt-get -y update && apt-get -y install ffmpeg tini \ python3 curl jq \ intel-media-va-driver-non-free \ mesa-va-drivers \ libasound2-plugins && \ apt-get clean && rm -rf /var/lib/apt/lists/* -COPY --link --from=rootfs / / - - +COPY --from=build /build/go2rtc /usr/local/bin/ ENTRYPOINT ["/usr/bin/tini", "--"] VOLUME /config diff --git a/docker/rockchip.Dockerfile b/docker/rockchip.Dockerfile new file mode 100644 index 00000000..949db83b --- /dev/null +++ b/docker/rockchip.Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:labs + +# 0. Prepare images +ARG PYTHON_VERSION="3.13-slim-bookworm" +ARG GO_VERSION="1.25-bookworm" + + +# 1. Build go2rtc binary +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH + +ENV GOOS=${TARGETOS} +ENV GOARCH=${TARGETARCH} + +WORKDIR /build + +# Cache dependencies +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build go mod download + +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath + + +# 2. Final image +FROM python:${PYTHON_VERSION} + +# Prepare apt for buildkit cache +RUN rm -f /etc/apt/apt.conf.d/docker-clean \ + && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache + +# Install ffmpeg, tini (for signal handling), +# and other common tools for the echo source. +# libasound2-plugins for ALSA support +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get -y update && apt-get -y install tini \ + curl jq \ + libasound2-plugins && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY --from=build /build/go2rtc /usr/local/bin/ +ADD --chmod=755 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-8-no_extra_dump/ffmpeg /usr/local/bin + +ENTRYPOINT ["/usr/bin/tini", "--"] +VOLUME /config +WORKDIR /config + +CMD ["go2rtc", "-config", "/config/go2rtc.yaml"] diff --git a/examples/homekit_info/main.go b/examples/homekit_info/main.go index d353ae79..8527042e 100644 --- a/examples/homekit_info/main.go +++ b/examples/homekit_info/main.go @@ -54,7 +54,7 @@ var chars = map[string]string{ "21C": "Third Party Camera Active", "21D": "Camera Operating Mode Indicator", "11B": "Night Vision", - "129": "Supported Data Stream Transport Configuration", + //"129": "Supported Data Stream Transport Configuration", "37": "Version", "131": "Setup Data Stream Transport", "130": "Supported Data Stream Transport Configuration", diff --git a/go.mod b/go.mod index 45d5327c..7abf1edd 100644 --- a/go.mod +++ b/go.mod @@ -1,50 +1,49 @@ module github.com/AlexxIT/go2rtc -go 1.20 +go 1.23.0 require ( github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.16.9 + github.com/expr-lang/expr v1.17.5 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.63 - github.com/pion/ice/v2 v2.3.37 - github.com/pion/interceptor v0.1.37 + github.com/miekg/dns v1.1.66 + github.com/pion/ice/v4 v4.0.10 + github.com/pion/interceptor v0.1.40 github.com/pion/rtcp v1.2.15 - github.com/pion/rtp v1.8.11 - github.com/pion/sdp/v3 v3.0.10 - github.com/pion/srtp/v2 v2.0.20 - github.com/pion/stun v0.6.1 - github.com/pion/webrtc/v3 v3.3.5 - github.com/rs/zerolog v1.33.0 + github.com/pion/rtp v1.8.20 + github.com/pion/sdp/v3 v3.0.14 + github.com/pion/srtp/v3 v3.0.6 + github.com/pion/stun/v3 v3.0.0 + github.com/pion/webrtc/v4 v4.1.3 + github.com/rs/zerolog v1.34.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.10.0 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.33.0 + golang.org/x/crypto v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asticode/go-astikit v0.52.0 // indirect + github.com/asticode/go-astikit v0.56.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/logging v0.2.3 // indirect - github.com/pion/mdns v0.0.12 // indirect + github.com/pion/dtls/v3 v3.0.6 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/sctp v1.8.36 // indirect - github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pion/sctp v1.8.39 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v2 v2.1.6 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.20.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/tools v0.24.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index a0fdcb88..7e1b0cee 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= -github.com/asticode/go-astikit v0.52.0 h1:kTl2XjgiVQhUl1H7kim7NhmTtCMwVBbPrXKqhQhbk8Y= -github.com/asticode/go-astikit v0.52.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ= +github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= +github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -8,19 +10,17 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= -github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= +github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= +github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -32,49 +32,58 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= +github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= -github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/ice/v2 v2.3.37 h1:ObIdaNDu1rCo7hObhs34YSBcO7fjslJMZV0ux+uZWh0= -github.com/pion/ice/v2 v2.3.37/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= +github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +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/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/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= -github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/rtp v1.8.11 h1:17xjnY5WO5hgO6SD3/NTIUPvSFw/PbLsIJyz1r1yNIk= -github.com/pion/rtp v1.8.11/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= -github.com/pion/sctp v1.8.36 h1:owNudmnz1xmhfYje5L/FCav3V9wpPRePHle3Zi+P+M0= -github.com/pion/sctp v1.8.36/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA= -github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk= -github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= -github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= -github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= -github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= +github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= +github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/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/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/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= +github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= +github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= -github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg= -github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE= +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/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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= @@ -82,96 +91,50 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= 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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= -github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 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/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/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/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/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -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/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +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/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -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/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 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/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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -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/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -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/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/alsa/alsa.go b/internal/alsa/alsa.go new file mode 100644 index 00000000..7886c74f --- /dev/null +++ b/internal/alsa/alsa.go @@ -0,0 +1,7 @@ +//go:build !(linux && (386 || amd64 || arm || arm64 || mipsle)) + +package alsa + +func Init() { + // not supported +} diff --git a/internal/alsa/alsa_linux.go b/internal/alsa/alsa_linux.go new file mode 100644 index 00000000..316a7594 --- /dev/null +++ b/internal/alsa/alsa_linux.go @@ -0,0 +1,83 @@ +//go:build linux && (386 || amd64 || arm || arm64 || mipsle) + +package alsa + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/alsa" + "github.com/AlexxIT/go2rtc/pkg/alsa/device" +) + +func Init() { + streams.HandleFunc("alsa", alsa.Open) + + api.HandleFunc("api/alsa", apiAlsa) +} + +func apiAlsa(w http.ResponseWriter, r *http.Request) { + files, err := os.ReadDir("/dev/snd/") + if err != nil { + return + } + + var sources []*api.Source + + for _, file := range files { + if !strings.HasPrefix(file.Name(), "pcm") { + continue + } + + path := "/dev/snd/" + file.Name() + + dev, err := device.Open(path) + if err != nil { + continue + } + + info, err := dev.Info() + if err == nil { + formats := formatsToString(dev.ListFormats()) + r1, r2 := dev.RangeRates() + c1, c2 := dev.RangeChannels() + source := &api.Source{ + Name: info.ID, + Info: fmt.Sprintf("Formats: %s, Rates: %d-%d, Channels: %d-%d", formats, r1, r2, c1, c2), + URL: "alsa:device?audio=" + path, + } + if !strings.Contains(source.Name, info.Name) { + source.Name += ", " + info.Name + } + sources = append(sources, source) + } + + _ = dev.Close() + } + + api.ResponseSources(w, sources) +} + +func formatsToString(formats []byte) string { + var s string + for i, format := range formats { + if i > 0 { + s += " " + } + switch format { + case 2: + s += "s16le" + case 10: + s += "s32le" + default: + s += strconv.Itoa(int(format)) + } + + } + return s +} diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 1d945bfe..981d1b41 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -11,6 +11,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/gorilla/websocket" "github.com/rs/zerolog" ) @@ -132,7 +133,8 @@ func apiWS(w http.ResponseWriter, r *http.Request) { if handler := wsHandlers[msg.Type]; handler != nil { go func() { if err = handler(tr, msg); err != nil { - tr.Write(&Message{Type: "error", Value: msg.Type + ": " + err.Error()}) + errMsg := core.StripUserinfo(err.Error()) + tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) } }() } diff --git a/internal/app/config.go b/internal/app/config.go index 9d4480b7..0f95894a 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -5,8 +5,9 @@ import ( "os" "path/filepath" "strings" + "sync" - "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/AlexxIT/go2rtc/pkg/yaml" ) @@ -18,11 +19,16 @@ func LoadConfig(v any) { } } +var configMu sync.Mutex + func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") } + configMu.Lock() + defer configMu.Unlock() + // empty config is OK b, _ := os.ReadFile(ConfigPath) @@ -65,13 +71,15 @@ func initConfig(confs flagConfig) { // config as file if ConfigPath == "" { ConfigPath = conf + initStorage() } if data, _ = os.ReadFile(conf); data == nil { continue } - data = []byte(shell.ReplaceEnvVars(string(data))) + loadEnv(data) + data = creds.ReplaceVars(data) configs = append(configs, data) } } diff --git a/internal/app/log.go b/internal/app/log.go index b8ca4aa5..9ec89a2c 100644 --- a/internal/app/log.go +++ b/internal/app/log.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/mattn/go-isatty" "github.com/rs/zerolog" ) @@ -88,6 +89,8 @@ func initLogger() { writer = MemoryLog } + writer = creds.SecretWriter(writer) + lvl, _ := zerolog.ParseLevel(modules["level"]) Logger = zerolog.New(writer).Level(lvl) diff --git a/internal/app/storage.go b/internal/app/storage.go new file mode 100644 index 00000000..cfa1ca91 --- /dev/null +++ b/internal/app/storage.go @@ -0,0 +1,56 @@ +package app + +import ( + "sync" + + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func initStorage() { + storage = &envStorage{data: make(map[string]string)} + creds.SetStorage(storage) +} + +func loadEnv(data []byte) { + var cfg struct { + Env map[string]string `yaml:"env"` + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return + } + + storage.mu.Lock() + for name, value := range cfg.Env { + storage.data[name] = value + creds.AddSecret(value) + } + storage.mu.Unlock() +} + +var storage *envStorage + +type envStorage struct { + data map[string]string + mu sync.Mutex +} + +func (s *envStorage) SetValue(name, value string) error { + if err := PatchConfig([]string{"env", name}, value); err != nil { + return err + } + + s.mu.Lock() + s.data[name] = value + s.mu.Unlock() + + return nil +} + +func (s *envStorage) GetValue(name string) (value string, ok bool) { + s.mu.Lock() + value, ok = s.data[name] + s.mu.Unlock() + return +} diff --git a/internal/debug/stack.go b/internal/debug/stack.go index f8d62772..6bc735ad 100644 --- a/internal/debug/stack.go +++ b/internal/debug/stack.go @@ -29,8 +29,8 @@ var stackSkip = [][]byte{ []byte("created by github.com/AlexxIT/go2rtc/internal/homekit.Init"), // webrtc/api.go - []byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"), - []byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"), + []byte("created by github.com/pion/ice/v4.NewTCPMuxDefault"), + []byte("created by github.com/pion/ice/v4.NewUDPMuxDefault"), } func stackHandler(w http.ResponseWriter, r *http.Request) { diff --git a/internal/eseecloud/eseecloud.go b/internal/eseecloud/eseecloud.go new file mode 100644 index 00000000..bb4d9d31 --- /dev/null +++ b/internal/eseecloud/eseecloud.go @@ -0,0 +1,10 @@ +package eseecloud + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/eseecloud" +) + +func Init() { + streams.HandleFunc("eseecloud", eseecloud.Dial) +} diff --git a/internal/exec/README.md b/internal/exec/README.md new file mode 100644 index 00000000..e15a9657 --- /dev/null +++ b/internal/exec/README.md @@ -0,0 +1,12 @@ +## Backchannel + +- You can check audio card names in the **Go2rtc > WebUI > Add** +- You can specify multiple backchannel lines with different codecs + +```yaml +sources: + two_way_audio_win: + - exec:ffmpeg -hide_banner -f dshow -i "audio=Microphone (High Definition Audio Device)" -c pcm_s16le -ar 16000 -ac 1 -f wav - + - exec:ffplay -nodisp -probesize 32 -f s16le -ar 16000 -#backchannel=1#audio=s16le/16000 + - exec:ffplay -nodisp -probesize 32 -f alaw -ar 8000 -#backchannel=1#audio=alaw/8000 +``` diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 89add393..711be8a2 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -19,9 +19,9 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/pcm" pkg "github.com/AlexxIT/go2rtc/pkg/rtsp" "github.com/AlexxIT/go2rtc/pkg/shell" - "github.com/AlexxIT/go2rtc/pkg/stdin" "github.com/rs/zerolog" ) @@ -86,7 +86,7 @@ func execHandle(rawURL string) (prod core.Producer, err error) { } if query.Get("backchannel") == "1" { - return stdin.NewClient(cmd) + return pcm.NewBackchannel(cmd, query.Get("audio")) } if path == "" { diff --git a/internal/expr/expr.go b/internal/expr/expr.go index a6d1f972..8fd6c9c2 100644 --- a/internal/expr/expr.go +++ b/internal/expr/expr.go @@ -12,7 +12,7 @@ func Init() { log := app.GetLogger("expr") streams.RedirectFunc("expr", func(url string) (string, error) { - v, err := expr.Run(url[5:]) + v, err := expr.Eval(url[5:], nil) if err != nil { return "", err } diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index 25d61e4b..242c151d 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -80,7 +80,7 @@ var defaults = map[string]string{ // `-profile high -level 4.1` - most used streaming profile // `-pix_fmt:v yuv420p` - important for Telegram "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", - "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", + "h265": "-c:v libx265 -g 50 -profile:v main -x265-params level=5.1:high-tier=0 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", "mjpeg": "-c:v mjpeg", //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", @@ -113,6 +113,7 @@ var defaults = map[string]string{ "pcm/48000": "-c:a pcm_s16be -ar:a 48000 -ac:a 1", "pcml": "-c:a pcm_s16le -ar:a 8000 -ac:a 1", "pcml/8000": "-c:a pcm_s16le -ar:a 8000 -ac:a 1", + "pcml/16000": "-c:a pcm_s16le -ar:a 16000 -ac:a 1", "pcml/44100": "-c:a pcm_s16le -ar:a 44100 -ac:a 1", // hardware Intel and AMD on Linux @@ -129,8 +130,9 @@ var defaults = map[string]string{ // hardware Rockchip // important to use custom ffmpeg https://github.com/AlexxIT/go2rtc/issues/768 // hevc - doesn't have a profile setting - "h264/rkmpp": "-c:v h264_rkmpp_encoder -g 50 -bf 0 -profile:v high -level:v 4.1", - "h265/rkmpp": "-c:v hevc_rkmpp_encoder -g 50 -bf 0 -level:v 5.1", + "h264/rkmpp": "-c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1", + "h265/rkmpp": "-c:v hevc_rkmpp -g 50 -bf 0 -profile:v main -level:v 5.1", + "mjpeg/rkmpp": "-c:v mjpeg_rkmpp", // hardware NVidia on Linux and Windows // preset=p2 - faster, tune=ll - low latency diff --git a/internal/ffmpeg/ffmpeg_test.go b/internal/ffmpeg/ffmpeg_test.go index 2ab1170d..30052d78 100644 --- a/internal/ffmpeg/ffmpeg_test.go +++ b/internal/ffmpeg/ffmpeg_test.go @@ -251,15 +251,33 @@ func _TestParseArgsHwV4l2m2m(t *testing.T) { } func TestParseArgsHwRKMPP(t *testing.T) { - // [HTTP-MJPEG] video will be transcoded to H264 - args := parseArgs("http://example.com#video=h264#hardware=rkmpp") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v h264_rkmpp_encoder -g 50 -bf 0 -profile:v high -level:v 4.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - args = parseArgs("http://example.com#video=h264#rotate=180#hardware=rkmpp") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v h264_rkmpp_encoder -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - args = parseArgs("http://example.com#video=h264#height=320#hardware=rkmpp") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v h264_rkmpp_encoder -g 50 -bf 0 -profile:v high -level:v 4.1 -height 320 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + tests := []struct { + name string + source string + expect string + }{ + { + name: "[FILE] transcoding to H264", + source: "bbb.mp4#video=h264#hardware=rkmpp", + expect: `ffmpeg -hide_banner -hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga -re -i bbb.mp4 -c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] transcoding with rotation", + source: "bbb.mp4#video=h264#rotate=180#hardware=rkmpp", + expect: `ffmpeg -hide_banner -hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga -re -i bbb.mp4 -c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "format=drm_prime|nv12,hwupload,vpp_rkrga=transpose=4" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] transcoding with scaling", + source: "bbb.mp4#video=h264#height=320#hardware=rkmpp", + expect: `ffmpeg -hide_banner -hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga -re -i bbb.mp4 -c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "format=drm_prime|nv12,hwupload,scale_rkrga=-1:320:force_original_aspect_ratio=0" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } } func _TestParseArgsHwCuda(t *testing.T) { diff --git a/internal/ffmpeg/hardware/README.md b/internal/ffmpeg/hardware/README.md new file mode 100644 index 00000000..2d7f21cf --- /dev/null +++ b/internal/ffmpeg/hardware/README.md @@ -0,0 +1,106 @@ +# Hardware + +You **DON'T** need hardware acceleration if: + +- you not using [FFmpeg source](https://github.com/AlexxIT/go2rtc#source-ffmpeg) +- you using only `#video=copy` for FFmpeg source +- you using only `#audio=...` (any audio) transcoding for FFmpeg source + +You **NEED** hardware acceleration if you using `#video=h264`, `#video=h265`, `#video=mjpeg` (video) transcoding. + +## Important + +- Acceleration is disabled by default because it can be unstable (it can be changed in future) +- go2rtc can automatically detect supported hardware acceleration if enabled +- go2rtc will enable hardware decoding only if hardware encoding supported +- go2rtc will use the same GPU for decoder and encoder +- Intel and AMD will switch to software decoder if input codec is not supported with hardware decoder +- NVidia will fail if input codec is not supported with hardware decoder +- Raspberry always uses software decoder + +```yaml +streams: + # auto select hardware encoder + camera1_hw: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#hardware + + # manual select hardware encoder (vaapi, cuda, v4l2m2m, dxva2, videotoolbox) + camera1_vaapi: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#hardware=vaapi +``` + +## Docker and Hass Addon + +There are two versions of the Docker container and Hass Add-on: + +- Latest (alpine) support hardware acceleration for Intel iGPU (CPU with Graphics) and Raspberry. +- Hardware (debian 12) support Intel iGPU, AMD GPU, NVidia GPU. + +## Intel iGPU + +**Supported on:** Windows binary, Linux binary, Docker, Hass Addon. + +If you have Intel CPU Sandy Bridge (2011) with Graphics, you already have support hardware decoding/encoding for `AVC/H.264`. + +If you have Intel CPU Skylake (2015) with Graphics, you already have support hardware decoding/encoding for `AVC/H.264`, `HEVC/H.265` and `MJPEG`. + +Read more [here](https://en.wikipedia.org/wiki/Intel_Quick_Sync_Video#Hardware_decoding_and_encoding) and [here](https://en.wikipedia.org/wiki/Intel_Graphics_Technology#Capabilities_(GPU_video_acceleration)). + +Linux and Docker: + +- It may be important to have the latest version of the OS with the latest version of the Linux kernel. For example, on my **Debian 10 (kernel 4.19)** it did not work, but after update to **Debian 11 (kernel 5.10)** all was fine. +- In case of troube check you have `/dev/dri/` folder on your host. + +Docker users should add `--privileged` option to container for access to Hardware. + +**PS.** Supported via [VAAPI](https://trac.ffmpeg.org/wiki/Hardware/VAAPI) engine on Linux and [DXVA2+QSV](https://trac.ffmpeg.org/wiki/Hardware/QuickSync) engine on Windows. + +## AMD GPU + +*I don't have the hardware for test support!!!* + +**Supported on:** Linux binary, Docker, Hass Addon. + +Docker users should install: `alexxit/go2rtc:master-hardware`. Docker users should add `--privileged` option to container for access to Hardware. + +Hass Addon users should install **go2rtc master hardware** version. + +**PS.** Supported via [VAAPI](https://trac.ffmpeg.org/wiki/Hardware/VAAPI) engine. + +## NVidia GPU + +**Supported on:** Windows binary, Linux binary, Docker. + +Docker users should install: `alexxit/go2rtc:master-hardware`. + +Read more [here](https://docs.frigate.video/configuration/hardware_acceleration) and [here](https://jellyfin.org/docs/general/administration/hardware-acceleration/#nvidia-hardware-acceleration-on-docker-linux). + +**PS.** Supported via [CUDA](https://trac.ffmpeg.org/wiki/HWAccelIntro#CUDANVENCNVDEC) engine. + +## Raspberry Pi 3 + +**Supported on:** Linux binary, Docker, Hass Addon. + +I don't recommend using transcoding on the Raspberry Pi 3. It's extreamly slow, even with hardware acceleration. Also it may fail when transcoding 2K+ stream. + +## Raspberry Pi 4 + +*I don't have the hardware for test support!!!* + +**Supported on:** Linux binary, Docker, Hass Addon. + +**PS.** Supported via [v4l2m2m](https://lalitm.com/hw-encoding-raspi/) engine. + +## macOS + +In my tests, transcoding is faster on the M1 CPU than on the M1 GPU. Transcoding time on M1 CPU better than any Intel iGPU and comparable to NVidia RTX 2070. + +**PS.** Supported via [videotoolbox](https://trac.ffmpeg.org/wiki/HWAccelIntro#VideoToolbox) engine. + +## Rockchip + +- Important to use custom FFmpeg with Rockchip support from [@nyanmisaka](https://github.com/nyanmisaka/ffmpeg-rockchip) + - Static binaries from [@MarcA711](https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/) +- Important to have Linux kernel 5.10 or 6.1 + +**Tested** + +- [Orange Pi 3B](https://www.armbian.com/orangepi3b/) with Armbian 6.1, support transcoding H264, H265, MJPEG diff --git a/internal/ffmpeg/hardware/hardware.go b/internal/ffmpeg/hardware/hardware.go index 39ce3323..80166890 100644 --- a/internal/ffmpeg/hardware/hardware.go +++ b/internal/ffmpeg/hardware/hardware.go @@ -128,19 +128,32 @@ func MakeHardware(args *ffmpeg.Args, engine string, defaults map[string]string) case EngineRKMPP: args.Codecs[i] = defaults[name+"/"+engine] - for j, filter := range args.Filters { - if strings.HasPrefix(filter, "scale=") { - args.Filters = append(args.Filters[:j], args.Filters[j+1:]...) + if !args.HasFilters("drawtext=") { + args.Input = "-hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga " + args.Input - width, height, _ := strings.Cut(filter[6:], ":") - if width != "-1" { - args.Codecs[i] += " -width " + width + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_rkrga=" + filter[6:] + ":force_original_aspect_ratio=0" } - if height != "-1" { - args.Codecs[i] += " -height " + height + if strings.HasPrefix(filter, "transpose=") { + if filter == "transpose=1,transpose=1" { // 180 degrees half-turn + args.Filters[i] = "vpp_rkrga=transpose=4" // reversal + } else { + args.Filters[i] = "vpp_rkrga=transpose=" + filter[10:] + } } - break } + + if len(args.Filters) > 0 { + // fix if input doesn't support hwaccel, do nothing when support + // insert as first filter before hardware scale and transpose + args.InsertFilter("format=drm_prime|nv12,hwupload") + } + } else { + // enable software pixel for drawtext, scale and transpose + args.Input = "-hwaccel rkmpp -hwaccel_output_format nv12 -afbc rga " + args.Input + + args.AddFilter("hwupload") } } } diff --git a/internal/ffmpeg/hardware/hardware_unix.go b/internal/ffmpeg/hardware/hardware_unix.go index 4f688ce4..e8000e17 100644 --- a/internal/ffmpeg/hardware/hardware_unix.go +++ b/internal/ffmpeg/hardware/hardware_unix.go @@ -11,8 +11,9 @@ import ( const ( ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -" ProbeV4L2M2MH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_v4l2m2m -f null -" - ProbeRKMPPH264 = "-f lavfi -i testsrc2 -t 1 -c h264_rkmpp_encoder -f null -" - ProbeRKMPPH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_rkmpp_encoder -f null -" + ProbeRKMPPH264 = "-f lavfi -i testsrc2 -t 1 -c h264_rkmpp -f null -" + ProbeRKMPPH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_rkmpp -f null -" + ProbeRKMPPJPEG = "-f lavfi -i testsrc2 -t 1 -c mjpeg_rkmpp -f null -" ProbeVAAPIH264 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c h264_vaapi -f null -" ProbeVAAPIH265 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c hevc_vaapi -f null -" ProbeVAAPIJPEG = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c mjpeg_vaapi -f null -" @@ -39,6 +40,10 @@ func ProbeAll(bin string) []*api.Source { Name: runToString(bin, ProbeRKMPPH265), URL: "ffmpeg:...#video=h265#hardware=" + EngineRKMPP, }, + { + Name: runToString(bin, ProbeRKMPPJPEG), + URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineRKMPP, + }, } } @@ -83,6 +88,10 @@ func ProbeHardware(bin, name string) string { if run(bin, ProbeRKMPPH265) { return EngineRKMPP } + case "mjpeg": + if run(bin, ProbeRKMPPJPEG) { + return EngineRKMPP + } } return EngineSoftware diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go index d132d253..97cf3d5c 100644 --- a/internal/ffmpeg/producer.go +++ b/internal/ffmpeg/producer.go @@ -42,6 +42,7 @@ func NewProducer(url string) (core.Producer, error) { Codecs: []*core.Codec{ // OPUS will always marked as OPUS/48000/2 {Name: core.CodecOpus, ClockRate: 48000, Channels: 2}, + {Name: core.CodecPCML, ClockRate: 16000}, {Name: core.CodecPCM, ClockRate: 16000}, {Name: core.CodecPCMA, ClockRate: 16000}, {Name: core.CodecPCMU, ClockRate: 16000}, @@ -97,6 +98,8 @@ func (p *Producer) newURL() string { s += "#audio=opus" case core.CodecAAC: s += "#audio=aac/16000" + case core.CodecPCML: + s += "#audio=pcml/16000" case core.CodecPCM: s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate)) case core.CodecPCMA: diff --git a/internal/flussonic/flussonic.go b/internal/flussonic/flussonic.go new file mode 100644 index 00000000..6e874285 --- /dev/null +++ b/internal/flussonic/flussonic.go @@ -0,0 +1,10 @@ +package flussonic + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/flussonic" +) + +func Init() { + streams.HandleFunc("flussonic", flussonic.Dial) +} diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 363a7047..6c8b37ae 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -87,7 +87,7 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a switch char.Type { case camera.TypeSetupEndpoints: var offer camera.SetupEndpoints - if err := tlv8.UnmarshalBase64(value.(string), &offer); err != nil { + if err := tlv8.UnmarshalBase64(value, &offer); err != nil { return } @@ -96,7 +96,7 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a case camera.TypeSelectedStreamConfiguration: var conf camera.SelectedStreamConfig - if err := tlv8.UnmarshalBase64(value.(string), &conf); err != nil { + if err := tlv8.UnmarshalBase64(value, &conf); err != nil { return } diff --git a/internal/ivideon/ivideon.go b/internal/ivideon/ivideon.go index 03feb742..51ddb890 100644 --- a/internal/ivideon/ivideon.go +++ b/internal/ivideon/ivideon.go @@ -2,12 +2,9 @@ package ivideon import ( "github.com/AlexxIT/go2rtc/internal/streams" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ivideon" ) func Init() { - streams.HandleFunc("ivideon", func(source string) (core.Producer, error) { - return ivideon.Dial(source) - }) + streams.HandleFunc("ivideon", ivideon.Dial) } diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index 2bb7093a..27c557e4 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -36,8 +36,7 @@ func Init() { var log zerolog.Logger func handlerKeyframe(w http.ResponseWriter, r *http.Request) { - src := r.URL.Query().Get("src") - stream := streams.Get(src) + stream := streams.GetOrPatch(r.URL.Query()) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return diff --git a/internal/ring/ring.go b/internal/ring/ring.go index 673ea480..7fdb284f 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -1,10 +1,11 @@ package ring import ( - "encoding/json" "net/http" "net/url" + "fmt" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" @@ -21,8 +22,7 @@ func Init() { func apiRing(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - var ringAPI *ring.RingRestClient - var err error + var ringAPI *ring.RingApi // Check auth method if email := query.Get("email"); email != "" { @@ -30,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) { password := query.Get("password") code := query.Get("code") - ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + var err error + ringAPI, err = ring.NewRestClient(ring.EmailAuth{ Email: email, Password: password, }, nil) @@ -44,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) { if _, err = ringAPI.GetAuth(code); err != nil { if ringAPI.Using2FA { // Return 2FA prompt - json.NewEncoder(w).Encode(map[string]interface{}{ + api.ResponseJSON(w, map[string]interface{}{ "needs_2fa": true, "prompt": ringAPI.PromptFor2FA, }) @@ -53,36 +54,39 @@ func apiRing(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - } else { + } else if refreshToken := query.Get("refresh_token"); refreshToken != "" { // Refresh Token Flow - refreshToken := query.Get("refresh_token") if refreshToken == "" { http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) return } - ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ + var err error + ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{ RefreshToken: refreshToken, }, nil) + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + } else { + http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest) + return } - // Fetch devices devices, err := ringAPI.FetchRingDevices() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Create clean query with only required parameters cleanQuery := url.Values{} cleanQuery.Set("refresh_token", ringAPI.RefreshToken) var items []*api.Source for _, camera := range devices.AllCameras { + cleanQuery.Set("camera_id", fmt.Sprint(camera.ID)) cleanQuery.Set("device_id", camera.DeviceID) // Stream source diff --git a/internal/streams/api.go b/internal/streams/api.go index 061e61c2..28f09708 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -5,10 +5,14 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/AlexxIT/go2rtc/pkg/probe" ) func apiStreams(w http.ResponseWriter, r *http.Request) { + w = creds.SecretResponse(w) + query := r.URL.Query() src := query.Get("src") @@ -27,7 +31,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { return } - cons := probe.NewProbe(query) + cons := probe.Create("probe", query) if len(cons.Medias) != 0 { cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { @@ -120,5 +124,55 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } dot = append(dot, '}') + dot = []byte(creds.SecretString(string(dot))) + api.Response(w, dot, "text/vnd.graphviz") } + +func apiPreload(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + src := query.Get("src") + + // check if stream exists + stream := Get(src) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + switch r.Method { + case "PUT": + // it's safe to delete from map while iterating + for k := range query { + switch k { + case core.KindVideo, core.KindAudio, "microphone": + default: + delete(query, k) + } + } + + rawQuery := query.Encode() + + if err := AddPreload(stream, rawQuery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + case "DELETE": + if err := DelPreload(stream); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := app.PatchConfig([]string{"preload", src}, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + } +} diff --git a/internal/streams/play.go b/internal/streams/play.go index 9bec7258..1f8c4ade 100644 --- a/internal/streams/play.go +++ b/internal/streams/play.go @@ -7,7 +7,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" ) -func (s *Stream) Play(source string) error { +func (s *Stream) Play(urlOrProd any) error { s.mu.Lock() for _, producer := range s.producers { if producer.state == stateInternal && producer.conn != nil { @@ -16,12 +16,18 @@ func (s *Stream) Play(source string) error { } s.mu.Unlock() - if source == "" { - return nil - } - + var source string var src core.Producer + switch urlOrProd.(type) { + case string: + if source = urlOrProd.(string); source == "" { + return nil + } + case core.Producer: + src = urlOrProd.(core.Producer) + } + for _, producer := range s.producers { if producer.conn == nil { continue @@ -140,10 +146,12 @@ func matchMedia(prod core.Producer, cons core.Consumer) bool { track, err := prod.GetTrack(prodMedia, prodCodec) if err != nil { + log.Warn().Err(err).Msg("[streams] can't get track") continue } if err = cons.AddTrack(consMedia, consCodec, track); err != nil { + log.Warn().Err(err).Msg("[streams] can't add track") continue } diff --git a/internal/streams/preload.go b/internal/streams/preload.go new file mode 100644 index 00000000..527746ac --- /dev/null +++ b/internal/streams/preload.go @@ -0,0 +1,58 @@ +package streams + +import ( + "errors" + "net/url" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/probe" +) + +var preloads = map[*Stream]*probe.Probe{} +var preloadsMu sync.Mutex + +func Preload(stream *Stream, rawQuery string) { + if err := AddPreload(stream, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func AddPreload(stream *Stream, rawQuery string) error { + if rawQuery == "" { + rawQuery = "video&audio" + } + + query, err := url.ParseQuery(rawQuery) + if err != nil { + return err + } + + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if cons := preloads[stream]; cons != nil { + stream.RemoveConsumer(cons) + } + + cons := probe.Create("preload", query) + + if err = stream.AddConsumer(cons); err != nil { + return err + } + + preloads[stream] = cons + return nil +} + +func DelPreload(stream *Stream) error { + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if cons := preloads[stream]; cons != nil { + stream.RemoveConsumer(cons) + delete(preloads, stream) + return nil + } + + return errors.New("streams: preload not found") +} diff --git a/internal/streams/streams.go b/internal/streams/streams.go index dcbaba28..a0b1ed68 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -14,8 +14,9 @@ import ( func Init() { var cfg struct { - Streams map[string]any `yaml:"streams"` - Publish map[string]any `yaml:"publish"` + Streams map[string]any `yaml:"streams"` + Publish map[string]any `yaml:"publish"` + Preload map[string]string `yaml:"preload"` } app.LoadConfig(&cfg) @@ -28,17 +29,24 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) + api.HandleFunc("api/preload", apiPreload) - if cfg.Publish == nil { + if cfg.Publish == nil && cfg.Preload == nil { return } time.AfterFunc(time.Second, func() { + // range for nil map is OK for name, dst := range cfg.Publish { if stream := Get(name); stream != nil { Publish(stream, dst) } } + for name, rawQuery := range cfg.Preload { + if stream := Get(name); stream != nil { + Preload(stream, rawQuery) + } + } }) } diff --git a/internal/v4l2/v4l2.go b/internal/v4l2/v4l2.go index 9cef99a5..3f2e62e6 100644 --- a/internal/v4l2/v4l2.go +++ b/internal/v4l2/v4l2.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !(linux && (386 || arm || mipsle || amd64 || arm64)) package v4l2 diff --git a/internal/v4l2/v4l2_linux.go b/internal/v4l2/v4l2_linux.go index 2cd60692..0bb05473 100644 --- a/internal/v4l2/v4l2_linux.go +++ b/internal/v4l2/v4l2_linux.go @@ -1,3 +1,5 @@ +//go:build linux && (386 || arm || mipsle || amd64 || arm64) + package v4l2 import ( diff --git a/internal/webrtc/candidates.go b/internal/webrtc/candidates.go index a15c4e7d..1138db76 100644 --- a/internal/webrtc/candidates.go +++ b/internal/webrtc/candidates.go @@ -8,7 +8,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/xnet" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) type Address struct { diff --git a/internal/webrtc/client.go b/internal/webrtc/client.go index 106b603e..5fbf2175 100644 --- a/internal/webrtc/client.go +++ b/internal/webrtc/client.go @@ -15,7 +15,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) // streamsHandler supports: diff --git a/internal/webrtc/client_creality.go b/internal/webrtc/client_creality.go index 0a3685a9..4618044e 100644 --- a/internal/webrtc/client_creality.go +++ b/internal/webrtc/client_creality.go @@ -10,6 +10,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/pion/sdp/v3" ) // https://github.com/AlexxIT/go2rtc/issues/1600 @@ -27,7 +28,6 @@ func crealityClient(url string) (core.Producer, error) { medias := []*core.Media{ {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, - {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, } // TODO: return webrtc.SessionDescription @@ -36,6 +36,8 @@ func crealityClient(url string) (core.Producer, error) { return nil, err } + log.Trace().Msgf("[webrtc] offer:\n%s", offer) + body, err := offerToB64(offer) if err != nil { return nil, err @@ -61,6 +63,12 @@ func crealityClient(url string) (core.Producer, error) { return nil, err } + log.Trace().Msgf("[webrtc] answer:\n%s", answer) + + if answer, err = fixCrealitySDP(answer); err != nil { + return nil, err + } + if err = prod.SetAnswer(answer); err != nil { return nil, err } @@ -108,3 +116,37 @@ func answerFromB64(r io.Reader) (string, error) { // string "v=0..." return v["sdp"], nil } + +func fixCrealitySDP(value string) (string, error) { + var sd sdp.SessionDescription + if err := sd.UnmarshalString(value); err != nil { + return "", err + } + + md := sd.MediaDescriptions[0] + + // important to skip first codec, because second codec will be used + skip := md.MediaName.Formats[0] + md.MediaName.Formats = md.MediaName.Formats[1:] + + attrs := make([]sdp.Attribute, 0, len(md.Attributes)) + for _, attr := range md.Attributes { + switch attr.Key { + case "fmtp", "rtpmap": + // important to skip fmtp with x-google, because this is second fmtp for same codec + // and pion library will fail parsing this SDP + if strings.HasPrefix(attr.Value, skip) || strings.Contains(attr.Value, "x-google") { + continue + } + } + attrs = append(attrs, attr) + } + + md.Attributes = attrs + + b, err := sd.Marshal() + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/webrtc/kinesis.go b/internal/webrtc/kinesis.go index b11d1d31..8bfaeb9b 100644 --- a/internal/webrtc/kinesis.go +++ b/internal/webrtc/kinesis.go @@ -12,7 +12,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) type kinesisRequest struct { diff --git a/internal/webrtc/milestone.go b/internal/webrtc/milestone.go index 6a696cb0..fe1cedcf 100644 --- a/internal/webrtc/milestone.go +++ b/internal/webrtc/milestone.go @@ -12,7 +12,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/webrtc" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) // This package handles the Milestone WebRTC session lifecycle, including authentication, diff --git a/internal/webrtc/openipc.go b/internal/webrtc/openipc.go index 8a951d04..2f2db119 100644 --- a/internal/webrtc/openipc.go +++ b/internal/webrtc/openipc.go @@ -9,7 +9,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) func openIPCClient(rawURL string, query url.Values) (core.Producer, error) { diff --git a/internal/webrtc/server.go b/internal/webrtc/server.go index 51565a74..48bd5380 100644 --- a/internal/webrtc/server.go +++ b/internal/webrtc/server.go @@ -13,7 +13,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) const MimeSDP = "application/sdp" diff --git a/internal/webrtc/switchbot.go b/internal/webrtc/switchbot.go index 5ece88ae..6f72e55d 100644 --- a/internal/webrtc/switchbot.go +++ b/internal/webrtc/switchbot.go @@ -33,8 +33,12 @@ func switchbotClient(rawURL string, query url.Values) (core.Producer, error) { v.Resolution = 0 case "sd": v.Resolution = 1 + case "auto": + v.Resolution = 2 } + v.PlayType = core.Atoi(query.Get("play_type")) // zero by default + return v, nil }) } diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index 989600f9..11e9db89 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -10,7 +10,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" "github.com/rs/zerolog" ) diff --git a/internal/webrtc/webrtc_test.go b/internal/webrtc/webrtc_test.go index e014c31c..1f82a0a7 100644 --- a/internal/webrtc/webrtc_test.go +++ b/internal/webrtc/webrtc_test.go @@ -2,10 +2,11 @@ package webrtc import ( "encoding/json" + "strings" "testing" "github.com/AlexxIT/go2rtc/internal/api/ws" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" ) @@ -36,3 +37,37 @@ func TestWebRTCAPIv2(t *testing.T) { require.Equal(t, "v=0\n...", offer.SDP) require.Equal(t, "stun:stun.l.google.com:19302", offer.ICEServers[0].URLs[0]) } + +func TestCrealitySDP(t *testing.T) { + sdp := `v=0 +o=- 1495799811084970 1495799811084970 IN IP4 0.0.0.0 +s=- +t=0 0 +a=msid-semantic:WMS * +a=group:BUNDLE 0 +m=video 9 UDP/TLS/RTP/SAVPF 96 98 +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=fmtp:96 profile-level-id=42e01f;level-asymmetry-allowed=1 +a=fmtp:98 profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1 +a=fmtp:98 x-google-max-bitrate=6000;x-google-min-bitrate=2000;x-google-start-bitrate=4000 +a=rtpmap:96 H264/90000 +a=rtpmap:98 H264/90000 +a=ssrc:1 cname:pear +c=IN IP4 0.0.0.0 +a=sendonly +a=mid:0 +a=rtcp-mux +a=ice-ufrag:7AVa +a=ice-pwd:T+F/5y05Paw+mtG5Jrd8N3 +a=ice-options:trickle +a=fingerprint:sha-256 A5:AB:C0:4E:29:5B:BD:3B:7D:88:24:6C:56:6B:2A:79:A3:76:99:35:57:75:AD:C8:5A:A6:34:20:88:1B:68:EF +a=setup:passive +a=candidate:1 1 UDP 2015363327 172.22.233.10 48929 typ host +a=candidate:2 1 TCP 1015021823 172.22.233.10 0 typ host tcptype active +a=candidate:3 1 TCP 1010827519 172.22.233.10 60677 typ host tcptype passive +` + sdp, err := fixCrealitySDP(sdp) + require.Nil(t, err) + require.False(t, strings.Contains(sdp, "x-google-max-bitrate")) +} diff --git a/internal/wyoming/README.md b/internal/wyoming/README.md new file mode 100644 index 00000000..f98acb05 --- /dev/null +++ b/internal/wyoming/README.md @@ -0,0 +1,281 @@ +# Wyoming + +This module provide [Wyoming Protocol](https://www.home-assistant.io/integrations/wyoming/) support to create local voice assistants using [Home Assistant](https://www.home-assistant.io/). + +- go2rtc can act as [Wyoming Satellite](https://github.com/rhasspy/wyoming-satellite) +- go2rtc can act as [Wyoming External Microphone](https://github.com/rhasspy/wyoming-mic-external) +- go2rtc can act as [Wyoming External Sound](https://github.com/rhasspy/wyoming-snd-external) +- any supported audio source with PCM codec can be used as audio input +- any supported two-way audio source with PCM codec can be used as audio output +- any desktop/server microphone/speaker can be used as two-way audio source + - supported any OS via FFmpeg or any similar software + - supported Linux via alsa source +- you can change the behavior using the built-in scripting engine + +## Typical Voice Pipeline + +1. Audio stream (MIC) + - any audio source with PCM codec support (include PCMA/PCMU) +2. Voice Activity Detector (VAD) +3. Wake Word (WAKE) + - [OpenWakeWord](https://www.home-assistant.io/voice_control/create_wake_word/) +4. Speech-to-Text (STT) + - [Whisper](https://github.com/home-assistant/addons/blob/master/whisper/README.md) + - [Vosk](https://github.com/rhasspy/hassio-addons/blob/master/vosk/README.md) +5. Conversation agent (INTENT) + - [Home Assistant](https://www.home-assistant.io/integrations/conversation/) +6. Text-to-speech (TTS) + - [Google Translate](https://www.home-assistant.io/integrations/google_translate/) + - [Piper](https://github.com/home-assistant/addons/blob/master/piper/README.md) +7. Audio stream (SND) + - any source with two-way audio (backchannel) and PCM codec support (include PCMA/PCMU) + +You can use a large number of different projects for WAKE, STT, INTENT and TTS thanks to the Home Assistant. + +And you can use a large number of different technologies for MIC and SND thanks to Go2rtc. + +## Configuration + +You can optionally specify WAKE service. So go2rtc will start transmitting audio to Home Assistant only after WAKE word. If the WAKE service cannot be connected to or not specified - go2rtc will pass all audio to Home Assistant. In this case WAKE service must be configured in your Voice Assistant pipeline. + +You can optionally specify VAD threshold. So go2rtc will start transmitting audio to WAKE service only after some audio noise. + +Your stream must support audio transmission in PCM codec (include PCMA/PCMU). + +```yaml +wyoming: + stream_name_from_streams_section: + listen: :10700 + name: "My Satellite" # optional name + wake_uri: tcp://192.168.1.23:10400 # optional WAKE service + vad_threshold: 1 # optional VAD threshold (from 0.1 to 3.5) +``` + +Home Assistant -> Settings -> Integrations -> Add -> Wyoming Protocol -> Host + Port from `go2rtc.yaml` + +Select one or multiple wake words: +```yaml +wake_uri: tcp://192.168.1.23:10400?name=alexa_v0.1&name=hey_jarvis_v0.1&name=hey_mycroft_v0.1&name=hey_rhasspy_v0.1&name=ok_nabu_v0.1 +``` + +## Events + +You can add wyoming event handling using the [expr](https://github.com/AlexxIT/go2rtc/blob/master/internal/expr/README.md) language. For example, to pronounce TTS on some media player from HA. + +Turn on the logs to see what kind of events happens. + +This is what the default scripts look like: + +```yaml +wyoming: + script_example: + event: + run-satellite: Detect() + pause-satellite: Stop() + voice-stopped: Pause() + audio-stop: PlayAudio() && WriteEvent("played") && Detect() + error: Detect() + internal-run: WriteEvent("run-pipeline", '{"start_stage":"wake","end_stage":"tts"}') && Stream() + internal-detection: WriteEvent("run-pipeline", '{"start_stage":"asr","end_stage":"tts"}') && Stream() +``` + +Supported functions and variables: + +- `Detect()` - start the VAD and WAKE word detection process +- `Stream()` - start transmission of audio data to the client (Home Assistant) +- `Stop()` - stop and disconnect stream without disconnecting client (Home Assistant) +- `Pause()` - temporary pause of audio transfer, without disconnecting the stream +- `PlayAudio()` - playing the last audio that was sent from client (Home Assistant) +- `WriteEvent(type, data)` - send event to client (Home Assistant) +- `Sleep(duration)` - temporary script pause (ex. `Sleep('1.5s')`) +- `PlayFile(path)` - play audio from `wav` file +- `Type` - type (name) of event +- `Data` - event data in JSON format (ex. `{"text":"how are you"}`) +- also available other functions from [expr](https://github.com/AlexxIT/go2rtc/blob/master/internal/expr/README.md) module (ex. `fetch`) + +If you write a script for an event - the default action is no longer executed. You need to repeat the necessary steps yourself. + +In addition to the standard events, there are two additional events: + +- `internal-run` - called after `Detect()` when VAD detected, but WAKE service unavailable +- `internal-detection` - called after `Detect()` when WAKE word detected + +**Example 1.** You want to play a sound file when a wake word detected (only `wav` supported): + +- `PlayFile` and `PlayAudio` functions are executed synchronously, the following steps will be executed only after they are completed + +```yaml +wyoming: + script_example: + event: + internal-detection: PlayFile('/media/beep.wav') && WriteEvent("run-pipeline", '{"start_stage":"asr","end_stage":"tts"}') && Stream() +``` + +**Example 2.** You want to play TTS on a Home Assistant media player: + +Each event has a `Type` and `Data` in JSON format. You can use their values in scripts. + +- in the `synthesize` step, we get the value of the `text` and call the HA REST API +- in the `audio-stop` step we get the duration of the TTS in seconds, wait for this time and start the pipeline again + +```yaml +wyoming: + script_example: + event: + synthesize: | + let text = fromJSON(Data).text; + let token = 'eyJhbGci...'; + fetch('http://localhost:8123/api/services/tts/speak', { + method: 'POST', + headers: {'Authorization': 'Bearer '+token,'Content-Type': 'application/json'}, + body: toJSON({ + entity_id: 'tts.google_translate_com', + media_player_entity_id: 'media_player.google_nest', + message: text, + language: 'en', + }), + }).ok + audio-stop: | + let timestamp = fromJSON(Data).timestamp; + let delay = string(timestamp)+'s'; + Sleep(delay) && WriteEvent("played") && Detect() +``` + +## Config examples + +Satellite on Windows server using FFmpeg and FFplay. + +```yaml +streams: + satellite_win: + - exec:ffmpeg -hide_banner -f dshow -i "audio=Microphone (High Definition Audio Device)" -c pcm_s16le -ar 16000 -ac 1 -f wav - + - exec:ffplay -hide_banner -nodisp -probesize 32 -f s16le -ar 22050 -#backchannel=1#audio=s16le/22050 + +wyoming: + satellite_win: + listen: :10700 + name: "Windows Satellite" + wake_uri: tcp://192.168.1.23:10400 + vad_threshold: 1 +``` + +Satellite on Dahua camera with two-way audio support. + +```yaml +streams: + dahua_camera: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif + +wyoming: + dahua_camera: + listen: :10700 + name: "Dahua Satellite" + wake_uri: tcp://192.168.1.23:10400 + vad_threshold: 1 +``` + +Satellite on external wyoming Microphone and Sound. + +```yaml +streams: + wyoming_external: + - wyoming://192.168.1.23:10600 # wyoming-mic-external + - wyoming://192.168.1.23:10601?backchannel=1 # wyoming-snd-external + +wyoming: + wyoming_external: + listen: :10700 + name: "Wyoming Satellite" + wake_uri: tcp://192.168.1.23:10400 + vad_threshold: 1 +``` + +## Wyoming External Microphone and Sound + +Advanced users, who want to enjoy the [Wyoming Satellite](https://github.com/rhasspy/wyoming-satellite) project, can use go2rtc as a [Wyoming External Microphone](https://github.com/rhasspy/wyoming-mic-external) or [Wyoming External Sound](https://github.com/rhasspy/wyoming-snd-external). + +**go2rtc.yaml** + +```yaml +streams: + wyoming_mic_external: + - exec:ffmpeg -hide_banner -f dshow -i "audio=Microphone (High Definition Audio Device)" -c pcm_s16le -ar 16000 -ac 1 -f wav - + wyoming_snd_external: + - exec:ffplay -hide_banner -nodisp -probesize 32 -f s16le -ar 22050 -#backchannel=1#audio=s16le/22050 + +wyoming: + wyoming_mic_external: + listen: :10600 + mode: mic + wyoming_snd_external: + listen: :10601 + mode: snd +``` + +**docker-compose.yml** + +```yaml +version: "3.8" +services: + satellite: + build: wyoming-satellite # https://github.com/rhasspy/wyoming-satellite + ports: + - "10700:10700" + command: + - "--name" + - "my satellite" + - "--mic-uri" + - "tcp://192.168.1.23:10600" + - "--snd-uri" + - "tcp://192.168.1.23:10601" + - "--debug" +``` + +## Wyoming External Source + +**go2rtc.yaml** + +```yaml +streams: + wyoming_external: + - wyoming://192.168.1.23:10600 + - wyoming://192.168.1.23:10601?backchannel=1 +``` + +**docker-compose.yml** + +```yaml +version: "3.8" +services: + microphone: + build: wyoming-mic-external # https://github.com/rhasspy/wyoming-mic-external + ports: + - "10600:10600" + devices: + - /dev/snd:/dev/snd + group_add: + - audio + command: + - "--device" + - "sysdefault" + - "--debug" + playback: + build: wyoming-snd-external # https://github.com/rhasspy/wyoming-snd-external + ports: + - "10601:10601" + devices: + - /dev/snd:/dev/snd + group_add: + - audio + command: + - "--device" + - "sysdefault" + - "--debug" +``` + +## Debug + +```yaml +log: + wyoming: trace +``` diff --git a/internal/wyoming/wyoming.go b/internal/wyoming/wyoming.go new file mode 100644 index 00000000..065275c3 --- /dev/null +++ b/internal/wyoming/wyoming.go @@ -0,0 +1,106 @@ +package wyoming + +import ( + "net" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/wyoming" + "github.com/rs/zerolog" +) + +func Init() { + streams.HandleFunc("wyoming", wyoming.Dial) + + // server + var cfg struct { + Mod map[string]struct { + Listen string `yaml:"listen"` + Name string `yaml:"name"` + Mode string `yaml:"mode"` + Event map[string]string `yaml:"event"` + WakeURI string `yaml:"wake_uri"` + VADThreshold float32 `yaml:"vad_threshold"` + } `yaml:"wyoming"` + } + app.LoadConfig(&cfg) + + log = app.GetLogger("wyoming") + + for name, cfg := range cfg.Mod { + stream := streams.Get(name) + if stream == nil { + log.Warn().Msgf("[wyoming] missing stream: %s", name) + continue + } + + if cfg.Name == "" { + cfg.Name = name + } + + srv := &wyoming.Server{ + Name: cfg.Name, + Event: cfg.Event, + VADThreshold: int16(1000 * cfg.VADThreshold), // 1.0 => 1000 + WakeURI: cfg.WakeURI, + MicHandler: func(cons core.Consumer) error { + if err := stream.AddConsumer(cons); err != nil { + return err + } + // not best solution + if i, ok := cons.(interface{ OnClose(func()) }); ok { + i.OnClose(func() { + stream.RemoveConsumer(cons) + }) + } + return nil + }, + SndHandler: func(prod core.Producer) error { + return stream.Play(prod) + }, + Trace: func(format string, v ...any) { + log.Trace().Msgf("[wyoming] "+format, v...) + }, + Error: func(format string, v ...any) { + log.Error().Msgf("[wyoming] "+format, v...) + }, + } + go serve(srv, cfg.Mode, cfg.Listen) + } +} + +var log zerolog.Logger + +func serve(srv *wyoming.Server, mode, address string) { + ln, err := net.Listen("tcp", address) + if err != nil { + log.Warn().Err(err).Msgf("[wyoming] listen") + } + + for { + conn, err := ln.Accept() + if err != nil { + return + } + + go handle(srv, mode, conn) + } +} + +func handle(srv *wyoming.Server, mode string, conn net.Conn) { + addr := conn.RemoteAddr() + + log.Trace().Msgf("[wyoming] %s connected", addr) + + switch mode { + case "mic": + srv.HandleMic(conn) + case "snd": + srv.HandleSnd(conn) + default: + srv.Handle(conn) + } + + log.Trace().Msgf("[wyoming] %s disconnected", addr) +} diff --git a/internal/yandex/README.md b/internal/yandex/README.md new file mode 100644 index 00000000..951e1e99 --- /dev/null +++ b/internal/yandex/README.md @@ -0,0 +1,22 @@ +# Yandex + +Source for receiving stream from new [Yandex IP camera](https://alice.yandex.ru/smart-home/security/ipcamera). + +## Get Yandex token + +1. Install HomeAssistant integration [YandexStation](https://github.com/AlexxIT/YandexStation). +2. Copy token from HomeAssistant config folder: `/config/.storage/core.config_entries`, key: `"x_token"`. + +## Get device ID + +1. Open this link in any browser: https://iot.quasar.yandex.ru/m/v3/user/devices +2. Copy ID of your camera, key: `"id"`. + +## Config examples + +```yaml +streams: + yandex_stream: yandex:?x_token=XXXX&device_id=XXXX + yandex_snapshot: yandex:?x_token=XXXX&device_id=XXXX&snapshot + yandex_snapshot_custom_size: yandex:?x_token=XXXX&device_id=XXXX&snapshot=h=540 +``` diff --git a/internal/yandex/goloom.go b/internal/yandex/goloom.go new file mode 100644 index 00000000..6bccb756 --- /dev/null +++ b/internal/yandex/goloom.go @@ -0,0 +1,152 @@ +package yandex + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/pkg/core" + xwebrtc "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +func goloomClient(serviceURL, serviceName, roomId, participantId, credentials string) (core.Producer, error) { + conn, _, err := websocket.DefaultDialer.Dial(serviceURL, nil) + if err != nil { + return nil, err + } + defer func() { + time.Sleep(time.Second) + _ = conn.Close() + }() + + s := fmt.Sprintf(`{"hello": { +"credentials":"%s","participantId":"%s","roomId":"%s","serviceName":"%s","sdkInitializationId":"%s", +"capabilitiesOffer":{},"sendAudio":false,"sendSharing":false,"sendVideo":false, +"sdkInfo":{"hwConcurrency":4,"implementation":"browser","version":"5.4.0"}, +"participantAttributes":{"description":"","name":"mike","role":"SPEAKER"}, +"participantMeta":{"description":"","name":"mike","role":"SPEAKER","sendAudio":false,"sendVideo":false} +},"uid":"%s"}`, + credentials, participantId, roomId, serviceName, + uuid.NewString(), uuid.NewString(), + ) + + err = conn.WriteMessage(websocket.TextMessage, []byte(s)) + if err != nil { + return nil, err + } + + if _, _, err = conn.ReadMessage(); err != nil { + return nil, err + } + + pc, err := webrtc.PeerConnection(true) + if err != nil { + return nil, err + } + + prod := xwebrtc.NewConn(pc) + prod.FormatName = "yandex" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "wss" + + var connState core.Waiter + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + go func() { + for { + var msg map[string]json.RawMessage + if err = conn.ReadJSON(&msg); err != nil { + return + } + + for k, v := range msg { + switch k { + case "uid": + continue + case "serverHello": + case "subscriberSdpOffer": + var sdp subscriberSdp + if err = json.Unmarshal(v, &sdp); err != nil { + return + } + //log.Trace().Msgf("offer:\n%s", sdp.Sdp) + if err = prod.SetOffer(sdp.Sdp); err != nil { + return + } + if sdp.Sdp, err = prod.GetAnswer(); err != nil { + return + } + //log.Trace().Msgf("answer:\n%s", sdp.Sdp) + + var raw []byte + if raw, err = json.Marshal(sdp); err != nil { + return + } + s = fmt.Sprintf(`{"uid":"%s","subscriberSdpAnswer":%s}`, uuid.NewString(), raw) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return + } + case "webrtcIceCandidate": + var candidate webrtcIceCandidate + if err = json.Unmarshal(v, &candidate); err != nil { + return + } + if err = prod.AddCandidate(candidate.Candidate); err != nil { + return + } + } + //log.Trace().Msgf("%s : %s", k, v) + } + + if msg["ack"] != nil { + continue + } + + s = fmt.Sprintf(`{"uid":%s,"ack":{"status":{"code":"OK"}}}`, msg["uid"]) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return + } + } + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + s = fmt.Sprintf(`{"uid":"%s","setSlots":{"slots":[{"width":0,"height":0}],"audioSlotsCount":0,"key":1,"shutdownAllVideo":false,"withSelfView":false,"selfViewVisibility":"ON_LOADING_THEN_HIDE","gridConfig":{}}}`, uuid.NewString()) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return nil, err + } + + return prod, nil +} + +type subscriberSdp struct { + PcSeq int `json:"pcSeq"` + Sdp string `json:"sdp"` +} + +type webrtcIceCandidate struct { + PcSeq int `json:"pcSeq"` + Target string `json:"target"` + Candidate string `json:"candidate"` + SdpMid string `json:"sdpMid"` + SdpMlineIndex int `json:"sdpMlineIndex"` +} diff --git a/internal/yandex/yandex.go b/internal/yandex/yandex.go new file mode 100644 index 00000000..05680b30 --- /dev/null +++ b/internal/yandex/yandex.go @@ -0,0 +1,44 @@ +package yandex + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/yandex" +) + +func Init() { + streams.HandleFunc("yandex", func(source string) (core.Producer, error) { + u, err := url.Parse(source) + if err != nil { + return nil, err + } + + query := u.Query() + token := query.Get("x_token") + + session, err := yandex.GetSession(token) + if err != nil { + return nil, err + } + + deviceID := query.Get("device_id") + + if query.Has("snapshot") { + rawURL, err := session.GetSnapshotURL(deviceID) + if err != nil { + return nil, err + } + rawURL += "/current.jpg?" + query.Get("snapshot") + "#header=Cookie:" + session.GetCookieString(rawURL) + return streams.GetProducer(rawURL) + } + + room, err := session.WebrtcCreateRoom(deviceID) + if err != nil { + return nil, err + } + + return goloomClient(room.ServiceUrl, room.ServiceName, room.RoomId, room.ParticipantId, room.Credentials) + }) +} diff --git a/main.go b/main.go index 5708f973..0cfc31fb 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "github.com/AlexxIT/go2rtc/internal/alsa" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/app" @@ -9,9 +10,11 @@ import ( "github.com/AlexxIT/go2rtc/internal/doorbird" "github.com/AlexxIT/go2rtc/internal/dvrip" "github.com/AlexxIT/go2rtc/internal/echo" + "github.com/AlexxIT/go2rtc/internal/eseecloud" "github.com/AlexxIT/go2rtc/internal/exec" "github.com/AlexxIT/go2rtc/internal/expr" "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/flussonic" "github.com/AlexxIT/go2rtc/internal/gopro" "github.com/AlexxIT/go2rtc/internal/hass" "github.com/AlexxIT/go2rtc/internal/hls" @@ -35,11 +38,13 @@ import ( "github.com/AlexxIT/go2rtc/internal/v4l2" "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" + "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/pkg/shell" ) func main() { - app.Version = "1.9.8" + app.Version = "1.9.10" // 1. Core modules: app, api/ws, streams @@ -66,6 +71,7 @@ func main() { 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 @@ -88,6 +94,10 @@ func main() { 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 diff --git a/pkg/README.md b/pkg/README.md index b12f0a70..e2759638 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -13,6 +13,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent | Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example | |--------------|------------------|-------------------|------------------------------|--------------------|---------------| | adts | http,tcp,pipe | http | aac | | `http:` | +| alsa | pipe | | | pcm | `alsa:` | | bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | | dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | | flv | http,tcp,pipe | http | h264,aac | | `http:` | diff --git a/pkg/aac/aac.go b/pkg/aac/aac.go index 5ce4e82d..dc961fc4 100644 --- a/pkg/aac/aac.go +++ b/pkg/aac/aac.go @@ -53,7 +53,7 @@ func ConfigToCodec(conf []byte) *core.Codec { codec.ClockRate = rd.ReadBits(24) } - codec.Channels = rd.ReadBits16(4) + codec.Channels = rd.ReadBits8(4) return codec } diff --git a/pkg/aac/adts.go b/pkg/aac/adts.go index 94a13ad7..6688d319 100644 --- a/pkg/aac/adts.go +++ b/pkg/aac/adts.go @@ -28,7 +28,7 @@ func ADTSToCodec(b []byte) *core.Codec { objType := rd.ReadBits8(2) + 1 // Profile, the MPEG-4 Audio Object Type minus 1 sampleRateIdx := rd.ReadBits8(4) // MPEG-4 Sampling Frequency Index _ = rd.ReadBit() // Private bit, guaranteed never to be used by MPEG, set to 0 when encoding, ignore when decoding - channels := rd.ReadBits16(3) // MPEG-4 Channel Configuration + channels := rd.ReadBits8(3) // MPEG-4 Channel Configuration //_ = rd.ReadBit() // Originality, set to 1 to signal originality of the audio and 0 otherwise //_ = rd.ReadBit() // Home, set to 1 to signal home usage of the audio and 0 otherwise @@ -43,7 +43,7 @@ func ADTSToCodec(b []byte) *core.Codec { wr := bits.NewWriter(nil) wr.WriteBits8(objType, 5) wr.WriteBits8(sampleRateIdx, 4) - wr.WriteBits16(channels, 4) + wr.WriteBits8(channels, 4) conf := wr.Bytes() codec := &core.Codec{ diff --git a/pkg/alsa/README.md b/pkg/alsa/README.md new file mode 100644 index 00000000..b644af11 --- /dev/null +++ b/pkg/alsa/README.md @@ -0,0 +1,23 @@ +## Build + +```shell +x86_64-linux-gnu-gcc -w -static asound_arch.c -o asound_amd64 +i686-linux-gnu-gcc -w -static asound_arch.c -o asound_i386 +aarch64-linux-gnu-gcc -w -static asound_arch.c -o asound_arm64 +arm-linux-gnueabihf-gcc -w -static asound_arch.c -o asound_arm +mipsel-linux-gnu-gcc -w -static asound_arch.c -o asound_mipsle -D_TIME_BITS=32 +``` + +## Useful links + +- https://github.com/torvalds/linux/blob/master/include/uapi/sound/asound.h +- https://github.com/yobert/alsa +- https://github.com/Narsil/alsa-go +- https://github.com/alsa-project/alsa-lib +- https://github.com/anisse/alsa +- https://github.com/tinyalsa/tinyalsa + +**Broken pipe** + +- https://stackoverflow.com/questions/26545139/alsa-cannot-recovery-from-underrun-prepare-failed-broken-pipe +- https://klipspringer.avadeaux.net/alsa-broken-pipe-errors/ diff --git a/pkg/alsa/capture_linux.go b/pkg/alsa/capture_linux.go new file mode 100644 index 00000000..54a7d679 --- /dev/null +++ b/pkg/alsa/capture_linux.go @@ -0,0 +1,90 @@ +package alsa + +import ( + "github.com/AlexxIT/go2rtc/pkg/alsa/device" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +type Capture struct { + core.Connection + dev *device.Device + closed core.Waiter +} + +func newCapture(dev *device.Device) (*Capture, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 16000}, + }, + }, + } + return &Capture{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "alsa", + Medias: medias, + Transport: dev, + }, + dev: dev, + }, nil +} + +func (c *Capture) Start() error { + dst := c.Medias[0].Codecs[0] + src := &core.Codec{ + Name: dst.Name, + ClockRate: c.dev.GetRateNear(dst.ClockRate), + Channels: c.dev.GetChannelsNear(dst.Channels), + } + + if err := c.dev.SetHWParams(device.SNDRV_PCM_FORMAT_S16_LE, src.ClockRate, src.Channels); err != nil { + return err + } + + transcode := transcodeFunc(dst, src) + frameBytes := int(pcm.BytesPerFrame(src)) + + var ts uint32 + + // readBufferSize for 20ms interval + readBufferSize := 20 * frameBytes * int(src.ClockRate) / 1000 + b := make([]byte, readBufferSize) + for { + n, err := c.dev.Read(b) + if err != nil { + return err + } + + c.Recv += n + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + Timestamp: ts, + }, + Payload: transcode(b[:n]), + } + c.Receivers[0].WriteRTP(pkt) + + ts += uint32(n / frameBytes) + } +} + +func transcodeFunc(dst, src *core.Codec) func([]byte) []byte { + if dst.ClockRate == src.ClockRate && dst.Channels == src.Channels { + return func(b []byte) []byte { + return b + } + } + return pcm.Transcode(dst, src) +} diff --git a/pkg/alsa/device/asound_32bit.go b/pkg/alsa/device/asound_32bit.go new file mode 100644 index 00000000..428c876a --- /dev/null +++ b/pkg/alsa/device/asound_32bit.go @@ -0,0 +1,148 @@ +//go:build 386 || arm + +package device + +type unsigned_char = byte +type signed_int = int32 +type unsigned_int = uint32 +type signed_long = int64 +type unsigned_long = uint64 +type __u32 = uint32 +type void__user = uintptr + +const ( + SNDRV_PCM_STREAM_PLAYBACK = 0 + SNDRV_PCM_STREAM_CAPTURE = 1 + + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED = 0 + SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED = 1 + SNDRV_PCM_ACCESS_MMAP_COMPLEX = 2 + SNDRV_PCM_ACCESS_RW_INTERLEAVED = 3 + SNDRV_PCM_ACCESS_RW_NONINTERLEAVED = 4 + + SNDRV_PCM_FORMAT_S8 = 0 + SNDRV_PCM_FORMAT_U8 = 1 + SNDRV_PCM_FORMAT_S16_LE = 2 + SNDRV_PCM_FORMAT_S16_BE = 3 + SNDRV_PCM_FORMAT_U16_LE = 4 + SNDRV_PCM_FORMAT_U16_BE = 5 + SNDRV_PCM_FORMAT_S24_LE = 6 + SNDRV_PCM_FORMAT_S24_BE = 7 + SNDRV_PCM_FORMAT_U24_LE = 8 + SNDRV_PCM_FORMAT_U24_BE = 9 + SNDRV_PCM_FORMAT_S32_LE = 10 + SNDRV_PCM_FORMAT_S32_BE = 11 + SNDRV_PCM_FORMAT_U32_LE = 12 + SNDRV_PCM_FORMAT_U32_BE = 13 + SNDRV_PCM_FORMAT_FLOAT_LE = 14 + SNDRV_PCM_FORMAT_FLOAT_BE = 15 + SNDRV_PCM_FORMAT_FLOAT64_LE = 16 + SNDRV_PCM_FORMAT_FLOAT64_BE = 17 + SNDRV_PCM_FORMAT_MU_LAW = 20 + SNDRV_PCM_FORMAT_A_LAW = 21 + SNDRV_PCM_FORMAT_MPEG = 23 + + SNDRV_PCM_IOCTL_PVERSION = 0x80044100 + SNDRV_PCM_IOCTL_INFO = 0x81204101 + SNDRV_PCM_IOCTL_HW_REFINE = 0xc25c4110 + SNDRV_PCM_IOCTL_HW_PARAMS = 0xc25c4111 + SNDRV_PCM_IOCTL_SW_PARAMS = 0xc0684113 + SNDRV_PCM_IOCTL_PREPARE = 0x00004140 + SNDRV_PCM_IOCTL_WRITEI_FRAMES = 0x400c4150 + SNDRV_PCM_IOCTL_READI_FRAMES = 0x800c4151 +) + +type snd_pcm_info struct { // size 288 + device unsigned_int // offset 0, size 4 + subdevice unsigned_int // offset 4, size 4 + stream signed_int // offset 8, size 4 + card signed_int // offset 12, size 4 + id [64]unsigned_char // offset 16, size 64 + name [80]unsigned_char // offset 80, size 80 + subname [32]unsigned_char // offset 160, size 32 + dev_class signed_int // offset 192, size 4 + dev_subclass signed_int // offset 196, size 4 + subdevices_count unsigned_int // offset 200, size 4 + subdevices_avail unsigned_int // offset 204, size 4 + pad1 [16]unsigned_char + reserved [64]unsigned_char // offset 224, size 64 +} + +type snd_pcm_uframes_t = unsigned_long +type snd_pcm_sframes_t = signed_long + +type snd_xferi struct { // size 12 + result snd_pcm_sframes_t // offset 0, size 4 + buf void__user // offset 4, size 4 + frames snd_pcm_uframes_t // offset 8, size 4 +} + +const ( + SNDRV_PCM_HW_PARAM_ACCESS = 0 + SNDRV_PCM_HW_PARAM_FORMAT = 1 + SNDRV_PCM_HW_PARAM_SUBFORMAT = 2 + SNDRV_PCM_HW_PARAM_FIRST_MASK = 0 + SNDRV_PCM_HW_PARAM_LAST_MASK = 2 + + SNDRV_PCM_HW_PARAM_SAMPLE_BITS = 8 + SNDRV_PCM_HW_PARAM_FRAME_BITS = 9 + SNDRV_PCM_HW_PARAM_CHANNELS = 10 + SNDRV_PCM_HW_PARAM_RATE = 11 + SNDRV_PCM_HW_PARAM_PERIOD_TIME = 12 + SNDRV_PCM_HW_PARAM_PERIOD_SIZE = 13 + SNDRV_PCM_HW_PARAM_PERIOD_BYTES = 14 + SNDRV_PCM_HW_PARAM_PERIODS = 15 + SNDRV_PCM_HW_PARAM_BUFFER_TIME = 16 + SNDRV_PCM_HW_PARAM_BUFFER_SIZE = 17 + SNDRV_PCM_HW_PARAM_BUFFER_BYTES = 18 + SNDRV_PCM_HW_PARAM_TICK_TIME = 19 + SNDRV_PCM_HW_PARAM_FIRST_INTERVAL = 8 + SNDRV_PCM_HW_PARAM_LAST_INTERVAL = 19 + + SNDRV_MASK_MAX = 256 + + SNDRV_PCM_TSTAMP_NONE = 0 + SNDRV_PCM_TSTAMP_ENABLE = 1 +) + +type snd_mask struct { // size 32 + bits [(SNDRV_MASK_MAX + 31) / 32]__u32 // offset 0, size 32 +} + +type snd_interval struct { // size 12 + min unsigned_int // offset 0, size 4 + max unsigned_int // offset 4, size 4 + bit unsigned_int +} + +type snd_pcm_hw_params struct { // size 604 + flags unsigned_int // offset 0, size 4 + masks [SNDRV_PCM_HW_PARAM_LAST_MASK - SNDRV_PCM_HW_PARAM_FIRST_MASK + 1]snd_mask // offset 4, size 96 + mres [5]snd_mask // offset 100, size 160 + intervals [SNDRV_PCM_HW_PARAM_LAST_INTERVAL - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL + 1]snd_interval // offset 260, size 144 + ires [9]snd_interval // offset 404, size 108 + rmask unsigned_int // offset 512, size 4 + cmask unsigned_int // offset 516, size 4 + info unsigned_int // offset 520, size 4 + msbits unsigned_int // offset 524, size 4 + rate_num unsigned_int // offset 528, size 4 + rate_den unsigned_int // offset 532, size 4 + fifo_size snd_pcm_uframes_t // offset 536, size 4 + reserved [64]unsigned_char // offset 540, size 64 +} + +type snd_pcm_sw_params struct { // size 104 + tstamp_mode signed_int // offset 0, size 4 + period_step unsigned_int // offset 4, size 4 + sleep_min unsigned_int // offset 8, size 4 + avail_min snd_pcm_uframes_t // offset 12, size 4 + xfer_align snd_pcm_uframes_t // offset 16, size 4 + start_threshold snd_pcm_uframes_t // offset 20, size 4 + stop_threshold snd_pcm_uframes_t // offset 24, size 4 + silence_threshold snd_pcm_uframes_t // offset 28, size 4 + silence_size snd_pcm_uframes_t // offset 32, size 4 + boundary snd_pcm_uframes_t // offset 36, size 4 + proto unsigned_int // offset 40, size 4 + tstamp_type unsigned_int // offset 44, size 4 + reserved [56]unsigned_char // offset 48, size 56 +} diff --git a/pkg/alsa/device/asound_64bit.go b/pkg/alsa/device/asound_64bit.go new file mode 100644 index 00000000..14d0069c --- /dev/null +++ b/pkg/alsa/device/asound_64bit.go @@ -0,0 +1,148 @@ +//go:build amd64 || arm64 + +package device + +type unsigned_char = byte +type signed_int = int32 +type unsigned_int = uint32 +type signed_long = int64 +type unsigned_long = uint64 +type __u32 = uint32 +type void__user = uintptr + +const ( + SNDRV_PCM_STREAM_PLAYBACK = 0 + SNDRV_PCM_STREAM_CAPTURE = 1 + + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED = 0 + SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED = 1 + SNDRV_PCM_ACCESS_MMAP_COMPLEX = 2 + SNDRV_PCM_ACCESS_RW_INTERLEAVED = 3 + SNDRV_PCM_ACCESS_RW_NONINTERLEAVED = 4 + + SNDRV_PCM_FORMAT_S8 = 0 + SNDRV_PCM_FORMAT_U8 = 1 + SNDRV_PCM_FORMAT_S16_LE = 2 + SNDRV_PCM_FORMAT_S16_BE = 3 + SNDRV_PCM_FORMAT_U16_LE = 4 + SNDRV_PCM_FORMAT_U16_BE = 5 + SNDRV_PCM_FORMAT_S24_LE = 6 + SNDRV_PCM_FORMAT_S24_BE = 7 + SNDRV_PCM_FORMAT_U24_LE = 8 + SNDRV_PCM_FORMAT_U24_BE = 9 + SNDRV_PCM_FORMAT_S32_LE = 10 + SNDRV_PCM_FORMAT_S32_BE = 11 + SNDRV_PCM_FORMAT_U32_LE = 12 + SNDRV_PCM_FORMAT_U32_BE = 13 + SNDRV_PCM_FORMAT_FLOAT_LE = 14 + SNDRV_PCM_FORMAT_FLOAT_BE = 15 + SNDRV_PCM_FORMAT_FLOAT64_LE = 16 + SNDRV_PCM_FORMAT_FLOAT64_BE = 17 + SNDRV_PCM_FORMAT_MU_LAW = 20 + SNDRV_PCM_FORMAT_A_LAW = 21 + SNDRV_PCM_FORMAT_MPEG = 23 + + SNDRV_PCM_IOCTL_PVERSION = 0x80044100 + SNDRV_PCM_IOCTL_INFO = 0x81204101 + SNDRV_PCM_IOCTL_HW_REFINE = 0xc2604110 + SNDRV_PCM_IOCTL_HW_PARAMS = 0xc2604111 + SNDRV_PCM_IOCTL_SW_PARAMS = 0xc0884113 + SNDRV_PCM_IOCTL_PREPARE = 0x00004140 + SNDRV_PCM_IOCTL_WRITEI_FRAMES = 0x40184150 + SNDRV_PCM_IOCTL_READI_FRAMES = 0x80184151 +) + +type snd_pcm_info struct { // size 288 + device unsigned_int // offset 0, size 4 + subdevice unsigned_int // offset 4, size 4 + stream signed_int // offset 8, size 4 + card signed_int // offset 12, size 4 + id [64]unsigned_char // offset 16, size 64 + name [80]unsigned_char // offset 80, size 80 + subname [32]unsigned_char // offset 160, size 32 + dev_class signed_int // offset 192, size 4 + dev_subclass signed_int // offset 196, size 4 + subdevices_count unsigned_int // offset 200, size 4 + subdevices_avail unsigned_int // offset 204, size 4 + pad1 [16]unsigned_char + reserved [64]unsigned_char // offset 224, size 64 +} + +type snd_pcm_uframes_t = unsigned_long +type snd_pcm_sframes_t = signed_long + +type snd_xferi struct { // size 24 + result snd_pcm_sframes_t // offset 0, size 8 + buf void__user // offset 8, size 8 + frames snd_pcm_uframes_t // offset 16, size 8 +} + +const ( + SNDRV_PCM_HW_PARAM_ACCESS = 0 + SNDRV_PCM_HW_PARAM_FORMAT = 1 + SNDRV_PCM_HW_PARAM_SUBFORMAT = 2 + SNDRV_PCM_HW_PARAM_FIRST_MASK = 0 + SNDRV_PCM_HW_PARAM_LAST_MASK = 2 + + SNDRV_PCM_HW_PARAM_SAMPLE_BITS = 8 + SNDRV_PCM_HW_PARAM_FRAME_BITS = 9 + SNDRV_PCM_HW_PARAM_CHANNELS = 10 + SNDRV_PCM_HW_PARAM_RATE = 11 + SNDRV_PCM_HW_PARAM_PERIOD_TIME = 12 + SNDRV_PCM_HW_PARAM_PERIOD_SIZE = 13 + SNDRV_PCM_HW_PARAM_PERIOD_BYTES = 14 + SNDRV_PCM_HW_PARAM_PERIODS = 15 + SNDRV_PCM_HW_PARAM_BUFFER_TIME = 16 + SNDRV_PCM_HW_PARAM_BUFFER_SIZE = 17 + SNDRV_PCM_HW_PARAM_BUFFER_BYTES = 18 + SNDRV_PCM_HW_PARAM_TICK_TIME = 19 + SNDRV_PCM_HW_PARAM_FIRST_INTERVAL = 8 + SNDRV_PCM_HW_PARAM_LAST_INTERVAL = 19 + + SNDRV_MASK_MAX = 256 + + SNDRV_PCM_TSTAMP_NONE = 0 + SNDRV_PCM_TSTAMP_ENABLE = 1 +) + +type snd_mask struct { // size 32 + bits [(SNDRV_MASK_MAX + 31) / 32]__u32 // offset 0, size 32 +} + +type snd_interval struct { // size 12 + min unsigned_int // offset 0, size 4 + max unsigned_int // offset 4, size 4 + bit unsigned_int +} + +type snd_pcm_hw_params struct { // size 608 + flags unsigned_int // offset 0, size 4 + masks [SNDRV_PCM_HW_PARAM_LAST_MASK - SNDRV_PCM_HW_PARAM_FIRST_MASK + 1]snd_mask // offset 4, size 96 + mres [5]snd_mask // offset 100, size 160 + intervals [SNDRV_PCM_HW_PARAM_LAST_INTERVAL - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL + 1]snd_interval // offset 260, size 144 + ires [9]snd_interval // offset 404, size 108 + rmask unsigned_int // offset 512, size 4 + cmask unsigned_int // offset 516, size 4 + info unsigned_int // offset 520, size 4 + msbits unsigned_int // offset 524, size 4 + rate_num unsigned_int // offset 528, size 4 + rate_den unsigned_int // offset 532, size 4 + fifo_size snd_pcm_uframes_t // offset 536, size 8 + reserved [64]unsigned_char // offset 544, size 64 +} + +type snd_pcm_sw_params struct { // size 136 + tstamp_mode signed_int // offset 0, size 4 + period_step unsigned_int // offset 4, size 4 + sleep_min unsigned_int // offset 8, size 4 + avail_min snd_pcm_uframes_t // offset 16, size 8 + xfer_align snd_pcm_uframes_t // offset 24, size 8 + start_threshold snd_pcm_uframes_t // offset 32, size 8 + stop_threshold snd_pcm_uframes_t // offset 40, size 8 + silence_threshold snd_pcm_uframes_t // offset 48, size 8 + silence_size snd_pcm_uframes_t // offset 56, size 8 + boundary snd_pcm_uframes_t // offset 64, size 8 + proto unsigned_int // offset 72, size 4 + tstamp_type unsigned_int // offset 76, size 4 + reserved [56]unsigned_char // offset 80, size 56 +} diff --git a/pkg/alsa/device/asound_arch.c b/pkg/alsa/device/asound_arch.c new file mode 100644 index 00000000..0f895fb1 --- /dev/null +++ b/pkg/alsa/device/asound_arch.c @@ -0,0 +1,163 @@ +#include +#include +#include +#include + +#define print_line(text) printf("%s\n", text) +#define print_hex_const(name) printf("\t%s = 0x%08lx\n", #name, name) +#define print_int_const(con) printf("\t%s = %d\n", #con, con) + +#define print_struct_header(str) printf("type %s struct { // size %lu\n", #str, sizeof(struct str)) +#define print_struct_member(str, mem, typ) printf("\t%s %s // offset %lu, size %lu\n", #mem == "type" ? "typ" : #mem, typ, offsetof(struct str, mem), sizeof((struct str){0}.mem)) + +// https://github.com/torvalds/linux/blob/master/include/uapi/sound/asound.h +int main() { + print_line("package device\n"); + + print_line("type unsigned_char = byte"); + print_line("type signed_int = int32"); + print_line("type unsigned_int = uint32"); + print_line("type signed_long = int64"); + print_line("type unsigned_long = uint64"); + print_line("type __u32 = uint32"); + print_line("type void__user = uintptr\n"); + + print_line("const ("); + print_int_const(SNDRV_PCM_STREAM_PLAYBACK); + print_int_const(SNDRV_PCM_STREAM_CAPTURE); + print_line(""); + print_int_const(SNDRV_PCM_ACCESS_MMAP_INTERLEAVED); + print_int_const(SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED); + print_int_const(SNDRV_PCM_ACCESS_MMAP_COMPLEX); + print_int_const(SNDRV_PCM_ACCESS_RW_INTERLEAVED); + print_int_const(SNDRV_PCM_ACCESS_RW_NONINTERLEAVED); + print_line(""); + print_int_const(SNDRV_PCM_FORMAT_S8); + print_int_const(SNDRV_PCM_FORMAT_U8); + print_int_const(SNDRV_PCM_FORMAT_S16_LE); + print_int_const(SNDRV_PCM_FORMAT_S16_BE); + print_int_const(SNDRV_PCM_FORMAT_U16_LE); + print_int_const(SNDRV_PCM_FORMAT_U16_BE); + print_int_const(SNDRV_PCM_FORMAT_S24_LE); + print_int_const(SNDRV_PCM_FORMAT_S24_BE); + print_int_const(SNDRV_PCM_FORMAT_U24_LE); + print_int_const(SNDRV_PCM_FORMAT_U24_BE); + print_int_const(SNDRV_PCM_FORMAT_S32_LE); + print_int_const(SNDRV_PCM_FORMAT_S32_BE); + print_int_const(SNDRV_PCM_FORMAT_U32_LE); + print_int_const(SNDRV_PCM_FORMAT_U32_BE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT_LE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT_BE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT64_LE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT64_BE); + print_int_const(SNDRV_PCM_FORMAT_MU_LAW); + print_int_const(SNDRV_PCM_FORMAT_A_LAW); + print_int_const(SNDRV_PCM_FORMAT_MPEG); + print_line(""); + print_hex_const(SNDRV_PCM_IOCTL_PVERSION); // A 0x00 + print_hex_const(SNDRV_PCM_IOCTL_INFO); // A 0x01 + print_hex_const(SNDRV_PCM_IOCTL_HW_REFINE); // A 0x10 + print_hex_const(SNDRV_PCM_IOCTL_HW_PARAMS); // A 0x11 + print_hex_const(SNDRV_PCM_IOCTL_SW_PARAMS); // A 0x13 + print_hex_const(SNDRV_PCM_IOCTL_PREPARE); // A 0x40 + print_hex_const(SNDRV_PCM_IOCTL_WRITEI_FRAMES); // A 0x50 + print_hex_const(SNDRV_PCM_IOCTL_READI_FRAMES); // A 0x51 + print_line(")\n"); + + print_struct_header(snd_pcm_info); + print_struct_member(snd_pcm_info, device, "unsigned_int"); + print_struct_member(snd_pcm_info, subdevice, "unsigned_int"); + print_struct_member(snd_pcm_info, stream, "signed_int"); + print_struct_member(snd_pcm_info, card, "signed_int"); + print_struct_member(snd_pcm_info, id, "[64]unsigned_char"); + print_struct_member(snd_pcm_info, name, "[80]unsigned_char"); + print_struct_member(snd_pcm_info, subname, "[32]unsigned_char"); + print_struct_member(snd_pcm_info, dev_class, "signed_int"); + print_struct_member(snd_pcm_info, dev_subclass, "signed_int"); + print_struct_member(snd_pcm_info, subdevices_count, "unsigned_int"); + print_struct_member(snd_pcm_info, subdevices_avail, "unsigned_int"); + print_line("\tpad1 [16]unsigned_char"); + print_struct_member(snd_pcm_info, reserved, "[64]unsigned_char"); + print_line("}\n"); + + print_line("type snd_pcm_uframes_t = unsigned_long"); + print_line("type snd_pcm_sframes_t = signed_long\n"); + + print_struct_header(snd_xferi); + print_struct_member(snd_xferi, result, "snd_pcm_sframes_t"); + print_struct_member(snd_xferi, buf, "void__user"); + print_struct_member(snd_xferi, frames, "snd_pcm_uframes_t"); + print_line("}\n"); + + print_line("const ("); + print_int_const(SNDRV_PCM_HW_PARAM_ACCESS); + print_int_const(SNDRV_PCM_HW_PARAM_FORMAT); + print_int_const(SNDRV_PCM_HW_PARAM_SUBFORMAT); + print_int_const(SNDRV_PCM_HW_PARAM_FIRST_MASK); + print_int_const(SNDRV_PCM_HW_PARAM_LAST_MASK); + print_line(""); + print_int_const(SNDRV_PCM_HW_PARAM_SAMPLE_BITS); + print_int_const(SNDRV_PCM_HW_PARAM_FRAME_BITS); + print_int_const(SNDRV_PCM_HW_PARAM_CHANNELS); + print_int_const(SNDRV_PCM_HW_PARAM_RATE); + print_int_const(SNDRV_PCM_HW_PARAM_PERIOD_TIME); + print_int_const(SNDRV_PCM_HW_PARAM_PERIOD_SIZE); + print_int_const(SNDRV_PCM_HW_PARAM_PERIOD_BYTES); + print_int_const(SNDRV_PCM_HW_PARAM_PERIODS); + print_int_const(SNDRV_PCM_HW_PARAM_BUFFER_TIME); + print_int_const(SNDRV_PCM_HW_PARAM_BUFFER_SIZE); + print_int_const(SNDRV_PCM_HW_PARAM_BUFFER_BYTES); + print_int_const(SNDRV_PCM_HW_PARAM_TICK_TIME); + print_int_const(SNDRV_PCM_HW_PARAM_FIRST_INTERVAL); + print_int_const(SNDRV_PCM_HW_PARAM_LAST_INTERVAL); + print_line(""); + print_int_const(SNDRV_MASK_MAX); + print_line(""); + print_int_const(SNDRV_PCM_TSTAMP_NONE); + print_int_const(SNDRV_PCM_TSTAMP_ENABLE); + print_line(")\n"); + + print_struct_header(snd_mask); + print_struct_member(snd_mask, bits, "[(SNDRV_MASK_MAX+31)/32]__u32"); + print_line("}\n"); + + print_struct_header(snd_interval); + print_struct_member(snd_interval, min, "unsigned_int"); + print_struct_member(snd_interval, max, "unsigned_int"); + print_line("\tbit unsigned_int"); + print_line("}\n"); + + print_struct_header(snd_pcm_hw_params); + print_struct_member(snd_pcm_hw_params, flags, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, masks, "[SNDRV_PCM_HW_PARAM_LAST_MASK-SNDRV_PCM_HW_PARAM_FIRST_MASK+1]snd_mask"); + print_struct_member(snd_pcm_hw_params, mres, "[5]snd_mask"); + print_struct_member(snd_pcm_hw_params, intervals, "[SNDRV_PCM_HW_PARAM_LAST_INTERVAL-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL+1]snd_interval"); + print_struct_member(snd_pcm_hw_params, ires, "[9]snd_interval"); + print_struct_member(snd_pcm_hw_params, rmask, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, cmask, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, info, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, msbits, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, rate_num, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, rate_den, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, fifo_size, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_hw_params, reserved, "[64]unsigned_char"); + print_line("}\n"); + + print_struct_header(snd_pcm_sw_params); + print_struct_member(snd_pcm_sw_params, tstamp_mode, "signed_int"); + print_struct_member(snd_pcm_sw_params, period_step, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, sleep_min, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, avail_min, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, xfer_align, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, start_threshold, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, stop_threshold, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, silence_threshold, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, silence_size, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, boundary, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, proto, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, tstamp_type, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, reserved, "[56]unsigned_char"); + print_line("}\n"); + + return 0; +} \ No newline at end of file diff --git a/pkg/alsa/device/asound_mipsle.go b/pkg/alsa/device/asound_mipsle.go new file mode 100644 index 00000000..743c89dd --- /dev/null +++ b/pkg/alsa/device/asound_mipsle.go @@ -0,0 +1,146 @@ +package device + +type unsigned_char = byte +type signed_int = int32 +type unsigned_int = uint32 +type signed_long = int64 +type unsigned_long = uint64 +type __u32 = uint32 +type void__user = uintptr + +const ( + SNDRV_PCM_STREAM_PLAYBACK = 0 + SNDRV_PCM_STREAM_CAPTURE = 1 + + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED = 0 + SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED = 1 + SNDRV_PCM_ACCESS_MMAP_COMPLEX = 2 + SNDRV_PCM_ACCESS_RW_INTERLEAVED = 3 + SNDRV_PCM_ACCESS_RW_NONINTERLEAVED = 4 + + SNDRV_PCM_FORMAT_S8 = 0 + SNDRV_PCM_FORMAT_U8 = 1 + SNDRV_PCM_FORMAT_S16_LE = 2 + SNDRV_PCM_FORMAT_S16_BE = 3 + SNDRV_PCM_FORMAT_U16_LE = 4 + SNDRV_PCM_FORMAT_U16_BE = 5 + SNDRV_PCM_FORMAT_S24_LE = 6 + SNDRV_PCM_FORMAT_S24_BE = 7 + SNDRV_PCM_FORMAT_U24_LE = 8 + SNDRV_PCM_FORMAT_U24_BE = 9 + SNDRV_PCM_FORMAT_S32_LE = 10 + SNDRV_PCM_FORMAT_S32_BE = 11 + SNDRV_PCM_FORMAT_U32_LE = 12 + SNDRV_PCM_FORMAT_U32_BE = 13 + SNDRV_PCM_FORMAT_FLOAT_LE = 14 + SNDRV_PCM_FORMAT_FLOAT_BE = 15 + SNDRV_PCM_FORMAT_FLOAT64_LE = 16 + SNDRV_PCM_FORMAT_FLOAT64_BE = 17 + SNDRV_PCM_FORMAT_MU_LAW = 20 + SNDRV_PCM_FORMAT_A_LAW = 21 + SNDRV_PCM_FORMAT_MPEG = 23 + + SNDRV_PCM_IOCTL_PVERSION = 0x40044100 + SNDRV_PCM_IOCTL_INFO = 0x41204101 + SNDRV_PCM_IOCTL_HW_REFINE = 0xc25c4110 + SNDRV_PCM_IOCTL_HW_PARAMS = 0xc25c4111 + SNDRV_PCM_IOCTL_SW_PARAMS = 0xc0684113 + SNDRV_PCM_IOCTL_PREPARE = 0x20004140 + SNDRV_PCM_IOCTL_WRITEI_FRAMES = 0x800c4150 + SNDRV_PCM_IOCTL_READI_FRAMES = 0x400c4151 +) + +type snd_pcm_info struct { // size 288 + device unsigned_int // offset 0, size 4 + subdevice unsigned_int // offset 4, size 4 + stream signed_int // offset 8, size 4 + card signed_int // offset 12, size 4 + id [64]unsigned_char // offset 16, size 64 + name [80]unsigned_char // offset 80, size 80 + subname [32]unsigned_char // offset 160, size 32 + dev_class signed_int // offset 192, size 4 + dev_subclass signed_int // offset 196, size 4 + subdevices_count unsigned_int // offset 200, size 4 + subdevices_avail unsigned_int // offset 204, size 4 + pad1 [16]unsigned_char + reserved [64]unsigned_char // offset 224, size 64 +} + +type snd_pcm_uframes_t = unsigned_long +type snd_pcm_sframes_t = signed_long + +type snd_xferi struct { // size 12 + result snd_pcm_sframes_t // offset 0, size 4 + buf void__user // offset 4, size 4 + frames snd_pcm_uframes_t // offset 8, size 4 +} + +const ( + SNDRV_PCM_HW_PARAM_ACCESS = 0 + SNDRV_PCM_HW_PARAM_FORMAT = 1 + SNDRV_PCM_HW_PARAM_SUBFORMAT = 2 + SNDRV_PCM_HW_PARAM_FIRST_MASK = 0 + SNDRV_PCM_HW_PARAM_LAST_MASK = 2 + + SNDRV_PCM_HW_PARAM_SAMPLE_BITS = 8 + SNDRV_PCM_HW_PARAM_FRAME_BITS = 9 + SNDRV_PCM_HW_PARAM_CHANNELS = 10 + SNDRV_PCM_HW_PARAM_RATE = 11 + SNDRV_PCM_HW_PARAM_PERIOD_TIME = 12 + SNDRV_PCM_HW_PARAM_PERIOD_SIZE = 13 + SNDRV_PCM_HW_PARAM_PERIOD_BYTES = 14 + SNDRV_PCM_HW_PARAM_PERIODS = 15 + SNDRV_PCM_HW_PARAM_BUFFER_TIME = 16 + SNDRV_PCM_HW_PARAM_BUFFER_SIZE = 17 + SNDRV_PCM_HW_PARAM_BUFFER_BYTES = 18 + SNDRV_PCM_HW_PARAM_TICK_TIME = 19 + SNDRV_PCM_HW_PARAM_FIRST_INTERVAL = 8 + SNDRV_PCM_HW_PARAM_LAST_INTERVAL = 19 + + SNDRV_MASK_MAX = 256 + + SNDRV_PCM_TSTAMP_NONE = 0 + SNDRV_PCM_TSTAMP_ENABLE = 1 +) + +type snd_mask struct { // size 32 + bits [(SNDRV_MASK_MAX + 31) / 32]__u32 // offset 0, size 32 +} + +type snd_interval struct { // size 12 + min unsigned_int // offset 0, size 4 + max unsigned_int // offset 4, size 4 + bit unsigned_int +} + +type snd_pcm_hw_params struct { // size 604 + flags unsigned_int // offset 0, size 4 + masks [SNDRV_PCM_HW_PARAM_LAST_MASK - SNDRV_PCM_HW_PARAM_FIRST_MASK + 1]snd_mask // offset 4, size 96 + mres [5]snd_mask // offset 100, size 160 + intervals [SNDRV_PCM_HW_PARAM_LAST_INTERVAL - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL + 1]snd_interval // offset 260, size 144 + ires [9]snd_interval // offset 404, size 108 + rmask unsigned_int // offset 512, size 4 + cmask unsigned_int // offset 516, size 4 + info unsigned_int // offset 520, size 4 + msbits unsigned_int // offset 524, size 4 + rate_num unsigned_int // offset 528, size 4 + rate_den unsigned_int // offset 532, size 4 + fifo_size snd_pcm_uframes_t // offset 536, size 4 + reserved [64]unsigned_char // offset 540, size 64 +} + +type snd_pcm_sw_params struct { // size 104 + tstamp_mode signed_int // offset 0, size 4 + period_step unsigned_int // offset 4, size 4 + sleep_min unsigned_int // offset 8, size 4 + avail_min snd_pcm_uframes_t // offset 12, size 4 + xfer_align snd_pcm_uframes_t // offset 16, size 4 + start_threshold snd_pcm_uframes_t // offset 20, size 4 + stop_threshold snd_pcm_uframes_t // offset 24, size 4 + silence_threshold snd_pcm_uframes_t // offset 28, size 4 + silence_size snd_pcm_uframes_t // offset 32, size 4 + boundary snd_pcm_uframes_t // offset 36, size 4 + proto unsigned_int // offset 40, size 4 + tstamp_type unsigned_int // offset 44, size 4 + reserved [56]unsigned_char // offset 48, size 56 +} diff --git a/pkg/alsa/device/device_linux.go b/pkg/alsa/device/device_linux.go new file mode 100644 index 00000000..ecccc17b --- /dev/null +++ b/pkg/alsa/device/device_linux.go @@ -0,0 +1,231 @@ +package device + +import ( + "fmt" + "syscall" + "unsafe" +) + +type Device struct { + fd uintptr + path string + + hwparams snd_pcm_hw_params + frameBytes int // sample size * channels +} + +func Open(path string) (*Device, error) { + // important to use nonblock because can get lock + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + + // important to remove nonblock because better to handle reads and writes + if err = syscall.SetNonblock(fd, false); err != nil { + return nil, err + } + + d := &Device{fd: uintptr(fd), path: path} + d.init() + + // load all supported formats, channels, rates, etc. + if err = ioctl(d.fd, SNDRV_PCM_IOCTL_HW_REFINE, &d.hwparams); err != nil { + _ = d.Close() + return nil, err + } + + d.setMask(SNDRV_PCM_HW_PARAM_ACCESS, SNDRV_PCM_ACCESS_RW_INTERLEAVED) + + return d, nil +} + +func (d *Device) Close() error { + return syscall.Close(int(d.fd)) +} + +func (d *Device) IsCapture() bool { + // path: /dev/snd/pcmC0D0c, where p - playback, c - capture + return d.path[len(d.path)-1] == 'c' +} + +type Info struct { + Card int + Device int + SubDevice int + Stream int + ID string + Name string + SubName string +} + +func (d *Device) Info() (*Info, error) { + var info snd_pcm_info + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_INFO, &info); err != nil { + return nil, err + } + return &Info{ + Card: int(info.card), + Device: int(info.device), + SubDevice: int(info.subdevice), + Stream: int(info.stream), + ID: str(info.id[:]), + Name: str(info.name[:]), + SubName: str(info.subname[:]), + }, nil +} + +func (d *Device) CheckFormat(format byte) bool { + return d.checkMask(SNDRV_PCM_HW_PARAM_FORMAT, uint32(format)) +} + +func (d *Device) ListFormats() (formats []byte) { + for i := byte(0); i <= 28; i++ { + if d.CheckFormat(i) { + formats = append(formats, i) + } + } + return +} + +func (d *Device) RangeRates() (uint32, uint32) { + return d.getInterval(SNDRV_PCM_HW_PARAM_RATE) +} + +func (d *Device) RangeChannels() (byte, byte) { + minCh, maxCh := d.getInterval(SNDRV_PCM_HW_PARAM_CHANNELS) + return byte(minCh), byte(maxCh) +} + +func (d *Device) GetRateNear(rate uint32) uint32 { + r1, r2 := d.RangeRates() + if rate < r1 { + return r1 + } + if rate > r2 { + return r2 + } + return rate +} + +func (d *Device) GetChannelsNear(channels byte) byte { + c1, c2 := d.RangeChannels() + if channels < c1 { + return c1 + } + if channels > c2 { + return c2 + } + return channels +} + +const bufferSize = 4096 + +func (d *Device) SetHWParams(format byte, rate uint32, channels byte) error { + d.setInterval(SNDRV_PCM_HW_PARAM_CHANNELS, uint32(channels)) + d.setInterval(SNDRV_PCM_HW_PARAM_RATE, rate) + d.setMask(SNDRV_PCM_HW_PARAM_FORMAT, uint32(format)) + //d.setMask(SNDRV_PCM_HW_PARAM_SUBFORMAT, 0) + + // important for smooth playback + d.setInterval(SNDRV_PCM_HW_PARAM_BUFFER_SIZE, bufferSize) + //d.setInterval(SNDRV_PCM_HW_PARAM_PERIOD_SIZE, 2000) + + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_HW_PARAMS, &d.hwparams); err != nil { + return fmt.Errorf("[alsa] set hw_params: %w", err) + } + + _, i := d.getInterval(SNDRV_PCM_HW_PARAM_FRAME_BITS) + d.frameBytes = int(i / 8) + + _, periods := d.getInterval(SNDRV_PCM_HW_PARAM_PERIODS) + _, periodSize := d.getInterval(SNDRV_PCM_HW_PARAM_PERIOD_SIZE) + threshold := snd_pcm_uframes_t(periods * periodSize) // same as bufferSize + + swparams := snd_pcm_sw_params{ + //tstamp_mode: SNDRV_PCM_TSTAMP_ENABLE, + period_step: 1, + avail_min: 1, // start as soon as possible + stop_threshold: threshold, + } + + if d.IsCapture() { + swparams.start_threshold = 1 + } else { + swparams.start_threshold = threshold + } + + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_SW_PARAMS, &swparams); err != nil { + return fmt.Errorf("[alsa] set sw_params: %w", err) + } + + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_PREPARE, nil); err != nil { + return fmt.Errorf("[alsa] prepare: %w", err) + } + + return nil +} + +func (d *Device) Write(b []byte) (n int, err error) { + xfer := &snd_xferi{ + buf: uintptr(unsafe.Pointer(&b[0])), + frames: snd_pcm_uframes_t(len(b) / d.frameBytes), + } + err = ioctl(d.fd, SNDRV_PCM_IOCTL_WRITEI_FRAMES, xfer) + if err == syscall.EPIPE { + // auto handle underrun state + // https://stackoverflow.com/questions/59396728/how-to-properly-handle-xrun-in-alsa-programming-when-playing-audio-with-snd-pcm + err = ioctl(d.fd, SNDRV_PCM_IOCTL_PREPARE, nil) + } + n = int(xfer.result) * d.frameBytes + return +} + +func (d *Device) Read(b []byte) (n int, err error) { + xfer := &snd_xferi{ + buf: uintptr(unsafe.Pointer(&b[0])), + frames: snd_pcm_uframes_t(len(b) / d.frameBytes), + } + err = ioctl(d.fd, SNDRV_PCM_IOCTL_READI_FRAMES, xfer) + n = int(xfer.result) * d.frameBytes + return +} + +func (d *Device) init() { + for i := range d.hwparams.masks { + d.hwparams.masks[i].bits[0] = 0xFFFFFFFF + d.hwparams.masks[i].bits[1] = 0xFFFFFFFF + } + for i := range d.hwparams.intervals { + d.hwparams.intervals[i].max = 0xFFFFFFFF + } + + d.hwparams.rmask = 0xFFFFFFFF + d.hwparams.cmask = 0 + d.hwparams.info = 0xFFFFFFFF +} + +func (d *Device) setInterval(param, val uint32) { + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].min = val + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].max = val + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].bit = 0b0100 // integer +} + +func (d *Device) setIntervalMin(param, val uint32) { + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].min = val +} + +func (d *Device) getInterval(param uint32) (uint32, uint32) { + return d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].min, + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].max +} + +func (d *Device) setMask(mask, val uint32) { + d.hwparams.masks[mask].bits[0] = 0 + d.hwparams.masks[mask].bits[1] = 0 + d.hwparams.masks[mask].bits[val>>5] = 1 << (val & 0x1F) +} + +func (d *Device) checkMask(mask, val uint32) bool { + return d.hwparams.masks[mask].bits[val>>5]&(1<<(val&0x1F)) > 0 +} diff --git a/pkg/alsa/device/ioctl_linux.go b/pkg/alsa/device/ioctl_linux.go new file mode 100644 index 00000000..1277a601 --- /dev/null +++ b/pkg/alsa/device/ioctl_linux.go @@ -0,0 +1,26 @@ +package device + +import ( + "bytes" + "reflect" + "syscall" +) + +func ioctl(fd, req uintptr, arg any) error { + var ptr uintptr + if arg != nil { + ptr = reflect.ValueOf(arg).Pointer() + } + _, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, req, ptr) + if err != 0 { + return err + } + return nil +} + +func str(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} diff --git a/pkg/alsa/open_linux.go b/pkg/alsa/open_linux.go new file mode 100644 index 00000000..2e4c57b4 --- /dev/null +++ b/pkg/alsa/open_linux.go @@ -0,0 +1,44 @@ +package alsa + +import ( + "errors" + "fmt" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/alsa/device" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Open(rawURL string) (core.Producer, error) { + // Example (ffmpeg source compatible): + // alsa:device?audio=/dev/snd/pcmC0D0p + // TODO: ?audio=default + // TODO: ?audio=hw:0,0 + // TODO: &sample_rate=48000&channels=2 + // TODO: &backchannel=1 + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + path := u.Query().Get("audio") + dev, err := device.Open(path) + if err != nil { + return nil, err + } + + if !dev.CheckFormat(device.SNDRV_PCM_FORMAT_S16_LE) { + _ = dev.Close() + return nil, errors.New("alsa: format S16LE not supported") + } + + switch path[len(path)-1] { + case 'p': // playback + return newPlayback(dev) + case 'c': // capture + return newCapture(dev) + } + + _ = dev.Close() + return nil, fmt.Errorf("alsa: unknown path: %s", path) +} diff --git a/pkg/alsa/playback_linux.go b/pkg/alsa/playback_linux.go new file mode 100644 index 00000000..7fb214d3 --- /dev/null +++ b/pkg/alsa/playback_linux.go @@ -0,0 +1,84 @@ +package alsa + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/alsa/device" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +type Playback struct { + core.Connection + dev *device.Device + closed core.Waiter +} + +func newPlayback(dev *device.Device) (*Playback, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML}, // support ffmpeg producer (auto transcode) + {Name: core.CodecPCMA, ClockRate: 8000}, // support webrtc producer + }, + }, + } + return &Playback{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "alsa", + Medias: medias, + Transport: dev, + }, + dev: dev, + }, nil +} + +func (p *Playback) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (p *Playback) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + src := track.Codec + dst := &core.Codec{ + Name: core.CodecPCML, + ClockRate: p.dev.GetRateNear(src.ClockRate), + Channels: p.dev.GetChannelsNear(src.Channels), + } + sender := core.NewSender(media, dst) + + sender.Handler = func(pkt *rtp.Packet) { + if n, err := p.dev.Write(pkt.Payload); err == nil { + p.Send += n + } + } + + if sender.Handler = pcm.TranscodeHandler(dst, src, sender.Handler); sender.Handler == nil { + return fmt.Errorf("alsa: can't convert %s to %s", src, dst) + } + + // typical card support: + // - Formats: S16_LE, S32_LE + // - ClockRates: 8000 - 192000 + // - Channels: 2 - 10 + err := p.dev.SetHWParams(device.SNDRV_PCM_FORMAT_S16_LE, dst.ClockRate, byte(dst.Channels)) + if err != nil { + return err + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} + +func (p *Playback) Start() (err error) { + return p.closed.Wait() +} + +func (p *Playback) Stop() error { + p.closed.Done(nil) + return p.Connection.Stop() +} diff --git a/pkg/bits/reader.go b/pkg/bits/reader.go index 31ea9ef8..2a957409 100644 --- a/pkg/bits/reader.go +++ b/pkg/bits/reader.go @@ -89,6 +89,12 @@ func (r *Reader) ReadBits64(n byte) (res uint64) { return } +func (r *Reader) ReadFloat32() float64 { + i := r.ReadUint16() + f := r.ReadUint16() + return float64(i) + float64(f)/65536 +} + func (r *Reader) ReadBytes(n int) (b []byte) { if r.bits == 0 { if r.pos+n > len(r.buf) { @@ -122,9 +128,9 @@ func (r *Reader) ReadUEGolomb() uint32 { // ReadSEGolomb - ReadSignedExponentialGolomb func (r *Reader) ReadSEGolomb() int32 { if b := r.ReadUEGolomb(); b%2 == 0 { - return -int32(b >> 1) + return -int32(b / 2) } else { - return int32(b >> 1) + return int32((b + 1) / 2) } } diff --git a/pkg/core/codec.go b/pkg/core/codec.go index b138df28..ba0c656a 100644 --- a/pkg/core/codec.go +++ b/pkg/core/codec.go @@ -13,7 +13,7 @@ import ( type Codec struct { Name string // H264, PCMU, PCMA, opus... ClockRate uint32 // 90000, 8000, 16000... - Channels uint16 // 0, 1, 2 + Channels uint8 // 0, 1, 2 FmtpLine string PayloadType uint8 } @@ -249,3 +249,36 @@ func DecodeH264(fmtp string) (profile string, level byte) { } return } + +func ParseCodecString(s string) *Codec { + var codec Codec + + ss := strings.Split(s, "/") + switch strings.ToLower(ss[0]) { + case "pcm_s16be", "s16be", "pcm": + codec.Name = CodecPCM + case "pcm_s16le", "s16le", "pcml": + codec.Name = CodecPCML + case "pcm_alaw", "alaw", "pcma": + codec.Name = CodecPCMA + case "pcm_mulaw", "mulaw", "pcmu": + codec.Name = CodecPCMU + case "aac", "mpeg4-generic": + codec.Name = CodecAAC + case "opus": + codec.Name = CodecOpus + case "flac": + codec.Name = CodecFLAC + default: + return nil + } + + if len(ss) >= 2 { + codec.ClockRate = uint32(Atoi(ss[1])) + } + if len(ss) >= 3 { + codec.Channels = uint8(Atoi(ss[1])) + } + + return &codec +} diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index 4a05380a..e7845ca7 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -118,3 +118,17 @@ func TestName(t *testing.T) { // stage3 _ = prod2.Stop() } + +func TestStripUserinfo(t *testing.T) { + s := `streams: + test: + - ffmpeg:rtsp://username:password@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +` + s = StripUserinfo(s) + require.Equal(t, `streams: + test: + - ffmpeg:rtsp://***@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +`, s) +} diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 72afe897..161a5504 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -2,6 +2,7 @@ package core import ( "crypto/rand" + "regexp" "runtime" "strconv" "strings" @@ -77,3 +78,14 @@ func Caller() string { _, file, line, _ := runtime.Caller(1) return file + ":" + strconv.Itoa(line) } + +const ( + unreserved = `A-Za-z0-9-._~` + subdelims = `!$&'()*+,;=` + userinfo = unreserved + subdelims + `%:` +) + +func StripUserinfo(s string) string { + sanitizer := regexp.MustCompile(`://[` + userinfo + `]+@`) + return sanitizer.ReplaceAllString(s, `://***@`) +} diff --git a/pkg/core/media.go b/pkg/core/media.go index a700bb62..367d8cb8 100644 --- a/pkg/core/media.go +++ b/pkg/core/media.go @@ -139,7 +139,7 @@ func MarshalSDP(name string, medias []*Media) ([]byte, error) { Protos: []string{"RTP", "AVP"}, }, } - md.WithCodec(codec.PayloadType, name, codec.ClockRate, codec.Channels, codec.FmtpLine) + md.WithCodec(codec.PayloadType, name, codec.ClockRate, uint16(codec.Channels), codec.FmtpLine) if media.Direction != "" { md.WithPropertyAttribute(media.Direction) diff --git a/pkg/core/track.go b/pkg/core/track.go index d3f1467d..f363a9fd 100644 --- a/pkg/core/track.go +++ b/pkg/core/track.go @@ -97,8 +97,8 @@ func NewSender(media *Media, codec *Codec) *Sender { buf: buf, } s.Input = func(packet *Packet) { - // writing to nil chan - OK, writing to closed chan - panic s.mu.Lock() + // unblock write to nil chan - OK, write to closed chan - panic select { case s.buf <- packet: s.Bytes += len(packet.Payload) @@ -139,13 +139,13 @@ func (s *Sender) Start() { } s.done = make(chan struct{}) - go func() { - // for range on nil chan is OK - for packet := range s.buf { + // pass buf directly so that it's impossible for buf to be nil + go func(buf chan *Packet) { + for packet := range buf { s.Output(packet) } close(s.done) - }() + }(s.buf) } func (s *Sender) Wait() { diff --git a/pkg/creds/README.md b/pkg/creds/README.md new file mode 100644 index 00000000..1909a206 --- /dev/null +++ b/pkg/creds/README.md @@ -0,0 +1,7 @@ +# Credentials + +This module allows you to get variables: + +- from custom storage (ex. config file) +- from [credential files](https://systemd.io/CREDENTIALS/) +- from environment variables diff --git a/pkg/creds/creds.go b/pkg/creds/creds.go new file mode 100644 index 00000000..84bc275a --- /dev/null +++ b/pkg/creds/creds.go @@ -0,0 +1,79 @@ +package creds + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" +) + +type Storage interface { + SetValue(name, value string) error + GetValue(name string) (string, bool) +} + +var storage Storage + +func SetStorage(s Storage) { + storage = s +} + +func SetValue(name, value string) error { + if storage == nil { + return errors.New("credentials: storage not initialized") + } + if err := storage.SetValue(name, value); err != nil { + return err + } + AddSecret(value) + return nil +} + +func GetValue(name string) (value string, ok bool) { + value, ok = getValue(name) + AddSecret(value) + return +} + +func getValue(name string) (string, bool) { + if storage != nil { + if value, ok := storage.GetValue(name); ok { + return value, true + } + } + + if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { + if value, _ := os.ReadFile(filepath.Join(dir, name)); value != nil { + return strings.TrimSpace(string(value)), true + } + } + + return os.LookupEnv(name) +} + +// ReplaceVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} +func ReplaceVars(data []byte) []byte { + re := regexp.MustCompile(`\${([^}{]+)}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + key := string(match[2 : len(match)-1]) + + var def string + var defok bool + + if i := strings.IndexByte(key, ':'); i > 0 { + key, def = key[:i], key[i+1:] + defok = true + } + + if value, ok := GetValue(key); ok { + return []byte(value) + } + + if defok { + return []byte(def) + } + + return match + }) +} diff --git a/pkg/creds/secrets.go b/pkg/creds/secrets.go new file mode 100644 index 00000000..a9a0094e --- /dev/null +++ b/pkg/creds/secrets.go @@ -0,0 +1,83 @@ +package creds + +import ( + "io" + "net/http" + "slices" + "strings" + "sync" +) + +func AddSecret(value string) { + if value == "" { + return + } + + secretsMu.Lock() + defer secretsMu.Unlock() + + if slices.Contains(secrets, value) { + return + } + + secrets = append(secrets, value) + secretsReplacer = nil +} + +var secrets []string +var secretsMu sync.Mutex +var secretsReplacer *strings.Replacer + +func getReplacer() *strings.Replacer { + secretsMu.Lock() + defer secretsMu.Unlock() + + if secretsReplacer == nil { + oldnew := make([]string, 0, 2*len(secrets)) + for _, s := range secrets { + oldnew = append(oldnew, s, "***") + } + secretsReplacer = strings.NewReplacer(oldnew...) + } + + return secretsReplacer +} + +func SecretString(s string) string { + re := getReplacer() + return re.Replace(s) +} + +func SecretWriter(w io.Writer) io.Writer { + return &secretWriter{w} +} + +type secretWriter struct { + w io.Writer +} + +func (s *secretWriter) Write(b []byte) (int, error) { + re := getReplacer() + return re.WriteString(s.w, string(b)) +} + +type secretResponse struct { + w http.ResponseWriter +} + +func (s *secretResponse) Header() http.Header { + return s.w.Header() +} + +func (s *secretResponse) Write(b []byte) (int, error) { + re := getReplacer() + return re.WriteString(s.w, string(b)) +} + +func (s *secretResponse) WriteHeader(statusCode int) { + s.w.WriteHeader(statusCode) +} + +func SecretResponse(w http.ResponseWriter) http.ResponseWriter { + return &secretResponse{w} +} diff --git a/pkg/creds/secrets_test.go b/pkg/creds/secrets_test.go new file mode 100644 index 00000000..83f1908a --- /dev/null +++ b/pkg/creds/secrets_test.go @@ -0,0 +1,15 @@ +package creds + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestString(t *testing.T) { + AddSecret("admin") + AddSecret("pa$$word") + + s := SecretString("rtsp://admin:pa$$word@192.168.1.123/stream1") + require.Equal(t, "rtsp://***:***@192.168.1.123/stream1", s) +} diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 82379383..4d252228 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -88,6 +88,8 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece } func (c *Client) Start() (err error) { - _, err = c.conn.Read(nil) + // just block until c.conn closed + b := make([]byte, 1) + _, err = c.conn.Read(b) return } diff --git a/pkg/eseecloud/eseecloud.go b/pkg/eseecloud/eseecloud.go new file mode 100644 index 00000000..05209d22 --- /dev/null +++ b/pkg/eseecloud/eseecloud.go @@ -0,0 +1,180 @@ +package eseecloud + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net/http" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer + + videoPT, audioPT uint8 +} + +func Dial(rawURL string) (core.Producer, error) { + rawURL, _ = strings.CutPrefix(rawURL, "eseecloud") + res, err := http.Get("http" + rawURL) + if err != nil { + return nil, err + } + + prod, err := Open(res.Body) + if err != nil { + return nil, err + } + + if info, ok := prod.(core.Info); ok { + info.SetProtocol("http") + info.SetURL(rawURL) + } + + return prod, nil +} + +func Open(r io.Reader) (core.Producer, error) { + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "eseecloud", + Transport: r, + }, + rd: core.NewReadBuffer(r), + } + + if err := prod.probe(); err != nil { + return nil, err + } + + return prod, nil +} + +func (p *Producer) probe() error { + b, err := p.rd.Peek(1024) + if err != nil { + return err + } + + i := bytes.Index(b, []byte("\r\n\r\n")) + if i == -1 { + return io.EOF + } + + b = make([]byte, i+4) + _, _ = p.rd.Read(b) + + re := regexp.MustCompile(`m=(video|audio) (\d+) (\w+)/(\d+)\S*`) + for _, item := range re.FindAllStringSubmatch(string(b), 2) { + p.SDP += item[0] + "\n" + + switch item[3] { + case "H264", "H265": + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: item[3], + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }) + p.videoPT = byte(core.Atoi(item[2])) + + case "G711": + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecPCMA, + ClockRate: 8000, + }, + }, + }) + p.audioPT = byte(core.Atoi(item[2])) + } + } + + return nil +} + +func (p *Producer) Start() error { + receivers := make(map[uint8]*core.Receiver) + + for _, receiver := range p.Receivers { + switch receiver.Codec.Kind() { + case core.KindVideo: + receivers[p.videoPT] = receiver + case core.KindAudio: + receivers[p.audioPT] = receiver + } + } + + for { + pkt, err := p.readPacket() + if err != nil { + return err + } + + if recv := receivers[pkt.PayloadType]; recv != nil { + switch recv.Codec.Name { + case core.CodecH264, core.CodecH265: + // timestamp = seconds x 1000000 + pkt = &rtp.Packet{ + Header: rtp.Header{ + Timestamp: uint32(uint64(pkt.Timestamp) * 90000 / 1000000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + case core.CodecPCMA: + pkt = &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + SequenceNumber: pkt.SequenceNumber, + Timestamp: uint32(uint64(pkt.Timestamp) * 8000 / 1000000), + }, + Payload: pkt.Payload, + } + } + recv.WriteRTP(pkt) + } + } +} + +func (p *Producer) readPacket() (*core.Packet, error) { + b := make([]byte, 8) + + if _, err := io.ReadFull(p.rd, b); err != nil { + return nil, err + } + + if b[0] != '$' { + return nil, errors.New("eseecloud: wrong start byte") + } + + size := binary.BigEndian.Uint32(b[4:]) + b = make([]byte, size) + if _, err := io.ReadFull(p.rd, b); err != nil { + return nil, err + } + + pkt := &core.Packet{} + if err := pkt.Unmarshal(b); err != nil { + return nil, err + } + + p.Recv += int(size) + + return pkt, nil +} diff --git a/pkg/expr/expr.go b/pkg/expr/expr.go index 36719100..4a8a663c 100644 --- a/pkg/expr/expr.go +++ b/pkg/expr/expr.go @@ -6,17 +6,24 @@ import ( "io" "net/http" "regexp" + "strings" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" ) -func newRequest(method, url string, headers map[string]any) (*http.Request, error) { +func newRequest(method, url string, headers map[string]any, body string) (*http.Request, error) { + var rd io.Reader + if method == "" { method = "GET" } + if body != "" { + rd = strings.NewReader(body) + } - req, err := http.NewRequest(method, url, nil) + req, err := http.NewRequest(method, url, rd) if err != nil { return nil, err } @@ -55,7 +62,8 @@ var Options = []expr.Option{ options := params[1].(map[string]any) method, _ := options["method"].(string) headers, _ := options["headers"].(map[string]any) - req, err = newRequest(method, url, headers) + body, _ := options["body"].(string) + req, err = newRequest(method, url, headers, body) } else { req, err = http.NewRequest("GET", url, nil) } @@ -105,11 +113,19 @@ var Options = []expr.Option{ ), } -func Run(input string) (any, error) { - program, err := expr.Compile(input, Options...) +func Compile(input string) (*vm.Program, error) { + return expr.Compile(input, Options...) +} + +func Eval(input string, env any) (any, error) { + program, err := Compile(input) if err != nil { return nil, err } - return expr.Run(program, nil) + return expr.Run(program, env) +} + +func Run(program *vm.Program, env any) (any, error) { + return vm.Run(program, env) } diff --git a/pkg/expr/expr_test.go b/pkg/expr/expr_test.go index 14e75b2a..096afcdc 100644 --- a/pkg/expr/expr_test.go +++ b/pkg/expr/expr_test.go @@ -7,11 +7,11 @@ import ( ) func TestMatchHost(t *testing.T) { - v, err := Run(` + v, err := Eval(` let url = "rtsp://user:pass@192.168.1.123/cam/realmonitor?..."; let host = match(url, "//[^/]+")[0][2:]; host -`) +`, nil) require.Nil(t, err) require.Equal(t, "user:pass@192.168.1.123", v) } diff --git a/pkg/flussonic/flussonic.go b/pkg/flussonic/flussonic.go new file mode 100644 index 00000000..70b6e9d4 --- /dev/null +++ b/pkg/flussonic/flussonic.go @@ -0,0 +1,176 @@ +package flussonic + +import ( + "strings" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/iso" + "github.com/gorilla/websocket" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + conn *websocket.Conn + + videoTrackID, audioTrackID uint32 + videoTimeScale, audioTimeScale float32 +} + +func Dial(source string) (core.Producer, error) { + url, _ := strings.CutPrefix(source, "flussonic:") + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flussonic", + Protocol: core.Before(url, ":"), // wss + RemoteAddr: conn.RemoteAddr().String(), + URL: url, + Transport: conn, + }, + conn: conn, + } + + if err = prod.probe(); err != nil { + _ = conn.Close() + return nil, err + } + + return prod, nil +} + +func (p *Producer) probe() error { + var init struct { + //Metadata struct { + // Tracks []struct { + // Width int `json:"width,omitempty"` + // Height int `json:"height,omitempty"` + // Fps int `json:"fps,omitempty"` + // Content string `json:"content"` + // TrackId string `json:"trackId"` + // Bitrate int `json:"bitrate"` + // } `json:"tracks"` + //} `json:"metadata"` + Tracks []struct { + Content string `json:"content"` + Id uint32 `json:"id"` + Payload []byte `json:"payload"` + } `json:"tracks"` + //Type string `json:"type"` + } + + if err := p.conn.ReadJSON(&init); err != nil { + return err + } + + var timeScale uint32 + + for _, track := range init.Tracks { + atoms, _ := iso.DecodeAtoms(track.Payload) + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomMdhd: + timeScale = atom.TimeScale + case *iso.AtomVideo: + switch atom.Name { + case "avc1": + codec := h264.AVCCToCodec(atom.Config) + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + p.videoTrackID = track.Id + p.videoTimeScale = float32(codec.ClockRate) / float32(timeScale) + } + case *iso.AtomAudio: + switch atom.Name { + case "mp4a": + codec := aac.ConfigToCodec(atom.Config) + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + p.audioTrackID = track.Id + p.audioTimeScale = float32(codec.ClockRate) / float32(timeScale) + } + } + } + } + + return nil +} + +func (p *Producer) Start() error { + if err := p.conn.WriteMessage(websocket.TextMessage, []byte("resume")); err != nil { + return err + } + + receivers := make(map[uint32]*core.Receiver) + timeScales := make(map[uint32]float32) + + for _, receiver := range p.Receivers { + switch receiver.Codec.Kind() { + case core.KindVideo: + receivers[p.videoTrackID] = receiver + timeScales[p.videoTrackID] = p.videoTimeScale + case core.KindAudio: + receivers[p.audioTrackID] = receiver + timeScales[p.audioTrackID] = p.audioTimeScale + } + } + + ch := make(chan []byte, 10) + defer close(ch) + + go func() { + for b := range ch { + atoms, err := iso.DecodeAtoms(b) + if err != nil { + continue + } + + var trackID uint32 + var decodeTime uint64 + + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomTfhd: + trackID = atom.TrackID + case *iso.AtomTfdt: + decodeTime = atom.DecodeTime + case *iso.AtomMdat: + b = atom.Data + } + } + + if recv := receivers[trackID]; recv != nil { + timestamp := uint32(float32(decodeTime) * timeScales[trackID]) + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: b, + } + recv.WriteRTP(packet) + } + } + }() + + for { + mType, b, err := p.conn.ReadMessage() + if err != nil { + return err + } + if mType == websocket.BinaryMessage { + p.Recv += len(b) + ch <- b + } + } +} diff --git a/pkg/h264/avcc.go b/pkg/h264/avcc.go index c80ea083..dd3a5687 100644 --- a/pkg/h264/avcc.go +++ b/pkg/h264/avcc.go @@ -16,6 +16,11 @@ func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { ps := JoinNALU(sps, pps) return func(packet *rtp.Packet) { + // this can happen for FLV from FFmpeg + if NALUType(packet.Payload) == NALUTypeSEI { + size := int(binary.BigEndian.Uint32(packet.Payload)) + 4 + packet.Payload = packet.Payload[size:] + } if NALUType(packet.Payload) == NALUTypeIFrame { packet.Payload = Join(ps, packet.Payload) } @@ -82,7 +87,15 @@ func AVCCToCodec(avcc []byte) *core.Codec { buf := bytes.NewBufferString("packetization-mode=1") for { + n := len(avcc) + if n < 4 { + break + } + size := 4 + int(binary.BigEndian.Uint32(avcc)) + if n < size { + break + } switch NALUType(avcc) { case NALUTypeSPS: @@ -95,11 +108,7 @@ func AVCCToCodec(avcc []byte) *core.Codec { buf.WriteString(base64.StdEncoding.EncodeToString(avcc[4:size])) } - if size < len(avcc) { - avcc = avcc[size:] - } else { - break - } + avcc = avcc[size:] } return &core.Codec{ diff --git a/pkg/h264/h264_test.go b/pkg/h264/h264_test.go index 8b9fb737..9f02b62b 100644 --- a/pkg/h264/h264_test.go +++ b/pkg/h264/h264_test.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -91,5 +90,21 @@ func TestDecodeSPS2(t *testing.T) { require.Nil(t, err) sps := DecodeSPS(b) - assert.Nil(t, sps) // broken SPS? + require.Equal(t, uint16(928), sps.Width()) + require.Equal(t, uint16(576), sps.Height()) + + s = "Z2QAHq2EAQwgCGEAQwgCGEAQwgCEO1BQF/yzcBAQFAAAD6AAAXcCEA==" // unknown + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(640), sps.Width()) + require.Equal(t, uint16(360), sps.Height()) +} + +func TestAVCCToCodec(t *testing.T) { + s := "000000196764001fac2484014016ec0440000003004000000c23c60c920000000568ee32c8b0000000d365" + b, _ := hex.DecodeString(s) + codec := AVCCToCodec(b) + require.Equal(t, "packetization-mode=1;profile-level-id=64001f;sprop-parameter-sets=Z2QAH6wkhAFAFuwEQAAAAwBAAAAMI8YMkg==,aO4yyLA=", codec.FmtpLine) } diff --git a/pkg/h264/sps.go b/pkg/h264/sps.go index 6bcca669..1ac73945 100644 --- a/pkg/h264/sps.go +++ b/pkg/h264/sps.go @@ -88,6 +88,8 @@ func (s *SPS) Height() uint16 { } func DecodeSPS(sps []byte) *SPS { + // https://developer.ridgerun.com/wiki/index.php/H264_Analysis_Tools + // ffmpeg -i file.h264 -c copy -bsf:v trace_headers -f null - r := bits.NewReader(sps) hdr := r.ReadByte() diff --git a/pkg/h265/rtp.go b/pkg/h265/rtp.go index 7a55b408..9c571ec5 100644 --- a/pkg/h265/rtp.go +++ b/pkg/h265/rtp.go @@ -9,11 +9,12 @@ import ( ) func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { - //vps, sps, pps := GetParameterSet(codec.FmtpLine) - //ps := h264.EncodeAVC(vps, sps, pps) + vps, sps, pps := GetParameterSet(codec.FmtpLine) + ps := h264.JoinNALU(vps, sps, pps) buf := make([]byte, 0, 512*1024) // 512K var nuStart int + var seqNum uint16 return func(packet *rtp.Packet) { data := packet.Payload @@ -34,28 +35,55 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { } } + // when we collect data into one buffer, we need to make sure + // that all of it falls into the same sequence + if len(buf) > 0 && packet.SequenceNumber-seqNum != 1 { + //log.Printf("broken H265 sequence") + buf = buf[:0] // drop data + return + } + + seqNum = packet.SequenceNumber + if nuType == NALUTypeFU { switch data[2] >> 6 { - case 2: // begin + case 0b10: // begin nuType = data[2] & 0x3F // push PS data before keyframe - //if len(buf) == 0 && nuType >= 19 && nuType <= 21 { - // buf = append(buf, ps...) - //} + if len(buf) == 0 && nuType >= 19 && nuType <= 21 { + buf = append(buf, ps...) + } nuStart = len(buf) buf = append(buf, 0, 0, 0, 0) // NAL unit size buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1]) buf = append(buf, data[3:]...) return - case 0: // continue + case 0b00: // continue + if len(buf) == 0 { + //log.Printf("broken H265 fragment") + return + } + buf = append(buf, data[3:]...) return - case 1: // end + case 0b01: // end + if len(buf) == 0 { + //log.Printf("broken H265 fragment") + return + } + buf = append(buf, data[3:]...) + + if nuStart > len(buf)+4 { + //log.Printf("broken H265 fragment") + buf = buf[:0] // drop data + return + } + binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(buf)-nuStart-4)) - case 3: // wrong RFC 7798 realisation from OpenIPC project + case 0b11: // wrong RFC 7798 realisation from OpenIPC project // A non-fragmented NAL unit MUST NOT be transmitted in one FU; i.e., // the Start bit and End bit must not both be set to 1 in the same FU // header. @@ -65,10 +93,8 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { buf = append(buf, data[3:]...) } } else { - nuStart = len(buf) - buf = append(buf, 0, 0, 0, 0) // NAL unit size + buf = binary.BigEndian.AppendUint32(buf, uint32(len(data))) // NAL unit size buf = append(buf, data...) - binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(data))) } // collect all NAL Units for Access Unit diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 42037d96..973983ec 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -12,7 +12,7 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory { hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware), ServiceCameraRTPStreamManagement(), //hap.ServiceHAPProtocolInformation(), - //ServiceMicrophone(), + ServiceMicrophone(), }, } acc.InitIID() @@ -30,17 +30,17 @@ func ServiceMicrophone() *hap.Service { Perms: hap.EVPRPW, //Descr: "Mute", }, - { - Type: "119", - Format: hap.FormatUInt8, - Value: 100, - Perms: hap.EVPRPW, - //Descr: "Volume", - //Unit: hap.UnitPercentage, - //MinValue: 0, - //MaxValue: 100, - //MinStep: 1, - }, + //{ + // Type: "119", + // Format: hap.FormatUInt8, + // Value: 100, + // Perms: hap.EVPRPW, + // //Descr: "Volume", + // //Unit: hap.UnitPercentage, + // //MinValue: 0, + // //MaxValue: 100, + // //MinStep: 1, + //}, }, } } @@ -62,7 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service { VideoAttrs: []VideoAttrs{ {Width: 1920, Height: 1080, Framerate: 30}, {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 }, }, }, diff --git a/pkg/hap/camera/ch131_data_stream.go b/pkg/hap/camera/ch131_data_stream.go new file mode 100644 index 00000000..067b01b4 --- /dev/null +++ b/pkg/hap/camera/ch131_data_stream.go @@ -0,0 +1,17 @@ +package camera + +const TypeSetupDataStreamTransport = "131" + +type SetupDataStreamRequest struct { + SessionCommandType byte `tlv8:"1"` + TransportType byte `tlv8:"2"` + ControllerKeySalt string `tlv8:"3"` +} + +type SetupDataStreamResponse struct { + Status byte `tlv8:"1"` + TransportTypeSessionParameters struct { + TCPListeningPort uint16 `tlv8:"1"` + } `tlv8:"2"` + AccessoryKeySalt string `tlv8:"3"` +} diff --git a/pkg/hap/hds/hds.go b/pkg/hap/hds/hds.go new file mode 100644 index 00000000..a7b2c74a --- /dev/null +++ b/pkg/hap/hds/hds.go @@ -0,0 +1,123 @@ +// Package hds - HomeKit Data Stream +package hds + +import ( + "bufio" + "encoding/binary" + "io" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" + "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) { + writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key") + if err != nil { + return nil, err + } + + readKey, err := hkdf.Sha512(key, salt, "HDS-Read-Encryption-Key") + if err != nil { + return nil, err + } + + c := &Conn{ + conn: conn, + rd: bufio.NewReaderSize(conn, 32*1024), + wr: bufio.NewWriterSize(conn, 32*1024), + } + + if controller { + c.decryptKey, c.encryptKey = readKey, writeKey + } else { + c.decryptKey, c.encryptKey = writeKey, readKey + } + + return c, nil +} + +type Conn struct { + conn net.Conn + + rd *bufio.Reader + wr *bufio.Writer + + decryptKey []byte + encryptKey []byte + decryptCnt uint64 + encryptCnt uint64 +} + +func (c *Conn) Read(p []byte) (n int, err error) { + verify := make([]byte, 4) + if _, err = io.ReadFull(c.rd, verify); err != nil { + return + } + + n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF) + + ciphertext := make([]byte, n+secure.Overhead) + if _, err = io.ReadFull(c.rd, ciphertext); err != nil { + return + } + + nonce := make([]byte, secure.NonceSize) + binary.LittleEndian.PutUint64(nonce, c.decryptCnt) + c.decryptCnt++ + + _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify) + return +} + +func (c *Conn) Write(b []byte) (n int, err error) { + n = len(b) + + verify := make([]byte, 4) + binary.BigEndian.PutUint32(verify, 0x01000000|uint32(n)) + if _, err = c.wr.Write(verify); err != nil { + return + } + + nonce := make([]byte, secure.NonceSize) + binary.LittleEndian.PutUint64(nonce, c.encryptCnt) + c.encryptCnt++ + + buf := make([]byte, n+secure.Overhead) + if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil { + return + } + + if _, err = c.wr.Write(buf); err != nil { + return + } + + err = c.wr.Flush() + return +} + +func (c *Conn) Close() error { + return c.conn.Close() +} + +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +func (c *Conn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +func (c *Conn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} diff --git a/pkg/hap/hds/hds_test.go b/pkg/hap/hds/hds_test.go new file mode 100644 index 00000000..f1c85455 --- /dev/null +++ b/pkg/hap/hds/hds_test.go @@ -0,0 +1,35 @@ +package hds + +import ( + "bufio" + "bytes" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestEncryption(t *testing.T) { + key := []byte(core.RandString(16, 0)) + salt := core.RandString(32, 0) + + c, err := Client(nil, key, salt, true) + require.NoError(t, err) + + buf := bytes.NewBuffer(nil) + c.wr = bufio.NewWriter(buf) + + n, err := c.Write([]byte("test")) + require.NoError(t, err) + require.Equal(t, 4, n) + + c, err = Client(nil, key, salt, false) + c.rd = bufio.NewReader(buf) + require.NoError(t, err) + + b := make([]byte, 32) + n, err = c.Read(b) + require.NoError(t, err) + + require.Equal(t, "test", string(b[:n])) +} diff --git a/pkg/hap/helpers.go b/pkg/hap/helpers.go index ea5e4059..3900f935 100644 --- a/pkg/hap/helpers.go +++ b/pkg/hap/helpers.go @@ -64,17 +64,24 @@ type JSONCharacters struct { } type JSONCharacter struct { - AID uint8 `json:"aid"` - IID uint64 `json:"iid"` - Value any `json:"value,omitempty"` - Event any `json:"ev,omitempty"` + AID uint8 `json:"aid"` + IID uint64 `json:"iid"` + Status any `json:"status,omitempty"` + Value any `json:"value,omitempty"` + Event any `json:"ev,omitempty"` } +// 4.2.1.2 Invalid Setup Codes +const insecurePINs = "00000000 11111111 22222222 33333333 44444444 55555555 66666666 77777777 88888888 99999999 12345678 87654321" + func SanitizePin(pin string) (string, error) { s := strings.ReplaceAll(pin, "-", "") if len(s) != 8 { return "", errors.New("hap: wrong PIN format: " + pin) } + if strings.Contains(insecurePINs, s) { + return "", errors.New("hap: insecure PIN: " + pin) + } // 123-45-678 return s[:3] + "-" + s[3:5] + "-" + s[5:], nil } diff --git a/pkg/hap/secure/secure.go b/pkg/hap/secure/secure.go index 0c33b356..576ee127 100644 --- a/pkg/hap/secure/secure.go +++ b/pkg/hap/secure/secure.go @@ -6,7 +6,6 @@ import ( "errors" "io" "net" - "sync" "time" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" @@ -24,7 +23,7 @@ type Conn struct { encryptCnt uint64 decryptCnt uint64 - mx sync.Mutex + SharedKey []byte } func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { @@ -42,6 +41,8 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { conn: conn, rd: bufio.NewReaderSize(conn, 32*1024), wr: bufio.NewWriterSize(conn, 32*1024), + + SharedKey: sharedKey, } if isClient { diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 41a6de58..7af27ea4 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -46,6 +46,8 @@ func Marshal(v any) ([]byte, error) { } switch kind { + case reflect.Slice: + return appendSlice(nil, value) case reflect.Struct: return appendStruct(nil, value) } @@ -53,6 +55,23 @@ func Marshal(v any) ([]byte, error) { return nil, errors.New("tlv8: not implemented: " + kind.String()) } +// separator the most confusing meaning in the documentation. +// It can have a value of 0x00 or 0xFF or even 0x05. +const separator = 0xFF + +func appendSlice(b []byte, value reflect.Value) ([]byte, error) { + for i := 0; i < value.Len(); i++ { + if i > 0 { + b = append(b, separator, 0) + } + var err error + if b, err = appendStruct(b, value.Index(i)); err != nil { + return nil, err + } + } + return b, nil +} + func appendStruct(b []byte, value reflect.Value) ([]byte, error) { valueType := value.Type() @@ -121,7 +140,7 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { case reflect.Slice: for i := 0; i < value.Len(); i++ { if i > 0 { - b = append(b, 0, 0) + b = append(b, separator, 0) } if b, err = appendValue(b, tag, value.Index(i)); err != nil { return nil, err @@ -142,12 +161,13 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { return nil, errors.New("tlv8: not implemented: " + value.Kind().String()) } -func UnmarshalBase64(s string, v any) error { +func UnmarshalBase64(in any, out any) error { + s, _ := in.(string) // protect from in == nil data, err := base64.StdEncoding.DecodeString(s) if err != nil { return err } - return Unmarshal(data, v) + return Unmarshal(data, out) } func UnmarshalReader(r io.Reader, v any) error { @@ -178,64 +198,86 @@ func Unmarshal(data []byte, v any) error { kind = value.Kind() } - if kind != reflect.Struct { - return errors.New("tlv8: not implemented: " + kind.String()) + switch kind { + case reflect.Slice: + return unmarshalSlice(data, value) + case reflect.Struct: + return unmarshalStruct(data, value) } - return unmarshalStruct(data, value) + return errors.New("tlv8: not implemented: " + kind.String()) } -func unmarshalStruct(b []byte, value reflect.Value) error { - var waitSlice bool +// unmarshalTLV can return two types of errors: +// - critical and then the value of []byte will be nil +// - not critical and then []byte will contain the value +func unmarshalTLV(b []byte, value reflect.Value) ([]byte, error) { + if len(b) < 2 { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) + } - for len(b) >= 2 { - t := b[0] - l := int(b[1]) + t := b[0] + l := int(b[1]) - // array item divider - if t == 0 && l == 0 { - b = b[2:] - waitSlice = true - continue + // array item divider (t == 0x00 || t == 0xFF) + if l == 0 { + return b[2:], errors.New("tlv8: zero item") + } + + var v []byte + + for { + if len(b) < 2+l { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) } - var v []byte + v = append(v, b[2:2+l]...) + b = b[2+l:] - for { - if len(b) < 2+l { - return errors.New("tlv8: wrong size: " + value.Type().Name()) + // if size == 255 and same tag - continue read big payload + if l < 255 || len(b) < 2 || b[0] != t { + break + } + + l = int(b[1]) + } + + tag := strconv.Itoa(int(t)) + + valueField, ok := getStructField(value, tag) + if !ok { + return b, fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) + } + + if err := unmarshalValue(v, valueField); err != nil { + return nil, err + } + + return b, nil +} + +func unmarshalSlice(b []byte, value reflect.Value) error { + valueIndex := value.Index(growSlice(value)) + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, valueIndex); err != nil { + if b != nil { + valueIndex = value.Index(growSlice(value)) + continue } - - v = append(v, b[2:2+l]...) - b = b[2+l:] - - // if size == 255 and same tag - continue read big payload - if l < 255 || len(b) < 2 || b[0] != t { - break - } - - l = int(b[1]) - } - - tag := strconv.Itoa(int(t)) - - valueField, ok := getStructField(value, tag) - if !ok { - return fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - - if waitSlice { - if valueField.Kind() != reflect.Slice { - return fmt.Errorf("tlv8: should be slice T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - waitSlice = false - } - - if err := unmarshalValue(v, valueField); err != nil { return err } } + return nil +} +func unmarshalStruct(b []byte, value reflect.Value) error { + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, value); b == nil && err != nil { + return err + } + } return nil } diff --git a/pkg/hap/tlv8/tlv8_test.go b/pkg/hap/tlv8/tlv8_test.go index 5ac41fec..bb44c981 100644 --- a/pkg/hap/tlv8/tlv8_test.go +++ b/pkg/hap/tlv8/tlv8_test.go @@ -2,6 +2,7 @@ package tlv8 import ( "encoding/hex" + "strings" "testing" "github.com/stretchr/testify/require" @@ -107,3 +108,49 @@ func TestInterface(t *testing.T) { require.Equal(t, src, dst) } + +func TestSlice1(t *testing.T) { + var v struct { + VideoAttrs []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } `tlv8:"3"` + } + + s := `030b010280070202380403011e ff00 030b010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v.VideoAttrs, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} + +func TestSlice2(t *testing.T) { + var v []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } + + s := `010280070202380403011e ff00 010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} diff --git a/pkg/hass/client.go b/pkg/hass/client.go index 5b236051..a9ea0264 100644 --- a/pkg/hass/client.go +++ b/pkg/hass/client.go @@ -6,7 +6,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) type Client struct { diff --git a/pkg/homekit/helpers.go b/pkg/homekit/helpers.go index a1719671..f5a17319 100644 --- a/pkg/homekit/helpers.go +++ b/pkg/homekit/helpers.go @@ -50,7 +50,7 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media { mediaCodec := &core.Codec{ Name: audioCodecs[codec.CodecType], ClockRate: audioSampleRates[sampleRate], - Channels: uint16(param.Channels), + Channels: param.Channels, } if mediaCodec.Name == core.CodecELD { diff --git a/pkg/homekit/proxy.go b/pkg/homekit/proxy.go index 77170fe2..be233042 100644 --- a/pkg/homekit/proxy.go +++ b/pkg/homekit/proxy.go @@ -3,65 +3,210 @@ package homekit import ( "bufio" "bytes" + "encoding/json" + "fmt" + "io" "net" "net/http" "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/hap/hds" + "github.com/AlexxIT/go2rtc/pkg/hap/secure" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" ) func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFunc { - return func(controller net.Conn) error { - accessory, err := dial() + return func(con net.Conn) error { + defer con.Close() + + acc, err := dial() + if err != nil { + return err + } + defer acc.Close() + + pr := &Proxy{ + con: con.(*secure.Conn), + acc: acc.(*secure.Conn), + res: make(chan *http.Response), + } + + // accessory (ex. Camera) => controller (ex. iPhone) + go pr.handleAcc() + + // controller => accessory + return pr.handleCon(pair) + } +} + +type Proxy struct { + con *secure.Conn + acc *secure.Conn + res chan *http.Response +} + +func (p *Proxy) handleCon(pair ServerPair) error { + var hdsCharIID uint64 + + rd := bufio.NewReader(p.con) + for { + req, err := http.ReadRequest(rd) if err != nil { return err } - // accessory (ex. Camera) => controller (ex. iPhone) - go proxy(accessory, controller, nil) + var hdsConSalt string - // controller => accessory - return proxy(controller, accessory, pair) + switch { + case req.Method == "POST" && req.URL.Path == hap.PathPairings: + var res *http.Response + if res, err = handlePairings(p.con, req, pair); err != nil { + return err + } + if err = res.Write(p.con); err != nil { + return err + } + continue + case req.Method == "PUT" && req.URL.Path == hap.PathCharacteristics && hdsCharIID != 0: + body, _ := io.ReadAll(req.Body) + var v hap.JSONCharacters + _ = json.Unmarshal(body, &v) + for _, char := range v.Value { + if char.IID == hdsCharIID { + var hdsReq camera.SetupDataStreamRequest + _ = tlv8.UnmarshalBase64(char.Value, &hdsReq) + hdsConSalt = hdsReq.ControllerKeySalt + break + } + } + req.Body = io.NopCloser(bytes.NewReader(body)) + } + + if err = req.Write(p.acc); err != nil { + return err + } + + res := <-p.res + + switch { + case req.Method == "GET" && req.URL.Path == hap.PathAccessories: + body, _ := io.ReadAll(res.Body) + var v hap.JSONAccessories + if err = json.Unmarshal(body, &v); err != nil { + return err + } + for _, acc := range v.Value { + if char := acc.GetCharacter(camera.TypeSetupDataStreamTransport); char != nil { + hdsCharIID = char.IID + } + break + } + res.Body = io.NopCloser(bytes.NewReader(body)) + + case hdsConSalt != "": + body, _ := io.ReadAll(res.Body) + var v hap.JSONCharacters + _ = json.Unmarshal(body, &v) + for i, char := range v.Value { + if char.IID == hdsCharIID { + var hdsRes camera.SetupDataStreamResponse + _ = tlv8.UnmarshalBase64(char.Value, &hdsRes) + + hdsAccSalt := hdsRes.AccessoryKeySalt + hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort) + + // swtich accPort to conPort + hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt) + if err != nil { + return err + } + + hdsRes.TransportTypeSessionParameters.TCPListeningPort = uint16(hdsPort) + if v.Value[i].Value, err = tlv8.MarshalBase64(hdsRes); err != nil { + return err + } + body, _ = json.Marshal(v) + res.ContentLength = int64(len(body)) + break + } + } + res.Body = io.NopCloser(bytes.NewReader(body)) + } + + if err = res.Write(p.con); err != nil { + return err + } } } -func proxy(r, w net.Conn, pair ServerPair) error { - b := make([]byte, 64*1024) +func (p *Proxy) handleAcc() error { + rd := bufio.NewReader(p.acc) for { - n, err := r.Read(b) + res, err := hap.ReadResponse(rd, nil) if err != nil { - break + return err } - if pair != nil && bytes.HasPrefix(b[:n], []byte("POST /pairings HTTP/1.1")) { - buf := bytes.NewBuffer(b[:n]) - req, err := http.ReadRequest(bufio.NewReader(buf)) - if err != nil { - return err - } - - res, err := handlePairings(r, req, pair) - if err != nil { - return err - } - - buf.Reset() - - if err = res.Write(buf); err != nil { - return err - } - if _, err = buf.WriteTo(r); err != nil { + if res.Proto == hap.ProtoEvent { + if err = res.Write(p.con); err != nil { return err } continue } - //log.Printf("[hap] %d bytes => %s\n%.512s", n, w.RemoteAddr(), b[:n]) - - if _, err = w.Write(b[:n]); err != nil { - break + // important to read body before next read response + body, err := io.ReadAll(res.Body) + if err != nil { + return err } + res.Body = io.NopCloser(bytes.NewReader(body)) + + p.res <- res } - _ = r.Close() - _ = w.Close() - return nil +} + +func (p *Proxy) listenHDS(accPort int, salt string) (int, error) { + ln, err := net.ListenTCP("tcp", nil) + if err != nil { + return 0, err + } + + go func() { + defer ln.Close() + + // raw controller conn + con, err := ln.Accept() + if err != nil { + return + } + defer con.Close() + + // secured controller conn (controlle=false because we are accessory) + con, err = hds.Client(con, p.con.SharedKey, salt, false) + if err != nil { + return + } + + accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP + + // raw accessory conn + acc, err := net.Dial("tcp", fmt.Sprintf("%s:%d", accIP, accPort)) + if err != nil { + return + } + defer acc.Close() + + // secured accessory conn (controller=true because we are controller) + acc, err = hds.Client(acc, p.acc.SharedKey, salt, true) + if err != nil { + return + } + + go io.Copy(con, acc) + _, _ = io.Copy(acc, con) + }() + + conPort := ln.Addr().(*net.TCPAddr).Port + return conPort, nil } diff --git a/pkg/ioctl/README.md b/pkg/ioctl/README.md new file mode 100644 index 00000000..41f82dff --- /dev/null +++ b/pkg/ioctl/README.md @@ -0,0 +1,3 @@ +# IOCTL + +This is just an example how Linux IOCTL constants works. diff --git a/pkg/ioctl/ioctl.go b/pkg/ioctl/ioctl.go new file mode 100644 index 00000000..0f21e17f --- /dev/null +++ b/pkg/ioctl/ioctl.go @@ -0,0 +1,28 @@ +package ioctl + +import ( + "bytes" +) + +func Str(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} + +func io(mode byte, type_ byte, number byte, size uint16) uintptr { + return uintptr(mode)<<30 | uintptr(size)<<16 | uintptr(type_)<<8 | uintptr(number) +} + +func IOR(type_ byte, number byte, size uint16) uintptr { + return io(read, type_, number, size) +} + +func IOW(type_ byte, number byte, size uint16) uintptr { + return io(write, type_, number, size) +} + +func IORW(type_ byte, number byte, size uint16) uintptr { + return io(read|write, type_, number, size) +} diff --git a/pkg/ioctl/ioctl_be.go b/pkg/ioctl/ioctl_be.go new file mode 100644 index 00000000..60de9c42 --- /dev/null +++ b/pkg/ioctl/ioctl_be.go @@ -0,0 +1,8 @@ +//go:build arm || arm64 || 386 || amd64 + +package ioctl + +const ( + write = 1 + read = 2 +) diff --git a/pkg/ioctl/ioctl_le.go b/pkg/ioctl/ioctl_le.go new file mode 100644 index 00000000..3bdb1f62 --- /dev/null +++ b/pkg/ioctl/ioctl_le.go @@ -0,0 +1,8 @@ +//go:build mipsle + +package ioctl + +const ( + read = 1 + write = 2 +) diff --git a/pkg/ioctl/ioctl_linux.go b/pkg/ioctl/ioctl_linux.go new file mode 100644 index 00000000..ed38f6ac --- /dev/null +++ b/pkg/ioctl/ioctl_linux.go @@ -0,0 +1,14 @@ +package ioctl + +import ( + "syscall" + "unsafe" +) + +func Ioctl(fd int, req uint, arg unsafe.Pointer) error { + _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)) + if err != 0 { + return err + } + return nil +} diff --git a/pkg/ioctl/ioctl_test.go b/pkg/ioctl/ioctl_test.go new file mode 100644 index 00000000..52657e64 --- /dev/null +++ b/pkg/ioctl/ioctl_test.go @@ -0,0 +1,16 @@ +package ioctl + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIOR(t *testing.T) { + // #define SNDRV_PCM_IOCTL_INFO _IOR('A', 0x01, struct snd_pcm_info) + if runtime.GOARCH == "arm64" { + c := IOR('A', 0x01, 288) + require.Equal(t, uintptr(0x81204101), c) + } +} diff --git a/pkg/iso/reader.go b/pkg/iso/reader.go index ec436af7..175e2563 100644 --- a/pkg/iso/reader.go +++ b/pkg/iso/reader.go @@ -1,6 +1,7 @@ package iso import ( + "bytes" "encoding/binary" "io" @@ -10,89 +11,192 @@ import ( type Atom struct { Name string Data []byte - - DecodeTime uint64 - - SamplesDuration []uint32 - SamplesSize []uint32 } -func DecodeAtoms(b []byte) ([]*Atom, error) { - var atoms []*Atom - for len(b) > 8 { - size := binary.BigEndian.Uint32(b) - if uint32(len(b)) < size { - return nil, io.EOF +type AtomTkhd struct { + TrackID uint32 +} + +type AtomMdhd struct { + TimeScale uint32 +} + +type AtomVideo struct { + Name string + Config []byte +} + +type AtomAudio struct { + Name string + Channels uint16 + SampleRate uint32 + Config []byte +} + +type AtomMfhd struct { + Sequence uint32 +} + +type AtomMdat struct { + Data []byte +} + +type AtomTfhd struct { + TrackID uint32 + SampleDuration uint32 + SampleSize uint32 + SampleFlags uint32 +} +type AtomTfdt struct { + DecodeTime uint64 +} + +type AtomTrun struct { + DataOffset uint32 + FirstSampleFlags uint32 + SamplesDuration []uint32 + SamplesSize []uint32 + SamplesFlags []uint32 + SamplesCTS []uint32 +} + +func DecodeAtom(b []byte) (any, error) { + size := binary.BigEndian.Uint32(b) + if len(b) < int(size) { + return nil, io.EOF + } + + name := string(b[4:8]) + data := b[8:size] + + switch name { + // useful containers + case Moov, MoovTrak, MoovTrakMdia, MoovTrakMdiaMinf, MoovTrakMdiaMinfStbl, Moof, MoofTraf: + return DecodeAtoms(data) + + case MoovTrakTkhd: + return &AtomTkhd{TrackID: binary.BigEndian.Uint32(data[1+3+4+4:])}, nil + + case MoovTrakMdiaMdhd: + return &AtomMdhd{TimeScale: binary.BigEndian.Uint32(data[1+3+4+4:])}, nil + + case MoovTrakMdiaMinfStblStsd: + // support only 1 codec entry + if n := binary.BigEndian.Uint32(data[1+3:]); n == 1 { + return DecodeAtom(data[1+3+4:]) } - name := string(b[4:8]) - data := b[8:size] + case "avc1", "hev1": + b = data[6+2+2+2+4+4+4+2+2+4+4+4+2+32+2+2:] + atom, err := DecodeAtom(b) + if err != nil { + return nil, err + } + if conf, ok := atom.(*Atom); ok { + return &AtomVideo{Name: name, Config: conf.Data}, nil + } - b = b[size:] + case "mp4a": + atom := &AtomAudio{Name: name} - switch name { - case Moof, MoofTraf: - childs, err := DecodeAtoms(data) - if err != nil { - return nil, err + rd := bits.NewReader(data) + rd.ReadBytes(6 + 2 + 2 + 2 + 4) // skip + atom.Channels = rd.ReadUint16() + rd.ReadBytes(2 + 2 + 2) // skip + atom.SampleRate = uint32(rd.ReadFloat32()) + + atom2, _ := DecodeAtom(rd.Left()) + if conf, ok := atom2.(*Atom); ok { + _, b, _ = bytes.Cut(conf.Data, []byte{5, 0x80, 0x80, 0x80}) + if n := len(b); n > 0 && n > 1+int(b[0]) { + atom.Config = b[1 : 1+b[0]] } + } + return atom, nil + + case MoofMfhd: + return &AtomMfhd{Sequence: binary.BigEndian.Uint32(data[4:])}, nil + + case MoofTrafTfhd: + rd := bits.NewReader(data) + _ = rd.ReadByte() // version + flags := rd.ReadUint24() + + atom := &AtomTfhd{ + TrackID: rd.ReadUint32(), + } + + if flags&TfhdDefaultSampleDuration != 0 { + atom.SampleDuration = rd.ReadUint32() + + } + if flags&TfhdDefaultSampleSize != 0 { + atom.SampleSize = rd.ReadUint32() + } + if flags&TfhdDefaultSampleFlags != 0 { + atom.SampleFlags = rd.ReadUint32() // skip + } + + return atom, nil + + case MoofTrafTfdt: + return &AtomTfdt{DecodeTime: binary.BigEndian.Uint64(data[4:])}, nil + + case MoofTrafTrun: + rd := bits.NewReader(data) + _ = rd.ReadByte() // version + flags := rd.ReadUint24() + samples := rd.ReadUint32() + + atom := &AtomTrun{} + + if flags&TrunDataOffset != 0 { + atom.DataOffset = rd.ReadUint32() + } + if flags&TrunFirstSampleFlags != 0 { + atom.FirstSampleFlags = rd.ReadUint32() + } + + for i := uint32(0); i < samples; i++ { + if flags&TrunSampleDuration != 0 { + atom.SamplesDuration = append(atom.SamplesDuration, rd.ReadUint32()) + } + if flags&TrunSampleSize != 0 { + atom.SamplesSize = append(atom.SamplesSize, rd.ReadUint32()) + } + if flags&TrunSampleFlags != 0 { + atom.SamplesFlags = append(atom.SamplesFlags, rd.ReadUint32()) + } + if flags&TrunSampleCTS != 0 { + atom.SamplesCTS = append(atom.SamplesCTS, rd.ReadUint32()) + } + } + + return atom, nil + + case Mdat: + return &AtomMdat{Data: data}, nil + } + + return &Atom{Name: name, Data: data}, nil +} + +func DecodeAtoms(b []byte) (atoms []any, err error) { + for len(b) > 0 { + atom, err := DecodeAtom(b) + if err != nil { + return nil, err + } + + if childs, ok := atom.([]any); ok { atoms = append(atoms, childs...) - - case MoofMfhd, MoofTrafTfhd: - continue - - case MoofTrafTfdt: - if len(data) < 8 { - return nil, io.EOF - } - - dt := binary.BigEndian.Uint64(data[4:]) - atoms = append(atoms, &Atom{Name: name, DecodeTime: dt}) - - case MoofTrafTrun: - rd := bits.NewReader(data) - - _ = rd.ReadByte() // version - flags := rd.ReadUint24() - samples := rd.ReadUint32() - - if flags&TrunDataOffset != 0 { - _ = rd.ReadUint32() // skip - } - if flags&TrunFirstSampleFlags != 0 { - _ = rd.ReadUint32() // skip - } - - atom := &Atom{Name: name} - - for i := uint32(0); i < samples; i++ { - if flags&TrunSampleDuration != 0 { - atom.SamplesDuration = append(atom.SamplesDuration, rd.ReadUint32()) - } - if flags&TrunSampleSize != 0 { - atom.SamplesSize = append(atom.SamplesSize, rd.ReadUint32()) - } - if flags&TrunSampleFlags != 0 { - _ = rd.ReadUint32() // skip - } - if flags&TrunSampleCTS != 0 { - _ = rd.ReadUint32() // skip - } - } - - if rd.EOF { - return nil, io.EOF - } - + } else { atoms = append(atoms, atom) - - case Mdat: - atoms = append(atoms, &Atom{Name: name, Data: data}) - - default: - println("iso: unsupported atom: " + name) } + + size := binary.BigEndian.Uint32(b) + b = b[size:] } return atoms, nil diff --git a/pkg/ivideon/client.go b/pkg/ivideon/client.go deleted file mode 100644 index ef79010e..00000000 --- a/pkg/ivideon/client.go +++ /dev/null @@ -1,314 +0,0 @@ -package ivideon - -import ( - "bytes" - "encoding/binary" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/h264" - "github.com/AlexxIT/go2rtc/pkg/iso" - "github.com/gorilla/websocket" - "github.com/pion/rtp" -) - -type State byte - -const ( - StateNone State = iota - StateConn - StateHandle -) - -// Deprecated: should be rewritten to core.Connection -type Client struct { - core.Listener - - ID string - - conn *websocket.Conn - - medias []*core.Media - receiver *core.Receiver - - msg *message - t0 time.Time - - buffer chan []byte - state State - mu sync.Mutex - - recv int -} - -func Dial(source string) (*Client, error) { - id := strings.Replace(source[8:], "/", ":", 1) - client := &Client{ID: id} - if err := client.Dial(); err != nil { - return nil, err - } - return client, nil -} - -func (c *Client) Dial() (err error) { - resp, err := http.Get( - "https://openapi-alpha.ivideon.com/cameras/" + c.ID + - "/live_stream?op=GET&access_token=public&q=2&" + - "video_codecs=h264&format=ws-fmp4", - ) - - data, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - var v liveResponse - if err = json.Unmarshal(data, &v); err != nil { - return err - } - - if !v.Success { - return fmt.Errorf("wrong response: %s", data) - } - - c.conn, _, err = websocket.DefaultDialer.Dial(v.Result.URL, nil) - if err != nil { - return err - } - - if err = c.getTracks(); err != nil { - _ = c.conn.Close() - return err - } - - c.state = StateConn - - return nil -} - -func (c *Client) Handle() error { - // add delay to the stream for smooth playing (not a best solution) - c.t0 = time.Now().Add(time.Second) - - c.mu.Lock() - - if c.state == StateConn { - c.buffer = make(chan []byte, 5) - c.state = StateHandle - - // processing stream in separate thread for lower delay between packets - go c.worker(c.buffer) - } - - c.mu.Unlock() - - _, data, err := c.conn.ReadMessage() - if err != nil { - return err - } - - if c.receiver != nil && c.receiver.ID == c.msg.Track { - c.mu.Lock() - if c.state == StateHandle { - c.buffer <- data - c.recv += len(data) - } - c.mu.Unlock() - } - - // we have one unprocessed msg after getTracks - for { - _, data, err = c.conn.ReadMessage() - if err != nil { - return err - } - - var msg message - if err = json.Unmarshal(data, &msg); err != nil { - return err - } - - switch msg.Type { - case "stream-init": - continue - - case "metadata": - continue - - case "fragment": - _, data, err = c.conn.ReadMessage() - if err != nil { - return err - } - - if c.receiver != nil && c.receiver.ID == msg.Track { - c.mu.Lock() - if c.state == StateHandle { - c.buffer <- data - c.recv += len(data) - } - c.mu.Unlock() - } - - default: - return fmt.Errorf("wrong message type: %s", data) - } - } -} - -func (c *Client) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - switch c.state { - case StateNone: - return nil - case StateConn: - case StateHandle: - close(c.buffer) - } - - c.state = StateNone - - return c.conn.Close() -} - -func (c *Client) getTracks() error { - for { - _, data, err := c.conn.ReadMessage() - if err != nil { - return err - } - - var msg message - if err = json.Unmarshal(data, &msg); err != nil { - return err - } - - switch msg.Type { - case "metadata": - continue - - case "stream-init": - s := msg.CodecString - i := strings.IndexByte(s, '.') - if i > 0 { - s = s[:i] - } - - switch s { - case "avc1": // avc1.4d0029 - // skip multiple identical init - if c.receiver != nil { - continue - } - - i = bytes.Index(msg.Data, []byte("avcC")) - 4 - if i < 0 { - return fmt.Errorf("ivideon: wrong AVC: %s", msg.Data) - } - - avccLen := binary.BigEndian.Uint32(msg.Data[i:]) - data = msg.Data[i+8 : i+int(avccLen)] - - codec := h264.ConfigToCodec(data) - - media := &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{codec}, - } - c.medias = append(c.medias, media) - - c.receiver = core.NewReceiver(media, codec) - c.receiver.ID = msg.TrackID - - case "mp4a": // mp4a.40.2 - } - - case "fragment": - c.msg = &msg - return nil - - default: - return fmt.Errorf("wrong message type: %s", data) - } - } -} - -func (c *Client) worker(buffer chan []byte) { - for data := range buffer { - atoms, err := iso.DecodeAtoms(data) - if err != nil { - continue - } - - var trun *iso.Atom - var ts uint32 - - for _, atom := range atoms { - switch atom.Name { - case iso.MoofTrafTrun: - trun = atom - case iso.MoofTrafTfdt: - ts = uint32(atom.DecodeTime) - case iso.Mdat: - data = atom.Data - } - } - - if trun == nil || trun.SamplesDuration == nil || trun.SamplesSize == nil { - continue - } - - for i := 0; i < len(trun.SamplesDuration); i++ { - duration := trun.SamplesDuration[i] - size := trun.SamplesSize[i] - - // synchronize framerate for WebRTC and MSE - d := time.Duration(ts)*time.Millisecond - time.Since(c.t0) - if d < 0 { - d = time.Duration(duration) * time.Millisecond / 2 - } - time.Sleep(d) - - // can be SPS, PPS and IFrame in one packet - packet := &rtp.Packet{ - // ivideon clockrate=1000, RTP clockrate=90000 - Header: rtp.Header{Timestamp: ts * 90}, - Payload: data[:size], - } - c.receiver.WriteRTP(packet) - - data = data[size:] - ts += duration - } - } -} - -type liveResponse struct { - Result struct { - URL string `json:"url"` - } `json:"result"` - Success bool `json:"success"` -} - -type message struct { - Type string `json:"type"` - - CodecString string `json:"codec_string"` - Data []byte `json:"data"` - TrackID byte `json:"track_id"` - - Track byte `json:"track"` - StartTime float32 `json:"start_time"` - Duration float32 `json:"duration"` - IsKey bool `json:"is_key"` - DataOffset uint32 `json:"data_offset"` -} diff --git a/pkg/ivideon/ivideon.go b/pkg/ivideon/ivideon.go new file mode 100644 index 00000000..973b9ba0 --- /dev/null +++ b/pkg/ivideon/ivideon.go @@ -0,0 +1,187 @@ +package ivideon + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/gorilla/websocket" +) + +type Producer struct { + core.Connection + conn *websocket.Conn + + buf []byte + + dem *mp4.Demuxer +} + +func Dial(source string) (core.Producer, error) { + id := strings.Replace(source[8:], "/", ":", 1) + + url, err := GetLiveStream(id) + if err != nil { + return nil, err + } + + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "ivideon", + Protocol: core.Before(url, ":"), // wss + RemoteAddr: conn.RemoteAddr().String(), + Source: source, + URL: url, + Transport: conn, + }, + conn: conn, + } + + if err = prod.probe(); err != nil { + _ = conn.Close() + return nil, err + } + + return prod, nil +} + +func GetLiveStream(id string) (string, error) { + // &video_codecs=h264,h265&audio_codecs=aac,mp3,pcma,pcmu,none + resp, err := http.Get( + "https://openapi-alpha.ivideon.com/cameras/" + id + + "/live_stream?op=GET&access_token=public&q=2&video_codecs=h264&format=ws-fmp4", + ) + if err != nil { + return "", err + } + + var v struct { + Message string `json:"message"` + Result struct { + URL string `json:"url"` + } `json:"result"` + Success bool `json:"success"` + } + if err = json.NewDecoder(resp.Body).Decode(&v); err != nil { + return "", err + } + + if !v.Success { + return "", fmt.Errorf("ivideon: can't get live_stream: " + v.Message) + } + + return v.Result.URL, nil +} + +func (p *Producer) Start() error { + receivers := make(map[uint32]*core.Receiver) + for _, receiver := range p.Receivers { + trackID := p.dem.GetTrackID(receiver.Codec) + receivers[trackID] = receiver + } + + ch := make(chan []byte, 10) + defer close(ch) + + ch <- p.buf + + go func() { + // add delay to the stream for smooth playing (not a best solution) + t0 := time.Now() + + for data := range ch { + trackID, packets := p.dem.Demux(data) + if receiver := receivers[trackID]; receiver != nil { + clockRate := time.Duration(receiver.Codec.ClockRate) + for _, packet := range packets { + // synchronize framerate for WebRTC and MSE + ts := time.Second * time.Duration(packet.Timestamp) / clockRate + d := ts - time.Since(t0) + if d < 0 { + d = 10 * time.Millisecond + } + time.Sleep(d) + + receiver.WriteRTP(packet) + } + } + } + }() + + for { + var msg message + if err := p.conn.ReadJSON(&msg); err != nil { + return err + } + + switch msg.Type { + case "stream-init", "metadata": + continue + + case "fragment": + _, b, err := p.conn.ReadMessage() + if err != nil { + return err + } + + p.Recv += len(b) + ch <- b + + default: + return errors.New("ivideon: wrong message type: " + msg.Type) + } + } +} + +func (p *Producer) probe() (err error) { + p.dem = &mp4.Demuxer{} + + for { + var msg message + if err = p.conn.ReadJSON(&msg); err != nil { + return err + } + + switch msg.Type { + case "metadata": + continue + + case "stream-init": + // it's difficult to maintain audio + if strings.HasPrefix(msg.CodecString, "avc1") { + medias := p.dem.Probe(msg.Data) + p.Medias = append(p.Medias, medias...) + } + + case "fragment": + _, p.buf, err = p.conn.ReadMessage() + return + + default: + return errors.New("ivideon: wrong message type: " + msg.Type) + } + } +} + +type message struct { + Type string `json:"type"` + CodecString string `json:"codec_string"` + Data []byte `json:"data"` + //TrackID byte `json:"track_id"` + //Track byte `json:"track"` + //StartTime float32 `json:"start_time"` + //Duration float32 `json:"duration"` + //IsKey bool `json:"is_key"` + //DataOffset uint32 `json:"data_offset"` +} diff --git a/pkg/ivideon/producer.go b/pkg/ivideon/producer.go deleted file mode 100644 index 78084123..00000000 --- a/pkg/ivideon/producer.go +++ /dev/null @@ -1,51 +0,0 @@ -package ivideon - -import ( - "encoding/json" - - "github.com/AlexxIT/go2rtc/pkg/core" -) - -func (c *Client) GetMedias() []*core.Media { - return c.medias -} - -func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - if c.receiver != nil { - return c.receiver, nil - } - return nil, core.ErrCantGetTrack -} - -func (c *Client) Start() error { - err := c.Handle() - if c.buffer == nil { - return nil - } - return err -} - -func (c *Client) Stop() error { - if c.receiver != nil { - c.receiver.Close() - } - return c.Close() -} - -func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Connection{ - ID: core.ID(c), - FormatName: "ivideon", - Protocol: "ws", - URL: c.ID, - Medias: c.medias, - Recv: c.recv, - } - if c.conn != nil { - info.RemoteAddr = c.conn.RemoteAddr().String() - } - if c.receiver != nil { - info.Receivers = []*core.Receiver{c.receiver} - } - return json.Marshal(info) -} diff --git a/pkg/mp4/demuxer.go b/pkg/mp4/demuxer.go new file mode 100644 index 00000000..25c8c70e --- /dev/null +++ b/pkg/mp4/demuxer.go @@ -0,0 +1,116 @@ +package mp4 + +import ( + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/iso" + "github.com/pion/rtp" +) + +type Demuxer struct { + codecs map[uint32]*core.Codec + timeScales map[uint32]float32 +} + +func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { + var trackID, timeScale uint32 + + if d.codecs == nil { + d.codecs = make(map[uint32]*core.Codec) + d.timeScales = make(map[uint32]float32) + } + + atoms, _ := iso.DecodeAtoms(init) + for _, atom := range atoms { + var codec *core.Codec + + switch atom := atom.(type) { + case *iso.AtomTkhd: + trackID = atom.TrackID + case *iso.AtomMdhd: + timeScale = atom.TimeScale + case *iso.AtomVideo: + switch atom.Name { + case "avc1": + codec = h264.ConfigToCodec(atom.Config) + } + case *iso.AtomAudio: + switch atom.Name { + case "mp4a": + codec = aac.ConfigToCodec(atom.Config) + } + } + + if codec != nil { + d.codecs[trackID] = codec + d.timeScales[trackID] = float32(codec.ClockRate) / float32(timeScale) + + medias = append(medias, &core.Media{ + Kind: codec.Kind(), + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + } + } + + return +} + +func (d *Demuxer) GetTrackID(codec *core.Codec) uint32 { + for trackID, c := range d.codecs { + if c == codec { + return trackID + } + } + return 0 +} + +func (d *Demuxer) Demux(data2 []byte) (trackID uint32, packets []*core.Packet) { + atoms, err := iso.DecodeAtoms(data2) + if err != nil { + return 0, nil + } + + var ts uint32 + var trun *iso.AtomTrun + var data []byte + + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomTfhd: + trackID = atom.TrackID + case *iso.AtomTfdt: + ts = uint32(atom.DecodeTime) + case *iso.AtomTrun: + trun = atom + case *iso.AtomMdat: + data = atom.Data + } + } + + timeScale := d.timeScales[trackID] + if timeScale == 0 { + return 0, nil + } + + n := len(trun.SamplesDuration) + packets = make([]*core.Packet, n) + + for i := 0; i < n; i++ { + duration := trun.SamplesDuration[i] + size := trun.SamplesSize[i] + + // can be SPS, PPS and IFrame in one packet + timestamp := uint32(float32(ts) * timeScale) + packets[i] = &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: data[:size], + } + + data = data[size:] + ts += duration + } + + return +} diff --git a/pkg/mp4/muxer.go b/pkg/mp4/muxer.go index 5533a9a3..b371f684 100644 --- a/pkg/mp4/muxer.go +++ b/pkg/mp4/muxer.go @@ -89,12 +89,12 @@ func (m *Muxer) GetInit() ([]byte, error) { } mv.WriteAudioTrack( - uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, b, + uint32(i+1), codec.Name, codec.ClockRate, uint16(codec.Channels), b, ) case core.CodecOpus, core.CodecMP3, core.CodecPCMA, core.CodecPCMU, core.CodecFLAC: mv.WriteAudioTrack( - uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, nil, + uint32(i+1), codec.Name, codec.ClockRate, uint16(codec.Channels), nil, ) } } diff --git a/pkg/nest/api.go b/pkg/nest/api.go index c2f255c2..4e9e4dbd 100644 --- a/pkg/nest/api.go +++ b/pkg/nest/api.go @@ -166,42 +166,100 @@ func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) { uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices/" + deviceID + ":executeCommand" - req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + + maxRetries := 3 + retryDelay := time.Second * 30 + + for attempt := 0; attempt < maxRetries; attempt++ { + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return "", err + } + + // Handle 409 (Conflict), 429 (Too Many Requests), and 401 (Unauthorized) + if res.StatusCode == 409 || res.StatusCode == 429 || res.StatusCode == 401 { + res.Body.Close() + if attempt < maxRetries-1 { + // Get new token from Google + if err := a.refreshToken(); err != nil { + return "", err + } + time.Sleep(retryDelay) + retryDelay *= 2 // exponential backoff + continue + } + } + + defer res.Body.Close() + + if res.StatusCode != 200 { + return "", errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + Answer string `json:"answerSdp"` + ExpiresAt time.Time `json:"expiresAt"` + MediaSessionID string `json:"mediaSessionId"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return "", err + } + + a.StreamProjectID = projectID + a.StreamDeviceID = deviceID + a.StreamSessionID = resv.Results.MediaSessionID + a.StreamExpiresAt = resv.Results.ExpiresAt + + return resv.Results.Answer, nil + } + + return "", errors.New("nest: max retries exceeded") +} + +func (a *API) refreshToken() error { + // Get the cached API with matching token to get credentials + var refreshKey string + cacheMu.Lock() + for key, api := range cache { + if api.Token == a.Token { + refreshKey = key + break + } + } + cacheMu.Unlock() + + if refreshKey == "" { + return errors.New("nest: unable to find cached credentials") + } + + // Parse credentials from cache key + parts := strings.Split(refreshKey, ":") + if len(parts) != 3 { + return errors.New("nest: invalid cache key format") + } + clientID, clientSecret, refreshToken := parts[0], parts[1], parts[2] + + // Get new API instance which will refresh the token + newAPI, err := NewAPI(clientID, clientSecret, refreshToken) if err != nil { - return "", err + return err } - req.Header.Set("Authorization", "Bearer "+a.Token) - - client := &http.Client{Timeout: time.Second * 5000} - res, err := client.Do(req) - if err != nil { - return "", err - } - defer res.Body.Close() - - if res.StatusCode != 200 { - return "", errors.New("nest: wrong status: " + res.Status) - } - - var resv struct { - Results struct { - Answer string `json:"answerSdp"` - ExpiresAt time.Time `json:"expiresAt"` - MediaSessionID string `json:"mediaSessionId"` - } `json:"results"` - } - - if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { - return "", err - } - - a.StreamProjectID = projectID - a.StreamDeviceID = deviceID - a.StreamSessionID = resv.Results.MediaSessionID - a.StreamExpiresAt = resv.Results.ExpiresAt - - return resv.Results.Answer, nil + // Update current API with new token + a.Token = newAPI.Token + a.ExpiresAt = newAPI.ExpiresAt + return nil } func (a *API) ExtendStream() error { @@ -407,20 +465,22 @@ type Device struct { } func (a *API) StartExtendStreamTimer() { - // Calculate the duration until 30 seconds before the stream expires - duration := time.Until(a.StreamExpiresAt.Add(-30 * time.Second)) - a.extendTimer = time.AfterFunc(duration, func() { + if a.extendTimer != nil { + return + } + + a.extendTimer = time.NewTimer(time.Until(a.StreamExpiresAt) - time.Minute) + go func() { + <-a.extendTimer.C if err := a.ExtendStream(); err != nil { return } - duration = time.Until(a.StreamExpiresAt.Add(-30 * time.Second)) - a.extendTimer.Reset(duration) - }) + }() } func (a *API) StopExtendStreamTimer() { - if a.extendTimer == nil { - return + if a.extendTimer != nil { + a.extendTimer.Stop() + a.extendTimer = nil } - a.extendTimer.Stop() } diff --git a/pkg/nest/client.go b/pkg/nest/client.go index 93c4ce64..6a570913 100644 --- a/pkg/nest/client.go +++ b/pkg/nest/client.go @@ -4,11 +4,12 @@ import ( "errors" "net/url" "strings" + "time" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/rtsp" "github.com/AlexxIT/go2rtc/pkg/webrtc" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) type WebRTCClient struct { @@ -38,9 +39,26 @@ func Dial(rawURL string) (core.Producer, error) { return nil, errors.New("nest: wrong query") } - nestAPI, err := NewAPI(cliendID, cliendSecret, refreshToken) - if err != nil { - return nil, err + maxRetries := 3 + retryDelay := time.Second * 30 + + var nestAPI *API + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + nestAPI, err = NewAPI(cliendID, cliendSecret, refreshToken) + if err == nil { + break + } + lastErr = err + if attempt < maxRetries-1 { + time.Sleep(retryDelay) + retryDelay *= 2 // exponential backoff + } + } + + if nestAPI == nil { + return nil, lastErr } protocols := strings.Split(query.Get("protocols"), ",") @@ -79,48 +97,62 @@ func (c *WebRTCClient) MarshalJSON() ([]byte, error) { } func rtcConn(nestAPI *API, rawURL, projectID, deviceID string) (*WebRTCClient, error) { - rtcAPI, err := webrtc.NewAPI() - if err != nil { - return nil, err + maxRetries := 3 + retryDelay := time.Second * 30 + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + rtcAPI, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := rtcAPI.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + conn := webrtc.NewConn(pc) + conn.FormatName = "nest/webrtc" + conn.Mode = core.ModeActiveProducer + conn.Protocol = "http" + conn.URL = rawURL + + // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: "app"}, // important for Nest + } + + // 3. Create offer with candidates + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 4. Exchange SDP via Hass + answer, err := nestAPI.ExchangeSDP(projectID, deviceID, offer) + if err != nil { + lastErr = err + if attempt < maxRetries-1 { + time.Sleep(retryDelay) + retryDelay *= 2 + continue + } + return nil, err + } + + // 5. Set answer with remote medias + if err = conn.SetAnswer(answer); err != nil { + return nil, err + } + + return &WebRTCClient{conn: conn, api: nestAPI}, nil } - conf := pion.Configuration{} - pc, err := rtcAPI.NewPeerConnection(conf) - if err != nil { - return nil, err - } - - conn := webrtc.NewConn(pc) - conn.FormatName = "nest/webrtc" - conn.Mode = core.ModeActiveProducer - conn.Protocol = "http" - conn.URL = rawURL - - // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields - medias := []*core.Media{ - {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, - {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, - {Kind: "app"}, // important for Nest - } - - // 3. Create offer with candidates - offer, err := conn.CreateCompleteOffer(medias) - if err != nil { - return nil, err - } - - // 4. Exchange SDP via Hass - answer, err := nestAPI.ExchangeSDP(projectID, deviceID, offer) - if err != nil { - return nil, err - } - - // 5. Set answer with remote medias - if err = conn.SetAnswer(answer); err != nil { - return nil, err - } - - return &WebRTCClient{conn: conn, api: nestAPI}, nil + return nil, lastErr } func rtspConn(nestAPI *API, rawURL, projectID, deviceID string) (*RTSPClient, error) { diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index 936f65f6..77bbe0ff 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -3,9 +3,10 @@ package onvif import ( "bytes" "errors" + "fmt" "html" "io" - "net/http" + "net" "net/url" "regexp" "strings" @@ -43,7 +44,14 @@ func NewClient(rawURL string) (*Client, error) { } client.mediaURL = FindTagValue(b, "Media.+?XAddr") + if client.mediaURL == "" { + client.mediaURL = baseURL + "/onvif/media_service" + } + client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") + if client.imaginURL == "" { + client.imaginURL = baseURL + "/onvif/imaging_service" + } return client, nil } @@ -175,26 +183,62 @@ func (c *Client) MediaRequest(operation string) ([]byte, error) { return c.Request(c.mediaURL, operation) } -func (c *Client) Request(url, body string) ([]byte, error) { - if url == "" { +func (c *Client) Request(rawUrl, body string) ([]byte, error) { + if rawUrl == "" { return nil, errors.New("onvif: unsupported service") } e := NewEnvelopeWithUser(c.url.User) e.Append(body) - client := &http.Client{Timeout: time.Second * 5000} - res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes())) + u, err := url.Parse(rawUrl) if err != nil { return nil, err } - // need to close body with eny response status - b, err := io.ReadAll(res.Body) - - if err == nil && res.StatusCode != http.StatusOK { - err = errors.New("onvif: " + res.Status + " for " + url) + host := u.Host + if u.Port() == "" { + host += ":80" } - return b, err + conn, err := net.DialTimeout("tcp", host, 5*time.Second) + 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(" 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 } diff --git a/pkg/pcm/backchannel.go b/pkg/pcm/backchannel.go new file mode 100644 index 00000000..99b6e3aa --- /dev/null +++ b/pkg/pcm/backchannel.go @@ -0,0 +1,69 @@ +package pcm + +import ( + "errors" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + cmd *shell.Command +} + +func NewBackchannel(cmd *shell.Command, audio string) (core.Producer, error) { + var codec *core.Codec + + if audio == "" { + // default codec + codec = &core.Codec{Name: core.CodecPCML, ClockRate: 16000} + } else if codec = core.ParseCodecString(audio); codec == nil { + return nil, errors.New("pcm: unsupported audio format: " + audio) + } + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{codec}, + }, + } + + return &Backchannel{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "pcm", + Protocol: "pipe", + Medias: medias, + Transport: cmd, + }, + cmd: cmd, + }, nil +} + +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Backchannel) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + wr, err := c.cmd.StdinPipe() + if err != nil { + return err + } + + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := wr.Write(packet.Payload); err != nil { + c.Send += n + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Backchannel) Start() error { + return c.cmd.Run() +} diff --git a/pkg/pcm/handlers.go b/pkg/pcm/handlers.go new file mode 100644 index 00000000..18a96468 --- /dev/null +++ b/pkg/pcm/handlers.go @@ -0,0 +1,109 @@ +package pcm + +import ( + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +// RepackG711 - Repack G.711 PCMA/PCMU into frames of size 1024 +// 1. Fixes WebRTC audio quality issue (monotonic timestamp) +// 2. Fixes Reolink Doorbell backchannel issue (zero timestamp) +// https://github.com/AlexxIT/go2rtc/issues/331 +func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { + const PacketSize = 1024 + + var buf []byte + var seq uint16 + var ts uint32 + + // fix https://github.com/AlexxIT/go2rtc/issues/432 + var mu sync.Mutex + + return func(packet *rtp.Packet) { + mu.Lock() + + buf = append(buf, packet.Payload...) + if len(buf) < PacketSize { + mu.Unlock() + return + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, // should be true + PayloadType: packet.PayloadType, // will be owerwriten + SequenceNumber: seq, + SSRC: packet.SSRC, + }, + Payload: buf[:PacketSize], + } + + seq++ + + // don't know if zero TS important for Reolink Doorbell + // don't have this strange devices for tests + if !zeroTS { + pkt.Timestamp = ts + ts += PacketSize + } + + buf = buf[PacketSize:] + + mu.Unlock() + + handler(pkt) + } +} + +// LittleToBig - convert PCM little endian to PCM big endian +func LittleToBig(handler core.HandlerFunc) core.HandlerFunc { + return func(packet *rtp.Packet) { + clone := *packet + clone.Payload = FlipEndian(packet.Payload) + handler(&clone) + } +} + +func TranscodeHandler(dst, src *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + var ts uint32 + k := float32(BytesPerFrame(dst)) / float32(BytesPerFrame(src)) + f := Transcode(dst, src) + + return func(packet *rtp.Packet) { + ts += uint32(k * float32(len(packet.Payload))) + + clone := *packet + clone.Payload = f(packet.Payload) + clone.Timestamp = ts + handler(&clone) + } +} + +func BytesPerSample(codec *core.Codec) int { + switch codec.Name { + case core.CodecPCML, core.CodecPCM: + return 2 + case core.CodecPCMU, core.CodecPCMA: + return 1 + } + return 0 +} + +func BytesPerFrame(codec *core.Codec) int { + if codec.Channels <= 1 { + return BytesPerSample(codec) + } + return int(codec.Channels) * BytesPerSample(codec) +} + +func FramesPerDuration(codec *core.Codec, duration time.Duration) int { + return int(time.Duration(codec.ClockRate) * duration / time.Second) +} + +func BytesPerDuration(codec *core.Codec, duration time.Duration) int { + return BytesPerFrame(codec) * FramesPerDuration(codec, duration) +} diff --git a/pkg/pcm/pcm.go b/pkg/pcm/pcm.go index 60062b62..5395621e 100644 --- a/pkg/pcm/pcm.go +++ b/pkg/pcm/pcm.go @@ -1,200 +1,220 @@ package pcm import ( - "sync" + "math" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" ) -// ResampleToG711 - convert PCMA/PCM/PCML to PCMA and PCMU to PCMU with decreasing sample rate -func ResampleToG711(codec *core.Codec, sampleRate uint32, handler core.HandlerFunc) core.HandlerFunc { - n := float32(codec.ClockRate) / float32(sampleRate) - - if codec.Channels == 2 { - n *= 2 // hacky way for support two channels audio +func ceil(x float32) int { + d, fract := math.Modf(float64(x)) + if fract == 0.0 { + return int(d) } - - switch codec.Name { - case core.CodecPCMA: - return DownsampleByte(PCMAtoPCM, PCMtoPCMA, n, handler) - case core.CodecPCMU: - return DownsampleByte(PCMUtoPCM, PCMtoPCMU, n, handler) - case core.CodecPCM, core.CodecPCML: - if n == 1 { - handler = ResamplePCM(PCMtoPCMA, handler) - } else { - handler = DownsamplePCM(PCMtoPCMA, n, handler) - } - - if codec.Name == core.CodecPCML { - return LittleToBig(handler) - } - - return handler - } - - panic(core.Caller()) + return int(d) + 1 } -// DownsampleByte - convert PCMA/PCMU to PCMA/PCMU with decreasing sample rate (N times) -func DownsampleByte( - toPCM func(byte) int16, fromPCM func(int16) byte, n float32, handler core.HandlerFunc, -) core.HandlerFunc { +func Downsample(k float32) func([]int16) []int16 { var sampleN, sampleSum float32 - var ts uint32 - - return func(packet *rtp.Packet) { - samples := len(packet.Payload) - newLen := uint32((float32(samples) + sampleN) / n) - - oldSamples := packet.Payload - newSamples := make([]byte, newLen) + return func(src []int16) (dst []int16) { var i int - for _, sample := range oldSamples { - sampleSum += float32(toPCM(sample)) - if sampleN++; sampleN >= n { - newSamples[i] = fromPCM(int16(sampleSum / n)) + dst = make([]int16, ceil((float32(len(src))+sampleN)/k)) + for _, sample := range src { + sampleSum += float32(sample) + sampleN++ + if sampleN >= k { + dst[i] = int16(sampleSum / k) i++ sampleSum = 0 - sampleN -= n + sampleN -= k } } - - ts += newLen - - clone := *packet - clone.Payload = newSamples - clone.Timestamp = ts - handler(&clone) + return } } -// LittleToBig - conver PCM little endian to PCM big endian -func LittleToBig(handler core.HandlerFunc) core.HandlerFunc { - return func(packet *rtp.Packet) { - size := len(packet.Payload) - b := make([]byte, size) - for i := 0; i < size; i += 2 { - b[i] = packet.Payload[i+1] - b[i+1] = packet.Payload[i] - } +func Upsample(k float32) func([]int16) []int16 { + var sampleN float32 - clone := *packet - clone.Payload = b - handler(&clone) - } -} + return func(src []int16) (dst []int16) { + var i int + dst = make([]int16, ceil(k*float32(len(src)))) + for _, sample := range src { + sampleN += k + for sampleN > 0 { + dst[i] = sample + i++ -// ResamplePCM - convert PCM to PCMA/PCMU with same sample rate -func ResamplePCM(fromPCM func(int16) byte, handler core.HandlerFunc) core.HandlerFunc { - var ts uint32 - - return func(packet *rtp.Packet) { - len1 := len(packet.Payload) - len2 := len1 / 2 - - oldSamples := packet.Payload - newSamples := make([]byte, len2) - - var i2 int - for i1 := 0; i1 < len1; i1 += 2 { - sample := int16(uint16(oldSamples[i1])<<8 | uint16(oldSamples[i1+1])) - newSamples[i2] = fromPCM(sample) - i2++ - } - - ts += uint32(len2) - - clone := *packet - clone.Payload = newSamples - clone.Timestamp = ts - handler(&clone) - } -} - -// DownsamplePCM - convert PCM to PCMA/PCMU with decreasing sample rate (N times) -func DownsamplePCM(fromPCM func(int16) byte, n float32, handler core.HandlerFunc) core.HandlerFunc { - var sampleN, sampleSum float32 - var ts uint32 - - return func(packet *rtp.Packet) { - samples := len(packet.Payload) / 2 - newLen := uint32((float32(samples) + sampleN) / n) - - oldSamples := packet.Payload - newSamples := make([]byte, newLen) - - var i2 int - for i1 := 0; i1 < len(packet.Payload); i1 += 2 { - sampleSum += float32(int16(uint16(oldSamples[i1])<<8 | uint16(oldSamples[i1+1]))) - if sampleN++; sampleN >= n { - newSamples[i2] = fromPCM(int16(sampleSum / n)) - i2++ - - sampleSum = 0 - sampleN -= n + sampleN -= 1 } } - - ts += newLen - - clone := *packet - clone.Payload = newSamples - clone.Timestamp = ts - handler(&clone) + return } } -// RepackG711 - Repack G.711 PCMA/PCMU into frames of size 1024 -// 1. Fixes WebRTC audio quality issue (monotonic timestamp) -// 2. Fixes Reolink Doorbell backchannel issue (zero timestamp) -// https://github.com/AlexxIT/go2rtc/issues/331 -func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { - const PacketSize = 1024 +func FlipEndian(src []byte) (dst []byte) { + var i, j int + n := len(src) + dst = make([]byte, n) + for i < n { + x := src[i] + i++ + dst[j] = src[i] + j++ + i++ + dst[j] = x + j++ + } + return +} - var buf []byte - var seq uint16 - var ts uint32 +func Transcode(dst, src *core.Codec) func([]byte) []byte { + var reader func([]byte) []int16 + var writer func([]int16) []byte + var filters []func([]int16) []int16 - // fix https://github.com/AlexxIT/go2rtc/issues/432 - var mu sync.Mutex - - return func(packet *rtp.Packet) { - mu.Lock() - - buf = append(buf, packet.Payload...) - if len(buf) < PacketSize { - mu.Unlock() + switch src.Name { + case core.CodecPCML: + reader = func(src []byte) (dst []int16) { + var i, j int + n := len(src) + dst = make([]int16, n/2) + for i < n { + lo := src[i] + i++ + hi := src[i] + i++ + dst[j] = int16(hi)<<8 | int16(lo) + j++ + } return } - - pkt := &rtp.Packet{ - Header: rtp.Header{ - Version: 2, - Marker: true, // should be true - PayloadType: packet.PayloadType, // will be owerwriten - SequenceNumber: seq, - SSRC: packet.SSRC, - }, - Payload: buf[:PacketSize], + case core.CodecPCM: + reader = func(src []byte) (dst []int16) { + var i, j int + n := len(src) + dst = make([]int16, n/2) + for i < n { + hi := src[i] + i++ + lo := src[i] + i++ + dst[j] = int16(hi)<<8 | int16(lo) + j++ + } + return } - - seq++ - - // don't know if zero TS important for Reolink Doorbell - // don't have this strange devices for tests - if !zeroTS { - pkt.Timestamp = ts - ts += PacketSize + case core.CodecPCMU: + reader = func(src []byte) (dst []int16) { + var i int + dst = make([]int16, len(src)) + for _, sample := range src { + dst[i] = PCMUtoPCM(sample) + i++ + } + return } + case core.CodecPCMA: + reader = func(src []byte) (dst []int16) { + var i int + dst = make([]int16, len(src)) + for _, sample := range src { + dst[i] = PCMAtoPCM(sample) + i++ + } + return + } + } - buf = buf[PacketSize:] + if src.Channels > 1 { + filters = append(filters, Downsample(float32(src.Channels))) + } - mu.Unlock() + if src.ClockRate > dst.ClockRate { + filters = append(filters, Downsample(float32(src.ClockRate)/float32(dst.ClockRate))) + } else if src.ClockRate < dst.ClockRate { + filters = append(filters, Upsample(float32(dst.ClockRate)/float32(src.ClockRate))) + } - handler(pkt) + if dst.Channels > 1 { + filters = append(filters, Upsample(float32(dst.Channels))) + } + + switch dst.Name { + case core.CodecPCML: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)*2) + for _, sample := range src { + dst[i] = byte(sample) + i++ + dst[i] = byte(sample >> 8) + i++ + } + return + } + case core.CodecPCM: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)*2) + for _, sample := range src { + dst[i] = byte(sample >> 8) + i++ + dst[i] = byte(sample) + i++ + } + return + } + case core.CodecPCMU: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)) + for _, sample := range src { + dst[i] = PCMtoPCMU(sample) + i++ + } + return + } + case core.CodecPCMA: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)) + for _, sample := range src { + dst[i] = PCMtoPCMA(sample) + i++ + } + return + } + } + + return func(b []byte) []byte { + samples := reader(b) + for _, filter := range filters { + samples = filter(samples) + } + return writer(samples) + } +} + +func ConsumerCodecs() []*core.Codec { + return []*core.Codec{ + {Name: core.CodecPCML}, + {Name: core.CodecPCM}, + {Name: core.CodecPCMA}, + {Name: core.CodecPCMU}, + } +} + +func ProducerCodecs() []*core.Codec { + return []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 16000}, + {Name: core.CodecPCM, ClockRate: 16000}, + {Name: core.CodecPCML, ClockRate: 8000}, + {Name: core.CodecPCM, ClockRate: 8000}, + {Name: core.CodecPCMA, ClockRate: 8000}, + {Name: core.CodecPCMU, ClockRate: 8000}, + {Name: core.CodecPCML, ClockRate: 22050}, // wyoming-snd-external } } diff --git a/pkg/pcm/pcm_test.go b/pkg/pcm/pcm_test.go new file mode 100644 index 00000000..2832be63 --- /dev/null +++ b/pkg/pcm/pcm_test.go @@ -0,0 +1,79 @@ +package pcm + +import ( + "encoding/hex" + "fmt" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestTranscode(t *testing.T) { + tests := []struct { + name string + src core.Codec + dst core.Codec + source string + expect string + }{ + { + name: "s16be->s16be", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + }, + { + name: "s16be->s16le", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCML, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "CAFC1300430328061308510B9E0D760FDA101111EA13BD15F2168216D4156115", + }, + { + name: "s16be->mulaw", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCMU, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "52FDD1C5BEB8B3B0AEAEABA9A8A8A9AA", + }, + { + name: "s16be->alaw", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "7CD4FFED95939E9B8584868083838080", + }, + { + name: "2ch->1ch", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 2}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + source: "FCCAFCCA001300130343034306280628081308130B510B510D9E0D9E0F760F76", + expect: "FCCA00130343062808130B510D9E0F76", + }, + { + name: "1ch->2ch", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 2}, + source: "FCCA00130343062808130B510D9E0F76", + expect: "FCCAFCCA001300130343034306280628081308130B510B510D9E0D9E0F760F76", + }, + { + name: "16khz->8khz", + src: core.Codec{Name: core.CodecPCM, ClockRate: 16000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + source: "FCCAFCCA001300130343034306280628081308130B510B510D9E0D9E0F760F76", + expect: "FCCA00130343062808130B510D9E0F76", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := Transcode(&test.dst, &test.src) + b, _ := hex.DecodeString(test.source) + b = f(b) + s := fmt.Sprintf("%X", b) + require.Equal(t, test.expect, s) + }) + } +} diff --git a/pkg/pcm/producer_sync.go b/pkg/pcm/producer_sync.go new file mode 100644 index 00000000..fedef268 --- /dev/null +++ b/pkg/pcm/producer_sync.go @@ -0,0 +1,96 @@ +package pcm + +import ( + "io" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type ProducerSync struct { + core.Connection + src *core.Codec + rd io.Reader + onClose func() +} + +func OpenSync(codec *core.Codec, rd io.Reader) *ProducerSync { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: ProducerCodecs(), + }, + } + + return &ProducerSync{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "pcm", + Medias: medias, + Transport: rd, + }, + src: codec, + rd: rd, + } +} + +func (p *ProducerSync) OnClose(f func()) { + p.onClose = f +} + +func (p *ProducerSync) Start() error { + if len(p.Receivers) == 0 { + return nil + } + + var pktSeq uint16 + var pktTS uint32 // time in frames + var pktTime time.Duration // time in seconds + + t0 := time.Now() + + dst := p.Receivers[0].Codec + transcode := Transcode(dst, p.src) + + const chunkDuration = 20 * time.Millisecond + chunkBytes := BytesPerDuration(p.src, chunkDuration) + chunkFrames := uint32(FramesPerDuration(dst, chunkDuration)) + + for { + buf := make([]byte, chunkBytes) + n, _ := io.ReadFull(p.rd, buf) + + if n == 0 { + break + } + + pkt := &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: pktSeq, + Timestamp: pktTS, + }, + Payload: transcode(buf[:n]), + } + + if d := pktTime - time.Since(t0); d > 0 { + time.Sleep(d) + } + + p.Receivers[0].WriteRTP(pkt) + p.Recv += n + + pktSeq++ + pktTS += chunkFrames + pktTime += chunkDuration + } + + if p.onClose != nil { + p.onClose() + } + + return nil +} diff --git a/pkg/pcm/s16le/s16le.go b/pkg/pcm/s16le/s16le.go new file mode 100644 index 00000000..acd2d4fc --- /dev/null +++ b/pkg/pcm/s16le/s16le.go @@ -0,0 +1,42 @@ +package s16le + +func PeaksRMS(b []byte) int16 { + // RMS of sine wave = peak / sqrt2 + // https://en.wikipedia.org/wiki/Root_mean_square + // https://www.youtube.com/watch?v=MUDkL4KZi0I + var peaks int32 + var peaksSum int32 + var prevSample int16 + var prevUp bool + + var i int + for n := len(b); i < n; { + lo := b[i] + i++ + hi := b[i] + i++ + + sample := int16(hi)<<8 | int16(lo) + up := sample >= prevSample + + if i >= 4 { + if up != prevUp { + if prevSample >= 0 { + peaksSum += int32(prevSample) + } else { + peaksSum -= int32(prevSample) + } + peaks++ + } + } + + prevSample = sample + prevUp = up + } + + if peaks == 0 { + return 0 + } + + return int16(peaksSum / peaks) +} diff --git a/pkg/probe/producer.go b/pkg/probe/consumer.go similarity index 63% rename from pkg/probe/producer.go rename to pkg/probe/consumer.go index 1fbd3efb..a1ca7ca5 100644 --- a/pkg/probe/producer.go +++ b/pkg/probe/consumer.go @@ -11,7 +11,7 @@ type Probe struct { core.Connection } -func NewProbe(query url.Values) *Probe { +func Create(name string, query url.Values) *Probe { medias := core.ParseQuery(query) for _, value := range query["microphone"] { @@ -32,39 +32,22 @@ func NewProbe(query url.Values) *Probe { return &Probe{ Connection: core.Connection{ ID: core.NewID(), - FormatName: "probe", + FormatName: name, Medias: medias, }, } } -func (p *Probe) GetMedias() []*core.Media { - return p.Medias -} - func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) - sender.Bind(track) + sender.Handler = func(pkt *core.Packet) { + p.Send += len(pkt.Payload) + } + sender.HandleRTP(track) p.Senders = append(p.Senders, sender) return nil } -func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver := core.NewReceiver(media, codec) - p.Receivers = append(p.Receivers, receiver) - return receiver, nil -} - func (p *Probe) Start() error { return nil } - -func (p *Probe) Stop() error { - for _, receiver := range p.Receivers { - receiver.Close() - } - for _, sender := range p.Senders { - sender.Close() - } - return nil -} diff --git a/pkg/ring/api.go b/pkg/ring/api.go index ed69465f..ea7c95ad 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -11,9 +11,13 @@ import ( "net/http" "reflect" "strings" + "sync" "time" ) +var clientCache = map[string]*RingApi{} +var cacheMutex sync.Mutex + type RefreshTokenAuth struct { RefreshToken string } @@ -23,13 +27,11 @@ type EmailAuth struct { Password string } -// AuthConfig represents the decoded refresh token data type AuthConfig struct { RT string `json:"rt"` // Refresh Token HID string `json:"hid"` // Hardware ID } -// AuthTokenResponse represents the response from the authentication endpoint type AuthTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` @@ -46,41 +48,50 @@ type Auth2faResponse struct { NextTimeInSecs int `json:"next_time_in_secs"` } -// SocketTicketRequest represents the request to get a socket ticket type SocketTicketResponse struct { Ticket string `json:"ticket"` ResponseTimestamp int64 `json:"response_timestamp"` } -// RingRestClient handles authentication and requests to Ring API -type RingRestClient struct { +type SessionResponse struct { + Profile struct { + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` +} + +type RingApi struct { httpClient *http.Client authConfig *AuthConfig hardwareID string authToken *AuthTokenResponse + tokenExpiry time.Time Using2FA bool PromptFor2FA string RefreshToken string auth interface{} // EmailAuth or RefreshTokenAuth onTokenRefresh func(string) + authMutex sync.Mutex + session *SessionResponse + sessionExpiry time.Time + sessionMutex sync.Mutex + cacheKey string } -// CameraKind represents the different types of Ring cameras type CameraKind string -// CameraData contains common fields for all camera types type CameraData struct { - ID float64 `json:"id"` - Description string `json:"description"` - DeviceID string `json:"device_id"` - Kind string `json:"kind"` - LocationID string `json:"location_id"` + ID int `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` } -// RingDeviceType represents different types of Ring devices type RingDeviceType string -// RingDevicesResponse represents the response from the Ring API type RingDevicesResponse struct { Doorbots []CameraData `json:"doorbots"` AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` @@ -139,23 +150,49 @@ const ( apiVersion = 11 defaultTimeout = 20 * time.Second maxRetries = 3 + sessionValidTime = 12 * time.Hour ) -// NewRingRestClient creates a new Ring client instance -func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{Timeout: defaultTimeout}, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - auth: auth, - } +func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) { + var cacheKey string + // Create cache key based on auth data switch a := auth.(type) { case RefreshTokenAuth: if a.RefreshToken == "" { return nil, fmt.Errorf("refresh token is required") } + cacheKey = "refresh:" + a.RefreshToken + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + cacheKey = "email:" + a.Email + ":" + a.Password + default: + return nil, fmt.Errorf("invalid auth type") + } + cacheMutex.Lock() + defer cacheMutex.Unlock() + + if cachedClient, ok := clientCache[cacheKey]; ok { + // Check if token is not nil and not expired + if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) { + cachedClient.onTokenRefresh = onTokenRefresh + return cachedClient, nil + } + } + + client := &RingApi{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + cacheKey: cacheKey, + } + + switch a := auth.(type) { + case RefreshTokenAuth: config, err := parseAuthConfig(a.RefreshToken) if err != nil { return nil, fmt.Errorf("failed to parse refresh token: %w", err) @@ -164,160 +201,30 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest client.authConfig = config client.hardwareID = config.HID client.RefreshToken = a.RefreshToken - case EmailAuth: - if a.Email == "" || a.Password == "" { - return nil, fmt.Errorf("email and password are required") - } - default: - return nil, fmt.Errorf("invalid auth type") } + clientCache[cacheKey] = client + return client, nil } -// Request makes an authenticated request to the Ring API -func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { - // Ensure we have a valid auth token - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("authentication failed: %w", err) - } - - var bodyReader io.Reader - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - bodyReader = bytes.NewReader(jsonBody) - } - - // Create request - req, err := http.NewRequest(method, url, bodyReader) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - - // Make request with retries - var resp *http.Response - var responseBody []byte - - for attempt := 0; attempt <= maxRetries; attempt++ { - resp, err = c.httpClient.Do(req) - if err != nil { - if attempt == maxRetries { - return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) - } - time.Sleep(5 * time.Second) - continue - } - defer resp.Body.Close() - - responseBody, err = io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - // Handle 401 by refreshing auth and retrying - if resp.StatusCode == http.StatusUnauthorized { - c.authToken = nil // Force token refresh - if attempt == maxRetries { - return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) - } - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("failed to refresh authentication: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - continue - } - - // Handle other error status codes - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) - } - - break - } - - return responseBody, nil +func ClientAPI(path string) string { + return clientAPIBaseURL + path } -// ensureAuth ensures we have a valid auth token -func (c *RingRestClient) ensureAuth() error { - if c.authToken != nil { - return nil - } - - var grantData = map[string]string{ - "grant_type": "refresh_token", - "refresh_token": c.authConfig.RT, - } - - // Add common fields - grantData["client_id"] = "ring_official_android" - grantData["scope"] = "client" - - // Make auth request - body, err := json.Marshal(grantData) - if err != nil { - return fmt.Errorf("failed to marshal auth request: %w", err) - } - - req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create auth request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - req.Header.Set("2fa-support", "true") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("auth request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusPreconditionFailed { - return fmt.Errorf("2FA required. Please see documentation for handling 2FA") - } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) - } - - var authResp AuthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { - return fmt.Errorf("failed to decode auth response: %w", err) - } - - // Update auth config and refresh token - c.authToken = &authResp - c.authConfig = &AuthConfig{ - RT: authResp.RefreshToken, - HID: c.hardwareID, - } - - // Encode and notify about new refresh token - if c.onTokenRefresh != nil { - newRefreshToken := encodeAuthConfig(c.authConfig) - c.onTokenRefresh(newRefreshToken) - } - - return nil +func DeviceAPI(path string) string { + return deviceAPIBaseURL + path } -// getAuth makes an authentication request to the Ring API -func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { +func CommandsAPI(path string) string { + return commandsAPIBaseURL + path +} + +func AppAPI(path string) string { + return appAPIBaseURL + path +} + +func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { var grantData map[string]string if c.authConfig != nil && twoFactorAuthCode == "" { @@ -404,60 +311,30 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, return nil, fmt.Errorf("failed to decode auth response: %w", err) } + // Refresh token and expiry c.authToken = &authResp c.authConfig = &AuthConfig{ RT: authResp.RefreshToken, HID: c.hardwareID, } + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) c.RefreshToken = encodeAuthConfig(c.authConfig) if c.onTokenRefresh != nil { c.onTokenRefresh(c.RefreshToken) } + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + return c.authToken, nil } -// Helper functions for auth config encoding/decoding -func parseAuthConfig(refreshToken string) (*AuthConfig, error) { - decoded, err := base64.StdEncoding.DecodeString(refreshToken) - if err != nil { - return nil, err - } - - var config AuthConfig - if err := json.Unmarshal(decoded, &config); err != nil { - // Handle legacy format where refresh token is the raw token - return &AuthConfig{RT: refreshToken}, nil - } - - return &config, nil -} - -func encodeAuthConfig(config *AuthConfig) string { - jsonBytes, _ := json.Marshal(config) - return base64.StdEncoding.EncodeToString(jsonBytes) -} - -// API URL helpers -func ClientAPI(path string) string { - return clientAPIBaseURL + path -} - -func DeviceAPI(path string) string { - return deviceAPIBaseURL + path -} - -func CommandsAPI(path string) string { - return commandsAPIBaseURL + path -} - -func AppAPI(path string) string { - return appAPIBaseURL + path -} - -// FetchRingDevices gets all Ring devices and categorizes them -func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { +func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) { response, err := c.Request("GET", ClientAPI("ring_devices"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch ring devices: %w", err) @@ -509,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { return &devices, nil } -func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { +func (c *RingApi) GetSocketTicket() (*SocketTicketResponse, error) { response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) @@ -523,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { return &ticket, nil } +func (c *RingApi) Request(method, url string, body interface{}) ([]byte, error) { + // Ensure we have a valid session + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("session validation failed: %w", err) + } + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + // Create request + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + // Make request with retries + var resp *http.Response + var responseBody []byte + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = c.httpClient.Do(req) + if err != nil { + if attempt == maxRetries { + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) + } + time.Sleep(5 * time.Second) + continue + } + defer resp.Body.Close() + + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Handle 401 by refreshing auth and retrying + if resp.StatusCode == http.StatusUnauthorized { + // Reset token to force refresh + c.authMutex.Lock() + c.authToken = nil + c.tokenExpiry = time.Time{} // Reset token expiry + c.authMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) + } + + // By 401 with Auth AND Session start over + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + continue + } + + // Handle 404 error with hardware_id reference - session issue + if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) { + var errorBody map[string]interface{} + if err := json.Unmarshal(responseBody, &errorBody); err == nil { + if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) { + // Session with hardware_id not found, refresh session + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) + } + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + continue + } + } + } + + // Handle other error status codes + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + break + } + + return responseBody, nil +} + +func (c *RingApi) ensureSession() error { + c.sessionMutex.Lock() + defer c.sessionMutex.Unlock() + + // If session is still valid, use it + if c.session != nil && time.Now().Before(c.sessionExpiry) { + return nil + } + + // Make sure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return fmt.Errorf("authentication failed while creating session: %w", err) + } + + sessionPayload := map[string]interface{}{ + "device": map[string]interface{}{ + "hardware_id": c.hardwareID, + "metadata": map[string]interface{}{ + "api_version": apiVersion, + "device_model": "ring-client-go", + }, + "os": "android", + }, + } + + body, err := json.Marshal(sessionPayload) + if err != nil { + return fmt.Errorf("failed to marshal session request: %w", err) + } + + req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var sessionResp SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { + return fmt.Errorf("failed to decode session response: %w", err) + } + + c.session = &sessionResp + c.sessionExpiry = time.Now().Add(sessionValidTime) + + // Aktualisiere den gecachten Client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func (c *RingApi) ensureAuth() error { + c.authMutex.Lock() + defer c.authMutex.Unlock() + + // If token exists and is not expired, use it + if c.authToken != nil && time.Now().Before(c.tokenExpiry) { + return nil + } + + var grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + + // Add common fields + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + // Make auth request + body, err := json.Marshal(grantData) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return fmt.Errorf("2FA required. Please see documentation for handling 2FA") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return fmt.Errorf("failed to decode auth response: %w", err) + } + + // Update auth config and refresh token + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + // Refreshn the token in the client + c.RefreshToken = encodeAuthConfig(c.authConfig) + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func parseAuthConfig(refreshToken string) (*AuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(refreshToken) + if err != nil { + return nil, err + } + + var config AuthConfig + if err := json.Unmarshal(decoded, &config); err != nil { + // Handle legacy format where refresh token is the raw token + return &AuthConfig{RT: refreshToken}, nil + } + + return &config, nil +} + +func encodeAuthConfig(config *AuthConfig) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + func generateHardwareID() string { h := sha256.New() h.Write([]byte("ring-client-go2rtc")) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 4c473276..fb77e198 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -5,103 +5,25 @@ import ( "errors" "fmt" "net/url" - "sync" - "time" + "strconv" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/google/uuid" - "github.com/gorilla/websocket" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) type Client struct { - api *RingRestClient - ws *websocket.Conn + api *RingApi + wsClient *WSClient prod core.Producer - camera *CameraData + cameraID int dialogID string - sessionID string - wsMutex sync.Mutex - done chan struct{} + connected core.Waiter + closed bool } -type SessionBody struct { - DoorbotID int `json:"doorbot_id"` - SessionID string `json:"session_id"` -} - -type AnswerMessage struct { - Method string `json:"method"` // "sdp" - Body struct { - SessionBody - SDP string `json:"sdp"` - Type string `json:"type"` // "answer" - } `json:"body"` -} - -type IceCandidateMessage struct { - Method string `json:"method"` // "ice" - Body struct { - SessionBody - Ice string `json:"ice"` - MLineIndex int `json:"mlineindex"` - } `json:"body"` -} - -type SessionMessage struct { - Method string `json:"method"` // "session_created" or "session_started" - Body SessionBody `json:"body"` -} - -type PongMessage struct { - Method string `json:"method"` // "pong" - Body SessionBody `json:"body"` -} - -type NotificationMessage struct { - Method string `json:"method"` // "notification" - Body struct { - SessionBody - IsOK bool `json:"is_ok"` - Text string `json:"text"` - } `json:"body"` -} - -type StreamInfoMessage struct { - Method string `json:"method"` // "stream_info" - Body struct { - SessionBody - Transcoding bool `json:"transcoding"` - TranscodingReason string `json:"transcoding_reason"` - } `json:"body"` -} - -type CloseMessage struct { - Method string `json:"method"` // "close" - Body struct { - SessionBody - Reason struct { - Code int `json:"code"` - Text string `json:"text"` - } `json:"reason"` - } `json:"body"` -} - -type BaseMessage struct { - Method string `json:"method"` - Body map[string]any `json:"body"` -} - -// Close reason codes -const ( - CloseReasonNormalClose = 0 - CloseReasonAuthenticationFailed = 5 - CloseReasonTimeout = 6 -) - func Dial(rawURL string) (*Client, error) { - // 1. Parse URL and validate basic params u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -109,70 +31,42 @@ func Dial(rawURL string) (*Client, error) { query := u.Query() encodedToken := query.Get("refresh_token") + cameraID := query.Get("camera_id") deviceID := query.Get("device_id") _, isSnapshot := query["snapshot"] - if encodedToken == "" || deviceID == "" { + if encodedToken == "" || deviceID == "" || cameraID == "" { return nil, errors.New("ring: wrong query") } - // URL-decode the refresh token + client := &Client{ + dialogID: uuid.NewString(), + } + + client.cameraID, err = strconv.Atoi(cameraID) + if err != nil { + return nil, fmt.Errorf("ring: invalid camera_id: %w", err) + } + refreshToken, err := url.QueryUnescape(encodedToken) if err != nil { return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) } - // Initialize Ring API client - ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) + client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) if err != nil { return nil, err } - // Get camera details - devices, err := ringAPI.FetchRingDevices() - if err != nil { - return nil, err - } - - var camera *CameraData - for _, cam := range devices.AllCameras { - if fmt.Sprint(cam.DeviceID) == deviceID { - camera = &cam - break - } - } - if camera == nil { - return nil, errors.New("ring: camera not found") - } - - // Create base client - client := &Client{ - api: ringAPI, - camera: camera, - dialogID: uuid.NewString(), - done: make(chan struct{}), - } - - // Check if snapshot request + // Snapshot Flow if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, camera) + client.prod = NewSnapshotProducer(client.api, client.cameraID) return client, nil } - // If not snapshot, continue with WebRTC setup - ticket, err := ringAPI.GetSocketTicket() - if err != nil { - return nil, err - } - - // Create WebSocket connection - wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", - uuid.NewString(), url.QueryEscape(ticket.Ticket)) - - client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{ - "User-Agent": {"android:com.ringapp"}, - }) + client.wsClient, err = StartWebsocket(client.cameraID, client.api) if err != nil { + client.Stop() return nil, err } @@ -196,13 +90,13 @@ func Dial(rawURL string) (*Client, error) { api, err := webrtc.NewAPI() if err != nil { - client.ws.Close() + client.Stop() return nil, err } pc, err := api.NewPeerConnection(conf) if err != nil { - client.ws.Close() + client.Stop() return nil, err } @@ -212,16 +106,27 @@ func Dial(rawURL string) (*Client, error) { // protect from blocking on errors defer sendOffer.Done(nil) - // waiter will wait PC error or WS error or nil (connection OK) - var connState core.Waiter - prod := webrtc.NewConn(pc) prod.FormatName = "ring/webrtc" prod.Mode = core.ModeActiveProducer prod.Protocol = "ws" prod.URL = rawURL - client.prod = prod + client.wsClient.onMessage = func(msg WSMessage) { + client.onWSMessage(msg) + } + + client.wsClient.onError = func(err error) { + // fmt.Printf("ring: error: %s\n", err.Error()) + client.Stop() + client.connected.Done(err) + } + + client.wsClient.onClose = func() { + // fmt.Println("ring: disconnect") + client.Stop() + client.connected.Done(errors.New("ring: disconnect")) + } prod.Listen(func(msg any) { switch msg := msg.(type) { @@ -240,22 +145,28 @@ func Dial(rawURL string) (*Client, error) { "mlineindex": iceCandidate.SDPMLineIndex, } - if err = client.sendSessionMessage("ice", icePayload); err != nil { - connState.Done(err) + if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil { + client.connected.Done(err) return } case pion.PeerConnectionState: switch msg { + case pion.PeerConnectionStateNew: + break case pion.PeerConnectionStateConnecting: + break case pion.PeerConnectionStateConnected: - connState.Done(nil) + client.connected.Done(nil) default: - connState.Done(errors.New("ring: " + msg.String())) + client.Stop() + client.connected.Done(errors.New("ring: " + msg.String())) } } }) + client.prod = prod + // Setup media configuration medias := []*core.Media{ { @@ -297,186 +208,103 @@ func Dial(rawURL string) (*Client, error) { "sdp": offer, } - if err = client.sendSessionMessage("live_view", offerPayload); err != nil { + if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil { client.Stop() return nil, err } sendOffer.Done(nil) - // Ring expects a ping message every 5 seconds - go client.startPingLoop(pc) - go client.startMessageLoop(&connState) - - if err = connState.Wait(); err != nil { + if err = client.connected.Wait(); err != nil { return nil, err } return client, nil } -func (c *Client) startPingLoop(pc *pion.PeerConnection) { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() +func (c *Client) onWSMessage(msg WSMessage) { + rawMsg, _ := json.Marshal(msg) - for { - select { - case <-c.done: - return - case <-ticker.C: - if pc.ConnectionState() == pion.PeerConnectionStateConnected { - if err := c.sendSessionMessage("ping", nil); err != nil { - return - } - } + // fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg)) + + // check if "doorbot_id" is present + if _, ok := msg.Body["doorbot_id"]; !ok { + return + } + + // check if the message is from the correct doorbot + doorbotID := msg.Body["doorbot_id"].(float64) + if int(doorbotID) != c.cameraID { + return + } + + if msg.Method == "session_created" || msg.Method == "session_started" { + if _, ok := msg.Body["session_id"]; ok && c.wsClient.sessionID == "" { + c.wsClient.sessionID = msg.Body["session_id"].(string) } } -} -func (c *Client) startMessageLoop(connState *core.Waiter) { - var err error - - // will be closed when conn will be closed - defer func() { - connState.Done(err) - }() - - for { - select { - case <-c.done: + // check if the message is from the correct session + if _, ok := msg.Body["session_id"]; ok { + if msg.Body["session_id"].(string) != c.wsClient.sessionID { return - default: - var res BaseMessage - if err = c.ws.ReadJSON(&res); err != nil { - select { - case <-c.done: - return - default: - } + } + } + switch msg.Method { + case "sdp": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Get answer + var msg AnswerMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { c.Stop() + c.connected.Done(err) return } - // check if "doorbot_id" is present - if _, ok := res.Body["doorbot_id"]; !ok { - continue - } - - // check if the message is from the correct doorbot - doorbotID := res.Body["doorbot_id"].(float64) - if doorbotID != float64(c.camera.ID) { - continue - } - - // check if the message is from the correct session - if res.Method == "session_created" || res.Method == "session_started" { - if _, ok := res.Body["session_id"]; ok && c.sessionID == "" { - c.sessionID = res.Body["session_id"].(string) - } - } - - if _, ok := res.Body["session_id"]; ok { - if res.Body["session_id"].(string) != c.sessionID { - continue - } - } - - rawMsg, _ := json.Marshal(res) - - switch res.Method { - case "sdp": - if prod, ok := c.prod.(*webrtc.Conn); ok { - // Get answer - var msg AnswerMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - c.Stop() - return - } - if err = prod.SetAnswer(msg.Body.SDP); err != nil { - c.Stop() - return - } - if err = c.activateSession(); err != nil { - c.Stop() - return - } - } - - case "ice": - if prod, ok := c.prod.(*webrtc.Conn); ok { - // Continue to receiving candidates - var msg IceCandidateMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - break - } - - // check for empty ICE candidate - if msg.Body.Ice == "" { - break - } - - if err = prod.AddCandidate(msg.Body.Ice); err != nil { - c.Stop() - return - } - } - - case "close": + if err := prod.SetAnswer(msg.Body.SDP); err != nil { c.Stop() + c.connected.Done(err) return + } - case "pong": - // Ignore - continue + if err := c.wsClient.activateSession(); err != nil { + c.Stop() + c.connected.Done(err) + return + } + + prod.SDP = msg.Body.SDP + } + + case "ice": + if prod, ok := c.prod.(*webrtc.Conn); ok { + var msg IceCandidateMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + break + } + + // Skip empty candidates + if msg.Body.Ice == "" { + break + } + + if err := prod.AddCandidate(msg.Body.Ice); err != nil { + c.Stop() + c.connected.Done(err) + return } } + + case "close": + c.Stop() + c.connected.Done(errors.New("ring: close")) + + case "pong": + // Ignore } } -func (c *Client) activateSession() error { - if err := c.sendSessionMessage("activate_session", nil); err != nil { - return err - } - - streamPayload := map[string]interface{}{ - "audio_enabled": true, - "video_enabled": true, - } - - if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { - return err - } - - return nil -} - -func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { - c.wsMutex.Lock() - defer c.wsMutex.Unlock() - - if body == nil { - body = make(map[string]interface{}) - } - - body["doorbot_id"] = c.camera.ID - if c.sessionID != "" { - body["session_id"] = c.sessionID - } - - msg := map[string]interface{}{ - "method": method, - "dialog_id": c.dialogID, - "body": body, - } - - if err := c.ws.WriteJSON(msg); err != nil { - return err - } - - return nil -} - func (c *Client) GetMedias() []*core.Media { return c.prod.GetMedias() } @@ -492,7 +320,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece speakerPayload := map[string]interface{}{ "stealth_mode": false, } - _ = c.sendSessionMessage("camera_options", speakerPayload) + _ = c.wsClient.sendSessionMessage("camera_options", speakerPayload) } return webrtcProd.AddTrack(media, codec, track) } @@ -505,37 +333,23 @@ func (c *Client) Start() error { } func (c *Client) Stop() error { - select { - case <-c.done: + if c.closed { return nil - default: - close(c.done) } + c.closed = true + if c.prod != nil { _ = c.prod.Stop() } - if c.ws != nil { - closePayload := map[string]interface{}{ - "reason": map[string]interface{}{ - "code": CloseReasonNormalClose, - "text": "", - }, - } - - _ = c.sendSessionMessage("close", closePayload) - _ = c.ws.Close() - c.ws = nil + if c.wsClient != nil { + _ = c.wsClient.Close() } return nil } func (c *Client) MarshalJSON() ([]byte, error) { - if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - return webrtcProd.MarshalJSON() - } - return json.Marshal(c.prod) } diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index f64e4f79..b52eadac 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -10,11 +10,11 @@ import ( type SnapshotProducer struct { core.Connection - client *RingRestClient - camera *CameraData + client *RingApi + cameraID int } -func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { +func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer { return &SnapshotProducer{ Connection: core.Connection{ ID: core.NewID(), @@ -35,14 +35,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr }, }, }, - client: client, - camera: camera, + client: client, + cameraID: cameraID, } } func (p *SnapshotProducer) Start() error { - // Fetch snapshot - response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil) + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil) if err != nil { return err } diff --git a/pkg/ring/ws.go b/pkg/ring/ws.go new file mode 100644 index 00000000..51e72fe6 --- /dev/null +++ b/pkg/ring/ws.go @@ -0,0 +1,265 @@ +package ring + +import ( + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type SessionBody struct { + DoorbotID int `json:"doorbot_id"` + SessionID string `json:"session_id"` +} + +type AnswerMessage struct { + Method string `json:"method"` // "sdp" + Body struct { + SessionBody + SDP string `json:"sdp"` + Type string `json:"type"` // "answer" + } `json:"body"` +} + +type IceCandidateMessage struct { + Method string `json:"method"` // "ice" + Body struct { + SessionBody + Ice string `json:"ice"` + MLineIndex int `json:"mlineindex"` + } `json:"body"` +} + +type SessionMessage struct { + Method string `json:"method"` // "session_created" or "session_started" + Body SessionBody `json:"body"` +} + +type PongMessage struct { + Method string `json:"method"` // "pong" + Body SessionBody `json:"body"` +} + +type NotificationMessage struct { + Method string `json:"method"` // "notification" + Body struct { + SessionBody + IsOK bool `json:"is_ok"` + Text string `json:"text"` + } `json:"body"` +} + +type StreamInfoMessage struct { + Method string `json:"method"` // "stream_info" + Body struct { + SessionBody + Transcoding bool `json:"transcoding"` + TranscodingReason string `json:"transcoding_reason"` + } `json:"body"` +} + +type CloseRequest struct { + Method string `json:"method"` // "close" + Body struct { + SessionBody + Reason struct { + Code int `json:"code"` + Text string `json:"text"` + } `json:"reason"` + } `json:"body"` +} + +type WSMessage struct { + Method string `json:"method"` + Body map[string]any `json:"body"` +} + +type WSClient struct { + ws *websocket.Conn + api *RingApi + wsMutex sync.Mutex + cameraID int + dialogID string + sessionID string + + onMessage func(msg WSMessage) + onError func(err error) + onClose func() + + closed chan struct{} +} + +const ( + CloseReasonNormalClose = 0 + CloseReasonAuthenticationFailed = 5 + CloseReasonTimeout = 6 +) + +func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) { + client := &WSClient{ + api: api, + cameraID: cameraID, + dialogID: uuid.NewString(), + closed: make(chan struct{}), + } + + ticket, err := client.api.GetSocketTicket() + if err != nil { + return nil, err + } + + url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", + uuid.NewString(), url.QueryEscape(ticket.Ticket)) + + httpHeader := http.Header{} + httpHeader.Set("User-Agent", "android:com.ringapp") + + client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader) + if err != nil { + return nil, err + } + + client.ws.SetCloseHandler(func(code int, text string) error { + client.onWsClose() + return nil + }) + + go client.startPingLoop() + go client.startMessageLoop() + + return client, nil +} + +func (c *WSClient) Close() error { + select { + case <-c.closed: + return nil + default: + close(c.closed) + } + + closePayload := map[string]interface{}{ + "reason": map[string]interface{}{ + "code": CloseReasonNormalClose, + "text": "", + }, + } + + _ = c.sendSessionMessage("close", closePayload) + + return c.ws.Close() +} + +func (c *WSClient) startPingLoop() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.closed: + return + case <-ticker.C: + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } +} + +func (c *WSClient) startMessageLoop() { + for { + select { + case <-c.closed: + return + default: + var res WSMessage + if err := c.ws.ReadJSON(&res); err != nil { + select { + case <-c.closed: + // Ignore error if closed + default: + c.onWsError(err) + } + + return + } + + c.onWsMessage(res) + } + } +} + +func (c *WSClient) activateSession() error { + if err := c.sendSessionMessage("activate_session", nil); err != nil { + return err + } + + streamPayload := map[string]interface{}{ + "audio_enabled": true, + "video_enabled": true, + } + + if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { + return err + } + + return nil +} + +func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error { + select { + case <-c.closed: + return nil + default: + // continue + } + + c.wsMutex.Lock() + defer c.wsMutex.Unlock() + + if payload == nil { + payload = make(map[string]interface{}) + } + + payload["doorbot_id"] = c.cameraID + if c.sessionID != "" { + payload["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": payload, + } + + // rawMsg, _ := json.Marshal(msg) + // fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg)) + + if err := c.ws.WriteJSON(msg); err != nil { + return err + } + + return nil +} + +func (c *WSClient) onWsMessage(msg WSMessage) { + if c.onMessage != nil { + c.onMessage(msg) + } +} + +func (c *WSClient) onWsError(err error) { + if c.onError != nil { + c.onError(err) + } +} + +func (c *WSClient) onWsClose() { + if c.onClose != nil { + c.onClose() + } +} diff --git a/pkg/roborock/client.go b/pkg/roborock/client.go index ef221e65..4940b74c 100644 --- a/pkg/roborock/client.go +++ b/pkg/roborock/client.go @@ -15,7 +15,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/roborock/iot" "github.com/AlexxIT/go2rtc/pkg/webrtc" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) // Deprecated: should be rewritten to core.Connection diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index 7fc134fc..c9607321 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/AlexxIT/go2rtc/pkg/tcp/websocket" @@ -36,14 +37,22 @@ func (c *Conn) Dial() (err error) { var conn net.Conn - if c.Transport == "" { - timeout := core.ConnDialTimeout + switch c.Transport { + case "", "tcp", "udp": + var timeout time.Duration if c.Timeout != 0 { timeout = time.Second * time.Duration(c.Timeout) + } else { + timeout = core.ConnDialTimeout } conn, err = tcp.Dial(c.URL, timeout) - c.Protocol = "rtsp+tcp" - } else { + + if c.Transport != "udp" { + c.Protocol = "rtsp+tcp" + } else { + c.Protocol = "rtsp+udp" + } + default: conn, err = websocket.Dial(c.Transport) c.Protocol = "ws" } @@ -61,6 +70,9 @@ func (c *Conn) Dial() (err error) { c.sequence = 0 c.state = StateConn + c.udpConn = nil + c.udpAddr = nil + c.Connection.RemoteAddr = conn.RemoteAddr().String() c.Connection.Transport = conn c.Connection.URL = c.uri @@ -81,7 +93,35 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) { c.Fire(res) - if res.StatusCode == http.StatusUnauthorized { + switch res.StatusCode { + case http.StatusOK: + return res, nil + + case http.StatusMovedPermanently, http.StatusFound: + rawURL := res.Header.Get("Location") + + var u *url.URL + if u, err = url.Parse(rawURL); err != nil { + return nil, err + } + + if u.User == nil { + u.User = c.auth.UserInfo() // restore auth if we don't have it in the new URL + } + + c.uri = u.String() // so auth will be saved on reconnect + + _ = c.conn.Close() + + if err = c.Dial(); err != nil { + return nil, err + } + + req.URL = c.URL // because path was changed + + return c.Do(req) + + case http.StatusUnauthorized: switch c.auth.Method { case tcp.AuthNone: if c.auth.ReadNone(res) { @@ -97,11 +137,7 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) { } } - if res.StatusCode != http.StatusOK { - return res, fmt.Errorf("wrong response on %s", req.Method) - } - - return res, nil + return res, fmt.Errorf("wrong response on %s", req.Method) } func (c *Conn) Options() error { @@ -218,15 +254,27 @@ func (c *Conn) Record() (err error) { func (c *Conn) SetupMedia(media *core.Media) (byte, error) { var transport string - // try to use media position as channel number - for i, m := range c.Medias { - if m.Equal(media) { - transport = fmt.Sprintf( - // i - RTP (data channel) - // i+1 - RTCP (control channel) - "RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1, - ) - break + if c.Transport == "udp" { + conn1, conn2, err := ListenUDPPair() + if err != nil { + return 0, err + } + + c.udpConn = append(c.udpConn, conn1, conn2) + + port := conn1.LocalAddr().(*net.UDPAddr).Port + transport = fmt.Sprintf("RTP/AVP;unicast;client_port=%d-%d", port, port+1) + } else { + // try to use media position as channel number + for i, m := range c.Medias { + if m.Equal(media) { + transport = fmt.Sprintf( + // i - RTP (data channel) + // i+1 - RTCP (control channel) + "RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1, + ) + break + } } } @@ -286,27 +334,53 @@ func (c *Conn) SetupMedia(media *core.Media) (byte, error) { } } - // we send our `interleaved`, but camera can answer with another - - // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 - // Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0 - // Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1 + // Parse server response transport = res.Header.Get("Transport") - if !strings.HasPrefix(transport, "RTP/AVP/TCP;") { + + if c.Transport == "udp" { + channel := byte(len(c.udpConn) - 2) + + // Dahua: RTP/AVP/UDP;unicast;client_port=49292-49293;server_port=43670-43671;ssrc=7CB694B4 + // OpenIPC: RTP/AVP/UDP;unicast;client_port=59612-59613 + if s := core.Between(transport, "server_port=", ";"); s != "" { + s1, s2, _ := strings.Cut(s, "-") + port1 := core.Atoi(s1) + port2 := core.Atoi(s2) + // TODO: more smart handling empty server ports + if port1 > 0 && port2 > 0 { + remoteIP := c.conn.RemoteAddr().(*net.TCPAddr).IP + c.udpAddr = append(c.udpAddr, + &net.UDPAddr{IP: remoteIP, Port: port1}, + &net.UDPAddr{IP: remoteIP, Port: port2}, + ) + + go func() { + // Try to open a hole in the NAT router (to allow incoming UDP packets) + // by send a UDP packet for RTP and RTCP to the remote RTSP server. + // https://github.com/FFmpeg/FFmpeg/blob/aa91ae25b88e195e6af4248e0ab30605735ca1cd/libavformat/rtpdec.c#L416-L438 + _, _ = c.WriteToUDP([]byte{0x80, 0x00, 0x00, 0x00}, channel) + _, _ = c.WriteToUDP([]byte{0x80, 0xC8, 0x00, 0x01}, channel+1) + }() + } + } + + return channel, nil + } else { + // we send our `interleaved`, but camera can answer with another + + // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 + // Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0 + // Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1 // Escam Q6 has a bug: // Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1 - if !strings.Contains(transport, ";interleaved=") { + s := core.Between(transport, "interleaved=", "-") + i, err := strconv.Atoi(s) + if err != nil { return 0, fmt.Errorf("wrong transport: %s", transport) } - } - channel := core.Between(transport, "interleaved=", "-") - i, err := strconv.Atoi(channel) - if err != nil { - return 0, err + return byte(i), nil } - - return byte(i), nil } func (c *Conn) Play() (err error) { @@ -327,5 +401,56 @@ func (c *Conn) Close() error { if c.OnClose != nil { _ = c.OnClose() } + for _, conn := range c.udpConn { + _ = conn.Close() + } return c.conn.Close() } + +func (c *Conn) WriteToUDP(b []byte, channel byte) (int, error) { + return c.udpConn[channel].WriteToUDP(b, c.udpAddr[channel]) +} + +const listenUDPAttemps = 10 + +var listenUDPMu sync.Mutex + +func ListenUDPPair() (*net.UDPConn, *net.UDPConn, error) { + listenUDPMu.Lock() + defer listenUDPMu.Unlock() + + for i := 0; i < listenUDPAttemps; i++ { + // Get a random even port from the OS + ln1, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: 0}) + if err != nil { + continue + } + + var port1 = ln1.LocalAddr().(*net.UDPAddr).Port + var port2 int + + // 11. RTP over Network and Transport Protocols (https://www.ietf.org/rfc/rfc3550.txt) + // For UDP and similar protocols, + // RTP SHOULD use an even destination port number and the corresponding + // RTCP stream SHOULD use the next higher (odd) destination port number + if port1&1 > 0 { + port2 = port1 - 1 + } else { + port2 = port1 + 1 + } + + ln2, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: port2}) + if err != nil { + _ = ln1.Close() + continue + } + + if port1 < port2 { + return ln1, ln2, nil + } else { + return ln2, ln1, nil + } + } + + return nil, nil, fmt.Errorf("can't open two UDP ports") +} diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index 0c2009d7..2984c781 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -2,6 +2,7 @@ package rtsp import ( "bufio" + "context" "encoding/binary" "fmt" "io" @@ -13,7 +14,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/pion/rtcp" "github.com/pion/rtp" ) @@ -40,6 +40,7 @@ type Conn struct { keepalive int mode core.Mode playOK bool + playErr error reader *bufio.Reader sequence int session string @@ -47,6 +48,9 @@ type Conn struct { state State stateMu sync.Mutex + + udpConn []*net.UDPConn + udpAddr []*net.UDPAddr } const ( @@ -68,7 +72,6 @@ func (s State) String() string { case StateNone: return "NONE" case StateConn: - return "CONN" case StateSetup: return MethodSetup @@ -88,23 +91,25 @@ const ( func (c *Conn) Handle() (err error) { var timeout time.Duration - var keepaliveDT time.Duration - var keepaliveTS time.Time - switch c.mode { case core.ModeActiveProducer: + var keepaliveDT time.Duration + if c.keepalive > 5 { keepaliveDT = time.Duration(c.keepalive-5) * time.Second } else { keepaliveDT = 25 * time.Second } - keepaliveTS = time.Now().Add(keepaliveDT) + + ctx, cancel := context.WithCancel(context.Background()) + go c.handleKeepalive(ctx, keepaliveDT) + defer cancel() if c.Timeout == 0 { // polling frames from remote RTSP Server (ex Camera) timeout = time.Second * 5 - if len(c.Receivers) == 0 { + if len(c.Receivers) == 0 || c.Transport == "udp" { // if we only send audio to camera // https://github.com/AlexxIT/go2rtc/issues/659 timeout += keepaliveDT @@ -129,148 +134,190 @@ func (c *Conn) Handle() (err error) { return fmt.Errorf("wrong RTSP conn mode: %d", c.mode) } + for i := 0; i < len(c.udpConn); i++ { + go c.handleUDPData(byte(i)) + } + for c.state != StateNone { ts := time.Now() - if err = c.conn.SetReadDeadline(ts.Add(timeout)); err != nil { + _ = c.conn.SetReadDeadline(ts.Add(timeout)) + + if err = c.handleTCPData(); err != nil { return } - - // we can read: - // 1. RTP interleaved: `$` + 1B channel number + 2B size - // 2. RTSP response: RTSP/1.0 200 OK - // 3. RTSP request: OPTIONS ... - var buf4 []byte // `$` + 1B channel number + 2B size - buf4, err = c.reader.Peek(4) - if err != nil { - return - } - - var channelID byte - var size uint16 - - if buf4[0] != '$' { - switch string(buf4) { - case "RTSP": - var res *tcp.Response - if res, err = c.ReadResponse(); err != nil { - return - } - c.Fire(res) - // for playing backchannel only after OK response on play - c.playOK = true - continue - - case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": - var req *tcp.Request - if req, err = c.ReadRequest(); err != nil { - return - } - c.Fire(req) - if req.Method == MethodOptions { - res := &tcp.Response{Request: req} - if err = c.WriteResponse(res); err != nil { - return - } - } - continue - - default: - c.Fire("RTSP wrong input") - - for i := 0; ; i++ { - // search next start symbol - if _, err = c.reader.ReadBytes('$'); err != nil { - return err - } - - if channelID, err = c.reader.ReadByte(); err != nil { - return err - } - - // TODO: better check maximum good channel ID - if channelID >= 20 { - continue - } - - buf4 = make([]byte, 2) - if _, err = io.ReadFull(c.reader, buf4); err != nil { - return err - } - - // check if size good for RTP - size = binary.BigEndian.Uint16(buf4) - if size <= 1500 { - break - } - - // 10 tries to find good packet - if i >= 10 { - return fmt.Errorf("RTSP wrong input") - } - } - } - } else { - // hope that the odd channels are always RTCP - channelID = buf4[1] - - // get data size - size = binary.BigEndian.Uint16(buf4[2:]) - - // skip 4 bytes from c.reader.Peek - if _, err = c.reader.Discard(4); err != nil { - return - } - } - - // init memory for data - buf := make([]byte, size) - if _, err = io.ReadFull(c.reader, buf); err != nil { - return - } - - c.Recv += int(size) - - if channelID&1 == 0 { - packet := &rtp.Packet{} - if err = packet.Unmarshal(buf); err != nil { - return - } - - for _, receiver := range c.Receivers { - if receiver.ID == channelID { - receiver.WriteRTP(packet) - break - } - } - } else { - msg := &RTCP{Channel: channelID} - - if err = msg.Header.Unmarshal(buf); err != nil { - continue - } - - msg.Packets, err = rtcp.Unmarshal(buf) - if err != nil { - continue - } - - c.Fire(msg) - } - - if keepaliveDT != 0 && ts.After(keepaliveTS) { - req := &tcp.Request{Method: MethodOptions, URL: c.URL} - if err = c.WriteRequest(req); err != nil { - return - } - - keepaliveTS = ts.Add(keepaliveDT) - } } return } +func (c *Conn) handleKeepalive(ctx context.Context, d time.Duration) { + ticker := time.NewTicker(d) + for { + select { + case <-ticker.C: + req := &tcp.Request{Method: MethodOptions, URL: c.URL} + if err := c.WriteRequest(req); err != nil { + return + } + case <-ctx.Done(): + return + } + } +} + +func (c *Conn) handleUDPData(channel byte) { + // TODO: handle timeouts and drop TCP connection after any error + conn := c.udpConn[channel] + + for { + // TP-Link Tapo camera has crazy 10000 bytes packet size + buf := make([]byte, 10240) + + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + + if err = c.handleRawPacket(channel, buf[:n]); err != nil { + return + } + } +} + +func (c *Conn) handleTCPData() error { + // we can read: + // 1. RTP interleaved: `$` + 1B channel number + 2B size + // 2. RTSP response: RTSP/1.0 200 OK + // 3. RTSP request: OPTIONS ... + var buf4 []byte // `$` + 1B channel number + 2B size + var err error + + buf4, err = c.reader.Peek(4) + if err != nil { + return err + } + + var channel byte + var size uint16 + + if buf4[0] != '$' { + switch string(buf4) { + case "RTSP": + var res *tcp.Response + if res, err = c.ReadResponse(); err != nil { + return err + } + c.Fire(res) + // for playing backchannel only after OK response on play + c.playOK = true + return nil + + case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": + var req *tcp.Request + if req, err = c.ReadRequest(); err != nil { + return err + } + c.Fire(req) + if req.Method == MethodOptions { + res := &tcp.Response{Request: req} + if err = c.WriteResponse(res); err != nil { + return err + } + } + return nil + + default: + c.Fire("RTSP wrong input") + + for i := 0; ; i++ { + // search next start symbol + if _, err = c.reader.ReadBytes('$'); err != nil { + return err + } + + if channel, err = c.reader.ReadByte(); err != nil { + return err + } + + // TODO: better check maximum good channel ID + if channel >= 20 { + continue + } + + buf4 = make([]byte, 2) + if _, err = io.ReadFull(c.reader, buf4); err != nil { + return err + } + + // check if size good for RTP + size = binary.BigEndian.Uint16(buf4) + if size <= 1500 { + break + } + + // 10 tries to find good packet + if i >= 10 { + return fmt.Errorf("RTSP wrong input") + } + } + } + } else { + // hope that the odd channels are always RTCP + channel = buf4[1] + + // get data size + size = binary.BigEndian.Uint16(buf4[2:]) + + // skip 4 bytes from c.reader.Peek + if _, err = c.reader.Discard(4); err != nil { + return err + } + } + + // init memory for data + buf := make([]byte, size) + if _, err = io.ReadFull(c.reader, buf); err != nil { + return err + } + + c.Recv += int(size) + + return c.handleRawPacket(channel, buf) +} + +func (c *Conn) handleRawPacket(channel byte, buf []byte) error { + if channel&1 == 0 { + packet := &rtp.Packet{} + if err := packet.Unmarshal(buf); err != nil { + return err + } + + for _, receiver := range c.Receivers { + if receiver.ID == channel { + receiver.WriteRTP(packet) + break + } + } + } else { + msg := &RTCP{Channel: channel} + + if err := msg.Header.Unmarshal(buf); err != nil { + return nil + } + + //var err error + //msg.Packets, err = rtcp.Unmarshal(buf) + //if err != nil { + // return nil + //} + + c.Fire(msg) + } + + return nil +} + func (c *Conn) WriteRequest(req *tcp.Request) error { if req.Proto == "" { req.Proto = ProtoRTSP diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index 860ed113..e6525d96 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -85,11 +85,8 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. } flushBuf := func() { - if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { - return - } //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) - if _, err := c.conn.Write(buf[:n]); err == nil { + if err := c.writeInterleavedData(buf[:n]); err != nil { c.Send += n } n = 0 @@ -177,3 +174,25 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. return handlerFunc } + +func (c *Conn) writeInterleavedData(data []byte) error { + if c.Transport != "udp" { + _ = c.conn.SetWriteDeadline(time.Now().Add(Timeout)) + _, err := c.conn.Write(data) + return err + } + + for len(data) >= 4 && data[0] == '$' { + channel := data[1] + size := uint16(data[2])<<8 | uint16(data[3]) + rtpData := data[4 : 4+size] + + if _, err := c.WriteToUDP(rtpData, channel); err != nil { + return err + } + + data = data[4+size:] + } + + return nil +} diff --git a/pkg/rtsp/helpers.go b/pkg/rtsp/helpers.go index d8ed1685..c73bd0a2 100644 --- a/pkg/rtsp/helpers.go +++ b/pkg/rtsp/helpers.go @@ -116,20 +116,39 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin // urlParse fix bugs: // 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/ // 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/ +// 3. Content-Base: 192.168.253.220:1935/ func urlParse(rawURL string) (*url.URL, error) { // fix https://github.com/AlexxIT/go2rtc/issues/830 if strings.HasPrefix(rawURL, "rtsp://rtsp://") { rawURL = rawURL[7:] } + // fix https://github.com/AlexxIT/go2rtc/issues/1852 + if !strings.Contains(rawURL, "://") { + rawURL = "rtsp://" + rawURL + } + u, err := url.Parse(rawURL) if err != nil && strings.HasSuffix(err.Error(), "after host") { - if i1 := strings.Index(rawURL, "://"); i1 > 0 { - if i2 := strings.IndexByte(rawURL[i1+3:], '/'); i2 > 0 { - return urlParse(rawURL[:i1+3+i2] + ":" + rawURL[i1+3+i2:]) - } + if i := indexN(rawURL, '/', 3); i > 0 { + return urlParse(rawURL[:i] + ":" + rawURL[i:]) } } return u, err } + +func indexN(s string, c byte, n int) int { + var offset int + for { + i := strings.IndexByte(s[offset:], c) + if i < 0 { + break + } + if n--; n == 0 { + return offset + i + } + offset += i + 1 + } + return -1 +} diff --git a/pkg/rtsp/rtsp_test.go b/pkg/rtsp/rtsp_test.go index 14c99803..282c04f8 100644 --- a/pkg/rtsp/rtsp_test.go +++ b/pkg/rtsp/rtsp_test.go @@ -11,14 +11,20 @@ func TestURLParse(t *testing.T) { // https://github.com/AlexxIT/WebRTC/issues/395 base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/" u, err := urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "::ffff:192.168.1.123:", u.Host) // https://github.com/AlexxIT/go2rtc/issues/208 base = "rtsp://rtsp://turret2-cam.lan:554/stream1/" u, err = urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "turret2-cam.lan:554", u.Host) + + // https://github.com/AlexxIT/go2rtc/issues/1852 + base = "192.168.253.220:1935/" + u, err = urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "192.168.253.220:1935", u.Host) } func TestBugSDP1(t *testing.T) { diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 75df671f..e04a58c4 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -3,8 +3,6 @@ package shell import ( "os" "os/signal" - "path/filepath" - "regexp" "strings" "syscall" ) @@ -38,39 +36,6 @@ func QuoteSplit(s string) []string { return a } -// ReplaceEnvVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} -func ReplaceEnvVars(text string) string { - re := regexp.MustCompile(`\${([^}{]+)}`) - return re.ReplaceAllStringFunc(text, func(match string) string { - key := match[2 : len(match)-1] - - var def string - var dok bool - - if i := strings.IndexByte(key, ':'); i > 0 { - key, def = key[:i], key[i+1:] - dok = true - } - - if dir, vok := os.LookupEnv("CREDENTIALS_DIRECTORY"); vok { - value, err := os.ReadFile(filepath.Join(dir, key)) - if err == nil { - return strings.TrimSpace(string(value)) - } - } - - if value, vok := os.LookupEnv(key); vok { - return value - } - - if dok { - return def - } - - return match - }) -} - func RunUntilSignal() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) diff --git a/pkg/srtp/session.go b/pkg/srtp/session.go index f70f9df6..0ab81648 100644 --- a/pkg/srtp/session.go +++ b/pkg/srtp/session.go @@ -6,7 +6,7 @@ import ( "github.com/pion/rtcp" "github.com/pion/rtp" - "github.com/pion/srtp/v2" + "github.com/pion/srtp/v3" ) type Session struct { diff --git a/pkg/stdin/backchannel.go b/pkg/stdin/backchannel.go deleted file mode 100644 index b154a291..00000000 --- a/pkg/stdin/backchannel.go +++ /dev/null @@ -1,59 +0,0 @@ -package stdin - -import ( - "encoding/json" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" -) - -func (c *Client) GetMedias() []*core.Media { - return c.medias -} - -func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - return nil, core.ErrCantGetTrack -} - -func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { - if c.sender == nil { - stdin, err := c.cmd.StdinPipe() - if err != nil { - return err - } - - c.sender = core.NewSender(media, track.Codec) - c.sender.Handler = func(packet *rtp.Packet) { - _, _ = stdin.Write(packet.Payload) - c.send += len(packet.Payload) - } - } - - c.sender.HandleRTP(track) - return nil -} - -func (c *Client) Start() (err error) { - return c.cmd.Run() -} - -func (c *Client) Stop() (err error) { - if c.sender != nil { - c.sender.Close() - } - return c.cmd.Close() -} - -func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Connection{ - ID: core.ID(c), - FormatName: "exec", - Protocol: "pipe", - Medias: c.medias, - Send: c.send, - } - if c.sender != nil { - info.Senders = []*core.Sender{c.sender} - } - return json.Marshal(info) -} diff --git a/pkg/stdin/client.go b/pkg/stdin/client.go deleted file mode 100644 index a77d4459..00000000 --- a/pkg/stdin/client.go +++ /dev/null @@ -1,33 +0,0 @@ -package stdin - -import ( - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/shell" -) - -// Deprecated: should be rewritten to core.Connection -type Client struct { - cmd *shell.Command - - medias []*core.Media - sender *core.Sender - send int -} - -func NewClient(cmd *shell.Command) (*Client, error) { - c := &Client{ - cmd: cmd, - medias: []*core.Media{ - { - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecPCMA, ClockRate: 8000}, - {Name: core.CodecPCM}, - }, - }, - }, - } - - return c, nil -} diff --git a/pkg/tcp/auth.go b/pkg/tcp/auth.go index 3eb26024..9cc56ba2 100644 --- a/pkg/tcp/auth.go +++ b/pkg/tcp/auth.go @@ -112,6 +112,10 @@ func (a *Auth) ReadNone(res *Response) bool { return false } +func (a *Auth) UserInfo() *url.Userinfo { + return url.UserPassword(a.user, a.pass) +} + func Between(s, sub1, sub2 string) string { i := strings.Index(s, sub1) if i < 0 { diff --git a/pkg/wav/backchannel.go b/pkg/wav/backchannel.go new file mode 100644 index 00000000..f9697ee4 --- /dev/null +++ b/pkg/wav/backchannel.go @@ -0,0 +1,67 @@ +package wav + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + cmd *shell.Command +} + +func NewBackchannel(cmd *shell.Command) (core.Producer, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + //{Name: core.CodecPCML}, + {Name: core.CodecPCMA}, + {Name: core.CodecPCMU}, + }, + }, + } + + return &Backchannel{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wav", + Protocol: "pipe", + Medias: medias, + Transport: cmd, + }, + cmd: cmd, + }, nil +} + +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Backchannel) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + wr, err := c.cmd.StdinPipe() + if err != nil { + return err + } + + b := Header(track.Codec) + if _, err = wr.Write(b); err != nil { + return err + } + + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := wr.Write(packet.Payload); err != nil { + c.Send += n + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Backchannel) Start() error { + return c.cmd.Run() +} diff --git a/pkg/wav/producer.go b/pkg/wav/producer.go index 63f6d01a..60bdeaa1 100644 --- a/pkg/wav/producer.go +++ b/pkg/wav/producer.go @@ -2,7 +2,6 @@ package wav import ( "bufio" - "encoding/binary" "errors" "io" @@ -17,39 +16,11 @@ func Open(r io.Reader) (*Producer, error) { // https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html rd := bufio.NewReaderSize(r, core.BufferSize) - // skip Master RIFF chunk - if _, err := rd.Discard(12); err != nil { + codec, err := ReadHeader(r) + if err != nil { return nil, err } - codec := &core.Codec{} - - for { - chunkID, data, err := readChunk(rd) - if err != nil { - return nil, err - } - - if chunkID == "data" { - break - } - - if chunkID == "fmt " { - // https://audiocoding.cc/articles/2008-05-22-wav-file-structure/wav_formats.txt - switch data[0] { - case 1: - codec.Name = core.CodecPCML - case 6: - codec.Name = core.CodecPCMA - case 7: - codec.Name = core.CodecPCMU - } - - codec.Channels = uint16(data[2]) - codec.ClockRate = binary.LittleEndian.Uint32(data[4:]) - } - } - if codec.Name == "" { return nil, errors.New("waw: unsupported codec") } @@ -110,18 +81,3 @@ func (c *Producer) Start() error { ts += PacketSize } } - -func readChunk(r io.Reader) (chunkID string, data []byte, err error) { - b := make([]byte, 8) - if _, err = io.ReadFull(r, b); err != nil { - return - } - - if chunkID = string(b[:4]); chunkID != "data" { - size := binary.LittleEndian.Uint32(b[4:]) - data = make([]byte, size) - _, err = io.ReadFull(r, data) - } - - return -} diff --git a/pkg/wav/wav.go b/pkg/wav/wav.go new file mode 100644 index 00000000..9fe857d4 --- /dev/null +++ b/pkg/wav/wav.go @@ -0,0 +1,103 @@ +package wav + +import ( + "encoding/binary" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Header(codec *core.Codec) []byte { + var fmt, size, extra byte + + switch codec.Name { + case core.CodecPCML: + fmt = 1 + size = 2 + case core.CodecPCMA: + fmt = 6 + size = 1 + extra = 2 + case core.CodecPCMU: + fmt = 7 + size = 1 + extra = 2 + default: + return nil + } + + channels := byte(codec.Channels) + if channels == 0 { + channels = 1 + } + + b := make([]byte, 0, 46) // cap with extra + b = append(b, "RIFF\xFF\xFF\xFF\xFFWAVEfmt "...) + + b = append(b, 0x10+extra, 0, 0, 0) + b = append(b, fmt, 0) + b = append(b, channels, 0) + b = binary.LittleEndian.AppendUint32(b, codec.ClockRate) + b = binary.LittleEndian.AppendUint32(b, uint32(size*channels)*codec.ClockRate) + b = append(b, size*channels, 0) + b = append(b, size*8, 0) + if extra > 0 { + b = append(b, 0, 0) // ExtraParamSize (if PCM, then doesn't exist) + } + + b = append(b, "data\xFF\xFF\xFF\xFF"...) + + return b +} + +func ReadHeader(r io.Reader) (*core.Codec, error) { + // skip Master RIFF chunk + if _, err := io.ReadFull(r, make([]byte, 12)); err != nil { + return nil, err + } + + var codec core.Codec + + for { + chunkID, data, err := readChunk(r) + if err != nil { + return nil, err + } + + if chunkID == "data" { + break + } + + if chunkID == "fmt " { + // https://audiocoding.cc/articles/2008-05-22-wav-file-structure/wav_formats.txt + switch data[0] { + case 1: + codec.Name = core.CodecPCML + case 6: + codec.Name = core.CodecPCMA + case 7: + codec.Name = core.CodecPCMU + } + + codec.Channels = data[2] + codec.ClockRate = binary.LittleEndian.Uint32(data[4:]) + } + } + + return &codec, nil +} + +func readChunk(r io.Reader) (chunkID string, data []byte, err error) { + b := make([]byte, 8) + if _, err = io.ReadFull(r, b); err != nil { + return + } + + if chunkID = string(b[:4]); chunkID != "data" { + size := binary.LittleEndian.Uint32(b[4:]) + data = make([]byte, size) + _, err = io.ReadFull(r, data) + } + + return +} diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index 013a2f25..79cf6d3c 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -5,9 +5,9 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/xnet" - "github.com/pion/ice/v2" + "github.com/pion/ice/v4" "github.com/pion/interceptor" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" ) // ReceiveMTU = Ethernet MTU (1500) - IP Header (20) - UDP Header (8) @@ -125,13 +125,20 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error networks = append(networks, ice.NetworkType(ntype)) } - udpMux, _ = ice.NewMultiUDPMuxFromPort( + var err error + if udpMux, err = ice.NewMultiUDPMuxFromPort( port, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter), ice.UDPMuxFromPortWithIPFilter(ipFilter), ice.UDPMuxFromPortWithNetworks(networks...), - ) - } else if ln, err := net.ListenPacket("udp", address); err == nil { + ); err != nil { + return nil, err + } + } else { + ln, err := net.ListenPacket("udp", address) + if err != nil { + return nil, err + } udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) } s.SetICEUDPMux(udpMux) diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 9a7a7b2f..84e9e86b 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -3,7 +3,7 @@ package webrtc import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/sdp/v3" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" ) func (c *Conn) CreateOffer(medias []*core.Media) (string, error) { diff --git a/pkg/webrtc/client_test.go b/pkg/webrtc/client_test.go index 45c8c88d..ce50ba65 100644 --- a/pkg/webrtc/client_test.go +++ b/pkg/webrtc/client_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 0845bdda..092b05c8 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -9,7 +9,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtcp" "github.com/pion/rtp" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" ) type Conn struct { diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index e9d7b2e5..ebc3a008 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -73,7 +73,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv codec.Name = core.CodecPCMA } codec.ClockRate = 8000 - sender.Handler = pcm.ResampleToG711(track.Codec, 8000, sender.Handler) + sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler) } } diff --git a/pkg/webrtc/helpers.go b/pkg/webrtc/helpers.go index b6cd3ab3..766254a0 100644 --- a/pkg/webrtc/helpers.go +++ b/pkg/webrtc/helpers.go @@ -11,10 +11,10 @@ import ( "time" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/ice/v2" + "github.com/pion/ice/v4" "github.com/pion/sdp/v3" - "github.com/pion/stun" - "github.com/pion/webrtc/v3" + "github.com/pion/stun/v3" + "github.com/pion/webrtc/v4" ) func UnmarshalMedias(descriptions []*sdp.MediaDescription) (medias []*core.Media) { diff --git a/pkg/webrtc/producer.go b/pkg/webrtc/producer.go index a0910c39..32e958ee 100644 --- a/pkg/webrtc/producer.go +++ b/pkg/webrtc/producer.go @@ -2,7 +2,7 @@ package webrtc import ( "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" ) func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { @@ -21,7 +21,7 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: MimeType(codec), ClockRate: codec.ClockRate, - Channels: codec.Channels, + Channels: uint16(codec.Channels), }, PayloadType: 0, // don't know if this necessary } diff --git a/pkg/webrtc/server.go b/pkg/webrtc/server.go index 9cc89778..4714a6a4 100644 --- a/pkg/webrtc/server.go +++ b/pkg/webrtc/server.go @@ -3,7 +3,7 @@ package webrtc import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/sdp/v3" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" ) func (c *Conn) SetOffer(offer string) (err error) { @@ -65,7 +65,8 @@ transeivers: switch tr.Direction() { case webrtc.RTPTransceiverDirectionSendrecv: - _ = tr.Sender().Stop() + _ = tr.Sender().Stop() // don't know if necessary + _ = tr.SetSender(tr.Sender(), nil) // set direction to recvonly case webrtc.RTPTransceiverDirectionSendonly: _ = tr.Stop() } diff --git a/pkg/webrtc/track.go b/pkg/webrtc/track.go index 3102abd1..657eee1f 100644 --- a/pkg/webrtc/track.go +++ b/pkg/webrtc/track.go @@ -4,7 +4,7 @@ import ( "sync" "github.com/pion/rtp" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" ) type Track struct { diff --git a/pkg/webrtc/webrtc_test.go b/pkg/webrtc/webrtc_test.go index c864a22b..6b20b089 100644 --- a/pkg/webrtc/webrtc_test.go +++ b/pkg/webrtc/webrtc_test.go @@ -3,7 +3,7 @@ package webrtc import ( "testing" - "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" ) diff --git a/pkg/webtorrent/client.go b/pkg/webtorrent/client.go index 3594679d..04eeccda 100644 --- a/pkg/webtorrent/client.go +++ b/pkg/webtorrent/client.go @@ -9,7 +9,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" - pion "github.com/pion/webrtc/v3" + pion "github.com/pion/webrtc/v4" ) func NewClient(tracker, share, pwd string, pc *pion.PeerConnection) (*webrtc.Conn, error) { diff --git a/pkg/wyoming/README.md b/pkg/wyoming/README.md new file mode 100644 index 00000000..ff17d079 --- /dev/null +++ b/pkg/wyoming/README.md @@ -0,0 +1,14 @@ +## Default wake words + +- alexa_v0.1 +- hey_jarvis_v0.1 +- hey_mycroft_v0.1 +- hey_rhasspy_v0.1 +- ok_nabu_v0.1 + +## Useful Links + +- https://github.com/rhasspy/wyoming-satellite +- https://github.com/rhasspy/wyoming-openwakeword +- https://github.com/fwartner/home-assistant-wakewords-collection +- https://github.com/esphome/micro-wake-word-models/tree/main?tab=readme-ov-file diff --git a/pkg/wyoming/api.go b/pkg/wyoming/api.go new file mode 100644 index 00000000..ce297a22 --- /dev/null +++ b/pkg/wyoming/api.go @@ -0,0 +1,99 @@ +package wyoming + +import ( + "bufio" + "encoding/json" + "io" + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type API struct { + conn net.Conn + rd *bufio.Reader +} + +func DialAPI(address string) (*API, error) { + conn, err := net.DialTimeout("tcp", address, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + return NewAPI(conn), nil +} + +const Version = "1.5.4" + +func NewAPI(conn net.Conn) *API { + return &API{conn: conn, rd: bufio.NewReader(conn)} +} + +func (w *API) WriteEvent(evt *Event) (err error) { + hdr := EventHeader{ + Type: evt.Type, + Version: Version, + DataLength: len(evt.Data), + PayloadLength: len(evt.Payload), + } + + buf, err := json.Marshal(hdr) + if err != nil { + return err + } + + buf = append(buf, '\n') + buf = append(buf, evt.Data...) + buf = append(buf, evt.Payload...) + + _, err = w.conn.Write(buf) + return err +} + +func (w *API) ReadEvent() (*Event, error) { + data, err := w.rd.ReadBytes('\n') + if err != nil { + return nil, err + } + + var hdr EventHeader + if err = json.Unmarshal(data, &hdr); err != nil { + return nil, err + } + + evt := Event{Type: hdr.Type} + + if hdr.DataLength > 0 { + data = make([]byte, hdr.DataLength) + if _, err = io.ReadFull(w.rd, data); err != nil { + return nil, err + } + evt.Data = string(data) + } + + if hdr.PayloadLength > 0 { + evt.Payload = make([]byte, hdr.PayloadLength) + if _, err = io.ReadFull(w.rd, evt.Payload); err != nil { + return nil, err + } + } + + return &evt, nil +} + +func (w *API) Close() error { + return w.conn.Close() +} + +type Event struct { + Type string + Data string + Payload []byte +} + +type EventHeader struct { + Type string `json:"type"` + Version string `json:"version"` + DataLength int `json:"data_length,omitempty"` + PayloadLength int `json:"payload_length,omitempty"` +} diff --git a/pkg/wyoming/backchannel.go b/pkg/wyoming/backchannel.go new file mode 100644 index 00000000..e0569fe1 --- /dev/null +++ b/pkg/wyoming/backchannel.go @@ -0,0 +1,63 @@ +package wyoming + +import ( + "fmt" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + api *API +} + +func newBackchannel(conn net.Conn) *Backchannel { + return &Backchannel{ + core.Connection{ + ID: core.NewID(), + FormatName: "wyoming", + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 22050}, + }, + }, + }, + Transport: conn, + }, + NewAPI(conn), + } +} + +func (b *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (b *Backchannel) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, codec) + sender.Handler = func(pkt *rtp.Packet) { + ts := time.Now().Nanosecond() + evt := &Event{ + Type: "audio-chunk", + Data: fmt.Sprintf(`{"rate":22050,"width":2,"channels":1,"timestamp":%d}`, ts), + Payload: pkt.Payload, + } + _ = b.api.WriteEvent(evt) + } + sender.HandleRTP(track) + b.Senders = append(b.Senders, sender) + return nil +} + +func (b *Backchannel) Start() error { + for { + if _, err := b.api.ReadEvent(); err != nil { + return err + } + } +} diff --git a/pkg/wyoming/expr.go b/pkg/wyoming/expr.go new file mode 100644 index 00000000..f2f58933 --- /dev/null +++ b/pkg/wyoming/expr.go @@ -0,0 +1,138 @@ +package wyoming + +import ( + "bytes" + "fmt" + "os" + "time" + + "github.com/AlexxIT/go2rtc/pkg/expr" + "github.com/AlexxIT/go2rtc/pkg/wav" +) + +type env struct { + *satellite + Type string + Data string +} + +func (s *satellite) handleEvent(evt *Event) { + switch evt.Type { + case "describe": + // {"asr": [], "tts": [], "handle": [], "intent": [], "wake": [], "satellite": {"name": "my satellite", "attribution": {"name": "", "url": ""}, "installed": true, "description": "my satellite", "version": "1.4.1", "area": null, "snd_format": null}} + data := fmt.Sprintf(`{"satellite":{"name":%q,"attribution":{"name":"go2rtc","url":"https://github.com/AlexxIT/go2rtc"},"installed":true}}`, s.srv.Name) + s.WriteEvent("info", data) + case "run-satellite": + s.Detect() + case "pause-satellite": + s.Stop() + case "detect": // WAKE_WORD_START {"names": null} + case "detection": // WAKE_WORD_END {"name": "ok_nabu_v0.1", "timestamp": 17580, "speaker": null} + case "transcribe": // STT_START {"language": "en"} + case "voice-started": // STT_VAD_START {"timestamp": 1160} + case "voice-stopped": // STT_VAD_END {"timestamp": 2470} + s.Pause() + case "transcript": // STT_END {"text": "how are you"} + case "synthesize": // TTS_START {"text": "Sorry, I couldn't understand that", "voice": {"language": "en"}} + case "audio-start": // TTS_END {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + case "audio-chunk": // {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + case "audio-stop": // {"timestamp": 2.880000000000002} + // run async because PlayAudio takes some time + go func() { + s.PlayAudio() + s.WriteEvent("played") + s.Detect() + }() + case "error": + s.Detect() + case "internal-run": + s.WriteEvent("run-pipeline", `{"start_stage":"wake","end_stage":"tts"}`) + s.Stream() + case "internal-detection": + s.WriteEvent("run-pipeline", `{"start_stage":"asr","end_stage":"tts"}`) + s.Stream() + } +} + +func (s *satellite) handleScript(evt *Event) { + var script string + if s.srv.Event != nil { + script = s.srv.Event[evt.Type] + } + + s.srv.Trace("event=%s data=%s payload size=%d", evt.Type, evt.Data, len(evt.Payload)) + + if script == "" { + s.handleEvent(evt) + return + } + + // run async because script can have sleeps + go func() { + e := &env{satellite: s, Type: evt.Type, Data: evt.Data} + if res, err := expr.Eval(script, e); err != nil { + s.srv.Trace("event=%s expr error=%s", evt.Type, err) + s.handleEvent(evt) + } else { + s.srv.Trace("event=%s expr result=%v", evt.Type, res) + } + }() +} + +func (s *satellite) Detect() bool { + return s.setMicState(stateWaitVAD) +} + +func (s *satellite) Stream() bool { + return s.setMicState(stateActive) +} + +func (s *satellite) Pause() bool { + return s.setMicState(stateIdle) +} + +func (s *satellite) Stop() bool { + s.micStop() + return true +} + +func (s *satellite) WriteEvent(args ...string) bool { + if len(args) == 0 { + return false + } + evt := &Event{Type: args[0]} + if len(args) > 1 { + evt.Data = args[1] + } + if err := s.api.WriteEvent(evt); err != nil { + return false + } + return true +} + +func (s *satellite) PlayAudio() bool { + return s.playAudio(sndCodec, bytes.NewReader(s.sndAudio)) +} + +func (s *satellite) PlayFile(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + + codec, err := wav.ReadHeader(f) + if err != nil { + return false + } + + return s.playAudio(codec, f) +} + +func (e *env) Sleep(s string) bool { + d, err := time.ParseDuration(s) + if err != nil { + return false + } + time.Sleep(d) + return true +} diff --git a/pkg/wyoming/mic.go b/pkg/wyoming/mic.go new file mode 100644 index 00000000..4fb03b44 --- /dev/null +++ b/pkg/wyoming/mic.go @@ -0,0 +1,35 @@ +package wyoming + +import ( + "fmt" + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (s *Server) HandleMic(conn net.Conn) { + defer conn.Close() + + var closed core.Waiter + var timestamp int + + api := NewAPI(conn) + mic := newMicConsumer(func(chunk []byte) { + data := fmt.Sprintf(`{"rate":16000,"width":2,"channels":1,"timestamp":%d}`, timestamp) + evt := &Event{Type: "audio-chunk", Data: data, Payload: chunk} + if err := api.WriteEvent(evt); err != nil { + closed.Done(nil) + } + + timestamp += len(chunk) / 2 + }) + mic.RemoteAddr = api.conn.RemoteAddr().String() + + if err := s.MicHandler(mic); err != nil { + s.Error("mic error: %s", err) + return + } + + _ = closed.Wait() + _ = mic.Stop() +} diff --git a/pkg/wyoming/producer.go b/pkg/wyoming/producer.go new file mode 100644 index 00000000..09451333 --- /dev/null +++ b/pkg/wyoming/producer.go @@ -0,0 +1,65 @@ +package wyoming + +import ( + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + api *API +} + +func newProducer(conn net.Conn) *Producer { + return &Producer{ + core.Connection{ + ID: core.NewID(), + FormatName: "wyoming", + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 16000}, + }, + }, + }, + Transport: conn, + }, + NewAPI(conn), + } +} + +func (p *Producer) Start() error { + var seq uint16 + var ts uint32 + + for { + evt, err := p.api.ReadEvent() + if err != nil { + return err + } + + if evt.Type != "audio-chunk" { + continue + } + + p.Recv += len(evt.Payload) + + pkt := &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: seq, + Timestamp: ts, + }, + Payload: evt.Payload, + } + p.Receivers[0].WriteRTP(pkt) + + seq++ + ts += uint32(len(evt.Payload) / 2) + } +} diff --git a/pkg/wyoming/satellite.go b/pkg/wyoming/satellite.go new file mode 100644 index 00000000..0c0e6f30 --- /dev/null +++ b/pkg/wyoming/satellite.go @@ -0,0 +1,275 @@ +package wyoming + +import ( + "context" + "fmt" + "io" + "net" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/AlexxIT/go2rtc/pkg/pcm/s16le" + "github.com/pion/rtp" +) + +type Server struct { + Name string + Event map[string]string + + VADThreshold int16 + WakeURI string + + MicHandler func(cons core.Consumer) error + SndHandler func(prod core.Producer) error + + Trace func(format string, v ...any) + Error func(format string, v ...any) +} + +func (s *Server) Serve(l net.Listener) error { + for { + conn, err := l.Accept() + if err != nil { + return err + } + + go s.Handle(conn) + } +} + +func (s *Server) Handle(conn net.Conn) { + api := NewAPI(conn) + sat := newSatellite(api, s) + defer sat.Close() + + for { + evt, err := api.ReadEvent() + if err != nil { + return + } + + switch evt.Type { + case "ping": // {"text": null} + _ = api.WriteEvent(&Event{Type: "pong", Data: evt.Data}) + case "audio-start": // TTS_END {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + sat.sndAudio = sat.sndAudio[:0] + case "audio-chunk": // {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + sat.sndAudio = append(sat.sndAudio, evt.Payload...) + default: + sat.handleScript(evt) + } + } +} + +// states like http.ConnState +const ( + stateError = -2 + stateClosed = -1 + stateNew = 0 + stateIdle = 1 + stateWaitVAD = 2 // aka wait VAD + stateWaitWakeWord = 3 + stateActive = 4 +) + +type satellite struct { + api *API + srv *Server + + micState int8 + micTS int + micMu sync.Mutex + sndAudio []byte + + mic *micConsumer + wake *WakeWord +} + +func newSatellite(api *API, srv *Server) *satellite { + sat := &satellite{api: api, srv: srv} + return sat +} + +func (s *satellite) Close() error { + s.Stop() + return s.api.Close() +} + +const wakeTimeout = 5 * 2 * 16000 // 5 seconds + +func (s *satellite) setMicState(state int8) bool { + s.micMu.Lock() + defer s.micMu.Unlock() + + if s.micState == stateNew { + s.mic = newMicConsumer(s.onMicChunk) + s.mic.RemoteAddr = s.api.conn.RemoteAddr().String() + if err := s.srv.MicHandler(s.mic); err != nil { + s.micState = stateError + s.srv.Error("can't get mic: %w", err) + _ = s.api.Close() + } else { + s.micState = stateIdle + } + } + + if s.micState < stateIdle { + return false + } + + s.micState = state + s.micTS = 0 + return true +} + +func (s *satellite) micStop() { + s.micMu.Lock() + + s.micState = stateClosed + if s.mic != nil { + _ = s.mic.Stop() + s.mic = nil + } + if s.wake != nil { + _ = s.wake.Close() + s.wake = nil + } + + s.micMu.Unlock() +} + +func (s *satellite) onMicChunk(chunk []byte) { + s.micMu.Lock() + defer s.micMu.Unlock() + + if s.micState == stateIdle { + return + } + + if s.micState == stateWaitVAD { + // tests show that values over 1000 are most likely speech + if s.srv.VADThreshold == 0 || s16le.PeaksRMS(chunk) > s.srv.VADThreshold { + if s.wake == nil && s.srv.WakeURI != "" { + s.wake, _ = DialWakeWord(s.srv.WakeURI) + } + if s.wake == nil { + // some problems with wake word - redirect to HA + s.micState = stateIdle + go s.handleScript(&Event{Type: "internal-run"}) + } else { + s.micState = stateWaitWakeWord + } + s.micTS = 0 + } + } + + if s.micState == stateWaitWakeWord { + if s.wake.Detection != "" { + // check if wake word detected + s.micState = stateIdle + go s.handleScript(&Event{Type: "internal-detection", Data: `{"name":"` + s.wake.Detection + `"}`}) + } else if err := s.wake.WriteChunk(chunk); err != nil { + // wake word service failed + s.micState = stateWaitVAD + _ = s.wake.Close() + s.wake = nil + } else if s.micTS > wakeTimeout { + // wake word detection timeout + s.micState = stateWaitVAD + } + } else if s.wake != nil { + _ = s.wake.Close() + s.wake = nil + } + + if s.micState == stateActive { + data := fmt.Sprintf(`{"rate":16000,"width":2,"channels":1,"timestamp":%d}`, s.micTS) + evt := &Event{Type: "audio-chunk", Data: data, Payload: chunk} + _ = s.api.WriteEvent(evt) + } + + s.micTS += len(chunk) / 2 +} + +func (s *satellite) playAudio(codec *core.Codec, rd io.Reader) bool { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + prod := pcm.OpenSync(codec, rd) + prod.OnClose(cancel) + + if err := s.srv.SndHandler(prod); err != nil { + return false + } else { + <-ctx.Done() + return true + } +} + +type micConsumer struct { + core.Connection + onData func(chunk []byte) + onClose func() +} + +func newMicConsumer(onData func(chunk []byte)) *micConsumer { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: pcm.ConsumerCodecs(), + }, + } + + return &micConsumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wyoming", + Protocol: "tcp", + Medias: medias, + }, + onData: onData, + } +} + +func (c *micConsumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + src := track.Codec + dst := &core.Codec{ + Name: core.CodecPCML, + ClockRate: 16000, + Channels: 1, + } + sender := core.NewSender(media, dst) + sender.Handler = pcm.TranscodeHandler(dst, src, + repack(func(packet *core.Packet) { + c.onData(packet.Payload) + }), + ) + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *micConsumer) Stop() error { + if c.onClose != nil { + c.onClose() + } + return c.Connection.Stop() +} + +func repack(handler core.HandlerFunc) core.HandlerFunc { + const PacketSize = 2 * 16000 / 50 // 20ms + + var buf []byte + + return func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + + for len(buf) >= PacketSize { + pkt = &core.Packet{Payload: buf[:PacketSize]} + buf = buf[PacketSize:] + handler(pkt) + } + } +} diff --git a/pkg/wyoming/snd.go b/pkg/wyoming/snd.go new file mode 100644 index 00000000..e26ca7ea --- /dev/null +++ b/pkg/wyoming/snd.go @@ -0,0 +1,40 @@ +package wyoming + +import ( + "bytes" + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" +) + +func (s *Server) HandleSnd(conn net.Conn) { + defer conn.Close() + + var snd []byte + + api := NewAPI(conn) + for { + evt, err := api.ReadEvent() + if err != nil { + return + } + + s.Trace("event: %s data: %s payload: %d", evt.Type, evt.Data, len(evt.Payload)) + + switch evt.Type { + case "audio-start": + snd = snd[:0] + case "audio-chunk": + snd = append(snd, evt.Payload...) + case "audio-stop": + prod := pcm.OpenSync(sndCodec, bytes.NewReader(snd)) + if err = s.SndHandler(prod); err != nil { + s.Error("snd error: %s", err) + return + } + } + } +} + +var sndCodec = &core.Codec{Name: core.CodecPCML, ClockRate: 22050} diff --git a/pkg/wyoming/wakeword.go b/pkg/wyoming/wakeword.go new file mode 100644 index 00000000..4c728f20 --- /dev/null +++ b/pkg/wyoming/wakeword.go @@ -0,0 +1,120 @@ +package wyoming + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type WakeWord struct { + *API + names []string + send int + + Detection string +} + +func DialWakeWord(rawURL string) (*WakeWord, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + api, err := DialAPI(u.Host) + if err != nil { + return nil, err + } + + names := u.Query()["name"] + if len(names) == 0 { + names = []string{"ok_nabu_v0.1"} + } + + wake := &WakeWord{API: api, names: names} + if err = wake.Start(); err != nil { + _ = wake.Close() + return nil, err + } + + go wake.handle() + return wake, nil +} + +func (w *WakeWord) handle() { + defer w.Close() + + for { + evt, err := w.ReadEvent() + if err != nil { + return + } + + if evt.Type == "detection" { + var data struct { + Name string `json:"name"` + } + if err = json.Unmarshal([]byte(evt.Data), &data); err != nil { + return + } + w.Detection = data.Name + } + } +} + +//func (w *WakeWord) Describe() error { +// if err := w.WriteEvent(&Event{Type: "describe"}); err != nil { +// return err +// } +// +// evt, err := w.ReadEvent() +// if err != nil { +// return err +// } +// +// var info struct { +// Wake []struct { +// Models []struct { +// Name string `json:"name"` +// } `json:"models"` +// } `json:"wake"` +// } +// if err = json.Unmarshal(evt.Data, &info); err != nil { +// return err +// } +// +// return nil +//} + +func (w *WakeWord) Start() error { + msg := struct { + Names []string `json:"names"` + }{ + Names: w.names, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + evt := &Event{Type: "detect", Data: string(data)} + if err := w.WriteEvent(evt); err != nil { + return err + } + + evt = &Event{Type: "audio-start", Data: audioData(0)} + return w.WriteEvent(evt) +} + +func (w *WakeWord) Close() error { + return w.conn.Close() +} + +func (w *WakeWord) WriteChunk(payload []byte) error { + evt := &Event{Type: "audio-chunk", Data: audioData(w.send), Payload: payload} + w.send += len(payload) + return w.WriteEvent(evt) +} + +func audioData(send int) string { + // timestamp in ms = send / 2 * 1000 / 16000 = send / 32 + return fmt.Sprintf(`{"rate":16000,"width":2,"channels":1,"timestamp":%d}`, send/32) +} diff --git a/pkg/wyoming/wyoming.go b/pkg/wyoming/wyoming.go new file mode 100644 index 00000000..0c8eebae --- /dev/null +++ b/pkg/wyoming/wyoming.go @@ -0,0 +1,26 @@ +package wyoming + +import ( + "net" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Dial(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + if u.Query().Get("backchannel") != "1" { + return newProducer(conn), nil + } else { + return newBackchannel(conn), nil + } +} diff --git a/pkg/yandex/session.go b/pkg/yandex/session.go new file mode 100644 index 00000000..bd0e3a2b --- /dev/null +++ b/pkg/yandex/session.go @@ -0,0 +1,203 @@ +package yandex + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Session struct { + token string + client *http.Client +} + +var sessions = map[string]*Session{} +var sessionsMu sync.Mutex + +func GetSession(token string) (*Session, error) { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + if session, ok := sessions[token]; ok { + return session, nil + } + + session := &Session{token: token} + if err := session.Login(); err != nil { + return nil, err + } + + sessions[token] = session + + return session, nil +} + +func (s *Session) Login() error { + req, err := http.NewRequest( + "POST", "https://mobileproxy.passport.yandex.net/1/bundle/auth/x_token/", + strings.NewReader("type=x-token&retpath=https%3A%2F%2Fwww.yandex.ru"), + ) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Ya-Consumer-Authorization", "OAuth "+s.token) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + var auth struct { + PassportHost string `json:"passport_host"` + Status string `json:"status"` + TrackId string `json:"track_id"` + } + if err = json.NewDecoder(res.Body).Decode(&auth); err != nil { + return err + } + + if auth.Status != "ok" { + return errors.New("yandex: login error: " + auth.Status) + } + + s.client = &http.Client{Timeout: 15 * time.Second} + s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + s.client.Jar, _ = cookiejar.New(nil) + + res, err = s.client.Get(auth.PassportHost + "/auth/session/?track_id=" + auth.TrackId) + if err != nil { + return err + } + + s.client.CheckRedirect = nil + + return nil +} + +func (s *Session) Get(url string) (*http.Response, error) { + return s.client.Get(url) +} + +func (s *Session) GetCSRF() (string, error) { + res, err := s.Get("https://yandex.ru/quasar") + if err != nil { + return "", err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + token := core.Between(string(body), `"csrfToken2":"`, `"`) + return token, nil +} + +func (s *Session) GetCookieString(url string) string { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + for _, cookie := range s.client.Jar.Cookies(req.URL) { + req.AddCookie(cookie) + } + return req.Header.Get("Cookie") +} + +func (s *Session) GetDevices() ([]Device, error) { + res, err := s.Get("https://iot.quasar.yandex.ru/m/v3/user/devices") + if err != nil { + return nil, err + } + + var data struct { + Households []struct { + All []Device `json:"all"` + } `json:"households"` + } + + if err = json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + var devices []Device + for _, household := range data.Households { + devices = append(devices, household.All...) + } + return devices, nil +} + +func (s *Session) GetSnapshotURL(deviceID string) (string, error) { + devices, err := s.GetDevices() + if err != nil { + return "", err + } + + for _, device := range devices { + if device.Id == deviceID { + return device.Parameters.SnapshotUrl, nil + } + } + + return "", errors.New("yandex: can't get snapshot url for device: " + deviceID) +} + +func (s *Session) WebrtcCreateRoom(deviceID string) (*Room, error) { + csrf, err := s.GetCSRF() + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "POST", "https://iot.quasar.yandex.ru/m/v3/user/devices/"+deviceID+"/webrtc/create-room", + strings.NewReader(`{"protocol":"whip"}`), + ) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-CSRF-Token", csrf) + + res, err := s.client.Do(req) + if err != nil { + return nil, err + } + + var data struct { + Result Room `json:"result"` + } + if err = json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + return &data.Result, nil +} + +type Device struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Parameters struct { + SnapshotUrl string `json:"snapshot_url,omitempty"` + } `json:"parameters"` +} + +type Room struct { + ServiceUrl string `json:"service_url"` + ServiceName string `json:"service_name"` + RoomId string `json:"room_id"` + ParticipantId string `json:"participant_id"` + Credentials string `json:"jwt"` +} diff --git a/scripts/README.md b/scripts/README.md index 3832475c..5594915d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ ## Versions +**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. Everything described below is not relevant. + [Go 1.20](https://go.dev/doc/go1.20) is last version with support Windows 7 and macOS 10.13. Go 1.21 support only Windows 10 and macOS 10.15. @@ -7,8 +9,12 @@ So we will set `go 1.20` (minimum version) inside `go.mod` file. And will use en `win32` and `mac_amd64` binaries. All other binaries will use latest go version. ``` +github.com/miekg/dns v1.1.63 golang.org/x/crypto v0.33.0 golang.org/x/mod v0.20.0 // indirect +golang.org/x/net v0.35.0 // indirect +golang.org/x/sync v0.11.0 // indirect +golang.org/x/sys v0.30.0 // indirect golang.org/x/tools v0.24.0 // indirect ``` diff --git a/scripts/build.cmd b/scripts/build.cmd index 85dd9531..37ccd441 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -1,18 +1,15 @@ @ECHO OFF -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=amd64 @SET FILENAME=go2rtc_win64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=windows @SET GOARCH=386 @SET FILENAME=go2rtc_win32.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=arm64 @SET FILENAME=go2rtc_win_arm64.zip @@ -21,42 +18,40 @@ go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe @SET GOOS=linux @SET GOARCH=amd64 @SET FILENAME=go2rtc_linux_amd64 -go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% @SET GOOS=linux @SET GOARCH=386 @SET FILENAME=go2rtc_linux_i386 -go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% @SET GOOS=linux @SET GOARCH=arm64 @SET FILENAME=go2rtc_linux_arm64 -go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% @SET GOOS=linux @SET GOARCH=arm @SET GOARM=7 @SET FILENAME=go2rtc_linux_arm -go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% @SET GOOS=linux @SET GOARCH=arm @SET GOARM=6 @SET FILENAME=go2rtc_linux_armv6 -go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% @SET GOOS=linux @SET GOARCH=mipsle @SET FILENAME=go2rtc_linux_mipsel -go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=darwin @SET GOARCH=amd64 @SET FILENAME=go2rtc_mac_amd64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc -@SET GOTOOLCHAIN= @SET GOOS=darwin @SET GOARCH=arm64 @SET FILENAME=go2rtc_mac_arm64.zip diff --git a/website/schema.json b/website/schema.json index d5e19436..530616c6 100644 --- a/website/schema.json +++ b/website/schema.json @@ -334,7 +334,7 @@ "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", - "ffmpeg:media.mp4#video=h264#hadware#width=1920#height=1080#rotate=180#audio=copy", + "ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy", "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", "bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0", "dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0", @@ -483,4 +483,4 @@ } } } -} \ No newline at end of file +} diff --git a/www/add.html b/www/add.html index cec8ed36..53d6b3dc 100644 --- a/www/add.html +++ b/www/add.html @@ -84,6 +84,18 @@ + +
+
+
+ + +
@@ -242,25 +254,30 @@ async function handleRingAuth(ev) { ev.preventDefault(); + + const table = document.getElementById('ring-table'); + table.innerText = 'loading...'; + const query = new URLSearchParams(new FormData(ev.target)); const url = new URL('api/ring?' + query.toString(), location.href); const r = await fetch(url, {cache: 'no-cache'}); + + if (!r.ok) { + table.innerText = (await r.text()) || 'Unknown error'; + return; + } + const data = await r.json(); + table.innerText = ''; + if (data.needs_2fa) { document.getElementById('tfa-field').style.display = 'block'; document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code'; return; } - if (!r.ok) { - const table = document.getElementById('ring-table'); - table.innerText = data.error || 'Unknown error'; - return; - } - - const table = document.getElementById('ring-table'); drawTable(table, data); } @@ -341,7 +358,7 @@ - +
diff --git a/www/hls.html b/www/hls.html index db2a376f..fa0f0067 100644 --- a/www/hls.html +++ b/www/hls.html @@ -21,8 +21,7 @@