Merge branch 'master' into python3.13

This commit is contained in:
Alex X
2025-10-11 11:00:52 +03:00
committed by GitHub
56 changed files with 2601 additions and 1088 deletions
+13 -13
View File
@@ -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
@@ -181,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
@@ -198,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
@@ -236,7 +236,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=-rockchip,onlatest=true
@@ -253,14 +253,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
+199 -160
View File
@@ -8,7 +8,7 @@
[![goreport](https://goreportcard.com/badge/github.com/AlexxIT/go2rtc)](https://goreportcard.com/report/github.com/AlexxIT/go2rtc)
</h1>
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)
@@ -69,12 +69,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 +118,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 +126,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 +184,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 +201,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 +223,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 +246,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 +261,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 +276,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 +292,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 +312,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 +320,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 +331,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 +346,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 +359,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 +389,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 +412,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 +422,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 +443,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 +465,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 +500,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 +514,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 +535,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 +546,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 +581,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 +599,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 +608,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 +622,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 +641,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 +694,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 +711,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 +733,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 +760,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 +780,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 +793,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 +805,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 +836,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 +868,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 +877,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 +901,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 +926,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 +935,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 +953,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 +985,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 +1011,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 +1058,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 +1080,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 +1113,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 +1127,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 +1140,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 +1160,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 +1172,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 +1182,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 +1216,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 +1263,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 +1279,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 +1308,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 +1325,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 +1336,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 +1358,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 +1370,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 +1380,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 +1406,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 +1430,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 +1460,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).
+48
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
# 0. Prepare images
ARG PYTHON_VERSION="3.13"
ARG GO_VERSION="1.24"
ARG GO_VERSION="1.25"
# 1. Build go2rtc binary
+1 -1
View File
@@ -4,7 +4,7 @@
# 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 GO_VERSION="1.25-bookworm"
# 1. Build go2rtc binary
+1 -1
View File
@@ -2,7 +2,7 @@
# 0. Prepare images
ARG PYTHON_VERSION="3.13-slim-bookworm"
ARG GO_VERSION="1.24-bookworm"
ARG GO_VERSION="1.25-bookworm"
# 1. Build go2rtc binary
+19 -19
View File
@@ -1,49 +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.17.2
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/v4 v4.0.9
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.13
github.com/pion/sdp/v3 v3.0.11
github.com/pion/srtp/v3 v3.0.4
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.0.14
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.54.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/v3 v3.0.6 // indirect
github.com/pion/logging v0.2.3 // 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.37 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // 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
)
+36
View File
@@ -1,6 +1,8 @@
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ=
github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw=
github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -10,6 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso=
github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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=
@@ -28,16 +32,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.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/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/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/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=
@@ -46,20 +58,32 @@ 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.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/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=
@@ -84,19 +108,31 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+3 -1
View File
@@ -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})
}
}()
}
+10 -2
View File
@@ -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)
}
}
+3
View File
@@ -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)
+56
View File
@@ -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
}
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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",
+14 -10
View File
@@ -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
+55 -1
View File
@@ -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)
}
}
+58
View File
@@ -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")
}
+11 -3
View File
@@ -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)
}
}
})
}
+4
View File
@@ -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
})
}
+22
View File
@@ -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
```
+152
View File
@@ -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"`
}
+44
View File
@@ -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)
})
}
+3 -1
View File
@@ -39,11 +39,12 @@ import (
"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.9"
app.Version = "1.9.10"
// 1. Core modules: app, api/ws, streams
@@ -96,6 +97,7 @@ func main() {
alsa.Init() // alsa source
flussonic.Init()
eseecloud.Init()
yandex.Init()
// 6. Helper modules
+14
View File
@@ -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)
}
+12
View File
@@ -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, `://***@`)
}
+7
View File
@@ -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
+79
View File
@@ -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
})
}
+83
View File
@@ -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}
}
+15
View File
@@ -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)
}
+3 -1
View File
@@ -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
}
+5
View File
@@ -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)
}
+38 -12
View File
@@ -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
+13 -13
View File
@@ -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
},
},
},
+6
View File
@@ -71,11 +71,17 @@ type JSONCharacter struct {
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
}
+86 -45
View File
@@ -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
@@ -179,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
}
+47
View File
@@ -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)
}
+55 -11
View File
@@ -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("<?xml")); i > 0 {
return rawRes[i:], nil
}
// No XML found - might be an error response
if i := bytes.Index(rawRes, []byte("\r\n\r\n")); i > 0 {
if bytes.Contains(rawRes[:i], []byte("chunked")) {
return nil, errors.New("onvif: TODO: support chunked encoding")
}
// Return body after headers
return rawRes[i+4:], nil
}
return rawRes, nil
}
@@ -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
}
+365 -208
View File
@@ -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"))
+122 -308
View File
@@ -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/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)
}
+6 -7
View File
@@ -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
}
+265
View File
@@ -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()
}
}
+128 -27
View File
@@ -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
@@ -218,15 +230,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 +310,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 +377,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")
}
+186 -139
View File
@@ -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
+23 -4
View File
@@ -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
}
+23 -4
View File
@@ -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
}
+8 -2
View File
@@ -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) {
-35
View File
@@ -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)
+10 -3
View File
@@ -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)
+2 -1
View File
@@ -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()
}
+203
View File
@@ -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"`
}
+2
View File
@@ -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.
-5
View File
@@ -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
@@ -50,13 +47,11 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME
@SET FILENAME=go2rtc_linux_mipsel
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
+2 -2
View File
@@ -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 @@
}
}
}
}
}
+12 -7
View File
@@ -254,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);
}
+19 -15
View File
@@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement {
/** @param {Function} isSupported */
codecs(isSupported) {
return this.CODECS
.filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
.filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio'))
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
}
@@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement {
const modes = [];
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
modes.push('mse');
this.onmse();
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
} else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) {
modes.push('hls');
this.onhls();
} else if (this.mode.indexOf('mp4') >= 0) {
} else if (this.mode.includes('mp4')) {
modes.push('mp4');
this.onmp4();
}
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
modes.push('webrtc');
this.onwebrtc();
}
if (this.mode.indexOf('mjpeg') >= 0) {
if (this.mode.includes('mjpeg')) {
if (modes.length) {
this.onmessage['mjpeg'] = msg => {
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
@@ -490,7 +490,7 @@ export class VideoRTC extends HTMLElement {
const pc = new RTCPeerConnection(this.pcConfig);
pc.addEventListener('icecandidate', ev => {
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return;
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
this.send({type: 'webrtc/candidate', value: candidate});
@@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement {
this.onmessage['webrtc'] = msg => {
switch (msg.type) {
case 'webrtc/candidate':
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return;
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
console.warn(er);
@@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement {
});
break;
case 'error':
if (msg.value.indexOf('webrtc/offer') < 0) return;
if (!msg.value.includes('webrtc/offer')) return;
pc.close();
}
};
@@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement {
*/
async createOffer(pc) {
try {
if (this.media.indexOf('microphone') >= 0) {
if (this.media.includes('microphone')) {
const media = await navigator.mediaDevices.getUserMedia({audio: true});
media.getTracks().forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'});
@@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement {
}
for (const kind of ['video', 'audio']) {
if (this.media.indexOf(kind) >= 0) {
if (this.media.includes(kind)) {
pc.addTransceiver(kind, {direction: 'recvonly'});
}
}
@@ -580,12 +580,16 @@ export class VideoRTC extends HTMLElement {
/** @type {MediaStream} */
const stream = video2.srcObject;
if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
if (stream.getVideoTracks().length > 0) {
// not the best, but a pretty simple way to check a codec
const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000');
rtcPriority += isH265Supported ? 0x240 : 0x220;
}
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
if (rtcPriority >= msePriority) {
this.video.srcObject = stream;