Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa580c516e | |||
| 7f4c450553 | |||
| 761ff7ed5a | |||
| 117d767f05 | |||
| 8405bfe6f9 | |||
| ccdb1479f7 | |||
| c8f68f44af | |||
| 3954a555f8 | |||
| b6934922fa | |||
| 944e6f5569 | |||
| d51b36e80d | |||
| c9724e2024 | |||
| 830e476120 | |||
| fe2e372997 | |||
| a15deedf0d | |||
| 22bf8163cd | |||
| b8390331af | |||
| 47b740ff35 | |||
| 39c14e6556 | |||
| 57cd791348 | |||
| 3c612e284e | |||
| 8cd1ab5c8f | |||
| a6c22cadb8 | |||
| a628ecf72b | |||
| 8d70233d83 | |||
| ae89600201 | |||
| 934d43b525 | |||
| 858c04bacf | |||
| 2a5355b1f8 | |||
| 5cf2ac4c3e | |||
| 71173da5ad | |||
| e304f4f34f | |||
| 7d37f645ba | |||
| c50738005d | |||
| effff6f88d | |||
| 45b223a2ef | |||
| 90544ba713 | |||
| e55c2e9598 | |||
| 6ee52474e1 | |||
| 4bf9f0b96c | |||
| 79e2fa89df | |||
| 2ad0ded73f | |||
| 1ab05e5c3b | |||
| 4b4a1644ff | |||
| 7fd0ec8ce6 | |||
| 2d1e08b50e | |||
| b881c52118 | |||
| 6fb59949a2 | |||
| 7d41dc21c1 | |||
| 6365968dc3 | |||
| 1abb3c8c22 | |||
| 33f4bb45d1 | |||
| 4897994b35 | |||
| 0a773c82af | |||
| b34d970076 | |||
| 19cf781431 | |||
| 637e65e5a0 | |||
| b3f83fd363 | |||
| 02ac3a6814 | |||
| 97891d36ab | |||
| 65c87d5e0f | |||
| ae3b53540e | |||
| 0e9009b0de | |||
| be2864c34b | |||
| c9bdac2e03 | |||
| 040de3d973 | |||
| 1703380ebc | |||
| e935885cd3 | |||
| da809bb9d7 | |||
| c39c9aa1da | |||
| ad61662cc4 | |||
| e42bcd0115 | |||
| ad8c025393 | |||
| 1b0db3c8b0 | |||
| f9a8c1969c | |||
| 645c11f0bd | |||
| ece49a158e | |||
| b139b8fdd6 | |||
| b14aa4f0dc | |||
| 9b392a22e1 | |||
| 36547a7343 | |||
| 876390aa68 | |||
| 297ecfbae3 | |||
| eeb0012e7f | |||
| 35cf82f11c | |||
| 82f6c2c550 | |||
| 3e3988a67f | |||
| 2f4694dc95 | |||
| f072dab07b | |||
| fc02e6f4a5 | |||
| 0651a09a3c | |||
| 2c5f1e0417 | |||
| c9682ca64d | |||
| bceb024588 | |||
| 17bba4d4a2 | |||
| 485448cbc7 | |||
| 244dad447b | |||
| 22e63a7367 | |||
| 83907132b5 | |||
| 7dc9beb171 | |||
| 0664e46a4b | |||
| 2ca97a42c5 | |||
| 773e415dff | |||
| 9e673559c4 | |||
| 879ef603fe | |||
| 7e0a163f12 | |||
| 8e4088e08f | |||
| 59161c663b | |||
| 93252fc5d2 | |||
| e4b8d1807d | |||
| 33e0ccdd10 | |||
| d59139a2ab | |||
| df831833b1 | |||
| c065db6da1 | |||
| a55be809f3 | |||
| a9e1ebc0a8 | |||
| 55af09a350 | |||
| 199fdd6728 | |||
| 4035e91672 | |||
| bc9194d740 | |||
| f601c47218 | |||
| 066d559377 | |||
| 2c3219ffcb | |||
| cf88bf9c23 | |||
| b8303b9a22 | |||
| a3f084dcde | |||
| 0d6b8fc6fc | |||
| 261a936bb8 | |||
| 159d9425a7 | |||
| 3a50b3678d | |||
| 6fa352f407 | |||
| 4b80b2c233 | |||
| d881755503 | |||
| fd125ecc68 | |||
| 29f7f1a57d | |||
| 8ecaabfce9 | |||
| 1797ff67c0 | |||
| f1ba5e95ec | |||
| d8c0f9d1d9 | |||
| df4b5fc87d | |||
| d7cdc8b3b0 | |||
| 5b53ca7cf1 | |||
| 194d1dae51 | |||
| 61322ede6c | |||
| a8edaedc8b | |||
| 25145f72e5 | |||
| 3ddf8b5922 | |||
| 223f94077f | |||
| f13aa21d0f | |||
| 23e8f7e0aa | |||
| c81caa4d2c | |||
| 13dd3084c2 | |||
| e1021a96af | |||
| 5b0781253f | |||
| a04b7eed28 | |||
| c47427633c | |||
| 56e2c6650d | |||
| 82f0fb8a79 | |||
| 0e5b293b1f | |||
| ac798d9d6d | |||
| e46fc13fea | |||
| bce0b4a8a0 | |||
| 562046c278 | |||
| 4cc28977cb | |||
| 3ce4624aee | |||
| 2b8ced9c59 |
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.24' }
|
||||
|
||||
- name: Build go2rtc_win64
|
||||
env: { GOOS: windows, GOARCH: amd64 }
|
||||
@@ -102,14 +102,14 @@ jobs:
|
||||
env: { GOOS: freebsd, GOARCH: amd64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_freebsd_amd64
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with: { name: go2rtc_freebsd_amd64, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_freebsd_arm64
|
||||
env: { GOOS: freebsd, GOARCH: arm64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_freebsd_arm64
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with: { name: go2rtc_freebsd_arm64, path: go2rtc }
|
||||
|
||||
docker-master:
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
go2rtc.yaml
|
||||
go2rtc.json
|
||||
|
||||
go2rtc_freebsd*
|
||||
go2rtc_linux*
|
||||
go2rtc_mac*
|
||||
go2rtc_win*
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.22"
|
||||
ARG GO_VERSION="1.24"
|
||||
|
||||
|
||||
# 1. Download ngrok binary (for support arm/v6)
|
||||
@@ -42,7 +42,7 @@ FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
# and other common tools for the echo source.
|
||||
# alsa-plugins-pulse for ALSA support (+0MB)
|
||||
# font-droid for FFmpeg drawtext filter (+2MB)
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse font-droid
|
||||
RUN apk add --no-cache tini ffmpeg ffplay bash curl jq alsa-plugins-pulse font-droid
|
||||
|
||||
# Hardware Acceleration for Intel CPU (+50MB)
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -126,6 +126,8 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
- `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_arm64.zip` - macOS ARM 64-bit
|
||||
- `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit
|
||||
- `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit
|
||||
|
||||
Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
|
||||
@@ -231,7 +233,7 @@ streams:
|
||||
sonoff_camera: rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0
|
||||
dahua_camera:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1#backchannel=0
|
||||
amcrest_doorbell:
|
||||
- rtsp://username:password@192.168.1.123:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
|
||||
unifi_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
|
||||
@@ -241,7 +243,7 @@ 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
|
||||
- **Dahua Doorbell** users may want to change backchannel [audio codec](https://github.com/AlexxIT/go2rtc/issues/52)
|
||||
- **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
|
||||
- **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)
|
||||
@@ -350,7 +352,7 @@ streams:
|
||||
mjpeg: ffmpeg:http://185.97.122.128/cgi-bin/faststream.jpg#video=h264
|
||||
|
||||
# [RTSP] video with rotation, should be transcoded, so select H264
|
||||
rotate: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90
|
||||
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`.
|
||||
@@ -680,13 +682,18 @@ Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC proto
|
||||
|
||||
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).
|
||||
|
||||
**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.
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
webrtc-whep: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1
|
||||
webrtc-go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1
|
||||
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-whep: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1
|
||||
webrtc-go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1
|
||||
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=[{...},{...}]
|
||||
```
|
||||
|
||||
**PS.** For `kinesis` sources you can use [echo](#source-echo) to get connection params using `bash`/`python` or any other script language.
|
||||
@@ -881,7 +888,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 differnt problems with this format.
|
||||
[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.
|
||||
|
||||
```yaml
|
||||
rtmp:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/v4l2"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init()
|
||||
streams.Init()
|
||||
|
||||
api.Init()
|
||||
ws.Init()
|
||||
|
||||
ffmpeg.Init()
|
||||
mjpeg.Init()
|
||||
v4l2.Init()
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
## Example
|
||||
|
||||
```shell
|
||||
go run examples/onvif_client/main.go http://admin:password@192.168.10.90 GetAudioEncoderConfigurations
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/onvif"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var rawURL = os.Args[1]
|
||||
var operation = os.Args[2]
|
||||
var token string
|
||||
if len(os.Args) > 3 {
|
||||
token = os.Args[3]
|
||||
}
|
||||
|
||||
client, err := onvif.NewClient(rawURL)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
var b []byte
|
||||
|
||||
switch operation {
|
||||
case onvif.ServiceGetServiceCapabilities:
|
||||
b, err = client.MediaRequest(operation)
|
||||
case onvif.DeviceGetCapabilities,
|
||||
onvif.DeviceGetDeviceInformation,
|
||||
onvif.DeviceGetDiscoveryMode,
|
||||
onvif.DeviceGetDNS,
|
||||
onvif.DeviceGetHostname,
|
||||
onvif.DeviceGetNetworkDefaultGateway,
|
||||
onvif.DeviceGetNetworkInterfaces,
|
||||
onvif.DeviceGetNetworkProtocols,
|
||||
onvif.DeviceGetNTP,
|
||||
onvif.DeviceGetScopes,
|
||||
onvif.DeviceGetServices,
|
||||
onvif.DeviceGetSystemDateAndTime,
|
||||
onvif.DeviceSystemReboot:
|
||||
b, err = client.DeviceRequest(operation)
|
||||
case onvif.MediaGetProfiles,
|
||||
onvif.MediaGetVideoEncoderConfigurations,
|
||||
onvif.MediaGetVideoSources,
|
||||
onvif.MediaGetVideoSourceConfigurations,
|
||||
onvif.MediaGetAudioEncoderConfigurations,
|
||||
onvif.MediaGetAudioSources,
|
||||
onvif.MediaGetAudioSourceConfigurations:
|
||||
b, err = client.MediaRequest(operation)
|
||||
case onvif.MediaGetProfile:
|
||||
b, err = client.GetProfile(token)
|
||||
case onvif.MediaGetVideoSourceConfiguration:
|
||||
b, err = client.GetVideoSourceConfiguration(token)
|
||||
case onvif.MediaGetStreamUri:
|
||||
b, err = client.GetStreamUri(token)
|
||||
case onvif.MediaGetSnapshotUri:
|
||||
b, err = client.GetSnapshotUri(token)
|
||||
default:
|
||||
log.Printf("unknown action\n")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("%s\n", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err = os.WriteFile(u.Hostname()+"_"+operation+".xml", b, 0644); err != nil {
|
||||
log.Printf("%s\n", err)
|
||||
}
|
||||
}
|
||||
@@ -5,45 +5,46 @@ go 1.20
|
||||
require (
|
||||
github.com/asticode/go-astits v1.13.0
|
||||
github.com/expr-lang/expr v1.16.9
|
||||
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.62
|
||||
github.com/pion/ice/v2 v2.3.36
|
||||
github.com/miekg/dns v1.1.63
|
||||
github.com/pion/ice/v2 v2.3.37
|
||||
github.com/pion/interceptor v0.1.37
|
||||
github.com/pion/rtcp v1.2.14
|
||||
github.com/pion/rtp v1.8.9
|
||||
github.com/pion/sdp/v3 v3.0.9
|
||||
github.com/pion/rtcp v1.2.15
|
||||
github.com/pion/rtp v1.8.11
|
||||
github.com/pion/sdp/v3 v3.0.10
|
||||
github.com/pion/srtp/v2 v2.0.20
|
||||
github.com/pion/stun v0.6.1
|
||||
github.com/pion/webrtc/v3 v3.3.4
|
||||
github.com/pion/webrtc/v3 v3.3.5
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astikit v0.45.0 // indirect
|
||||
github.com/asticode/go-astikit v0.52.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/pion/datachannel v1.5.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.33 // indirect
|
||||
github.com/pion/sctp v1.8.36 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // 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
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.45.0 h1:08to/jrbod9tchF2bJ9moW+RTDK7DBUxLdIeSE7v7Sw=
|
||||
github.com/asticode/go-astikit v0.45.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.52.0 h1:kTl2XjgiVQhUl1H7kim7NhmTtCMwVBbPrXKqhQhbk8Y=
|
||||
github.com/asticode/go-astikit v0.52.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
github.com/asticode/go-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=
|
||||
@@ -23,39 +23,41 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
|
||||
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
|
||||
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
|
||||
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/ice/v2 v2.3.37 h1:ObIdaNDu1rCo7hObhs34YSBcO7fjslJMZV0ux+uZWh0=
|
||||
github.com/pion/ice/v2 v2.3.37/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
|
||||
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
|
||||
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
|
||||
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
|
||||
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||
github.com/pion/rtp v1.8.11 h1:17xjnY5WO5hgO6SD3/NTIUPvSFw/PbLsIJyz1r1yNIk=
|
||||
github.com/pion/rtp v1.8.11/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||
github.com/pion/sctp v1.8.36 h1:owNudmnz1xmhfYje5L/FCav3V9wpPRePHle3Zi+P+M0=
|
||||
github.com/pion/sctp v1.8.36/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA=
|
||||
github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
@@ -67,11 +69,12 @@ github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQp
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.4 h1:v2heQVnXTSqNRXcaFQVOhIOYkLMxOu1iJG8uy1djvkk=
|
||||
github.com/pion/webrtc/v3 v3.3.4/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
||||
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
|
||||
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
||||
github.com/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=
|
||||
@@ -89,14 +92,13 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDf
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
@@ -108,12 +110,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
|
||||
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -122,13 +124,13 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -143,8 +145,8 @@ golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -163,8 +165,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
|
||||
+1
-1
@@ -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.22-bookworm"
|
||||
ARG GO_VERSION="1.24-bookworm"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
+2
-4
@@ -69,6 +69,8 @@ func Init() {
|
||||
}
|
||||
|
||||
if cfg.Mod.Listen != "" {
|
||||
_, port, _ := net.SplitHostPort(cfg.Mod.Listen)
|
||||
Port, _ = strconv.Atoi(port)
|
||||
go listen("tcp", cfg.Mod.Listen)
|
||||
}
|
||||
|
||||
@@ -92,10 +94,6 @@ func listen(network, address string) {
|
||||
|
||||
log.Info().Str("addr", address).Msg("[api] listen")
|
||||
|
||||
if network == "tcp" {
|
||||
Port = ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
server := http.Server{
|
||||
Handler: Handler,
|
||||
ReadHeaderTimeout: 5 * time.Second, // Example: Set to 5 seconds
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/www"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/www"
|
||||
)
|
||||
|
||||
func initStatic(staticDir string) {
|
||||
|
||||
@@ -18,7 +18,7 @@ func LoadConfig(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
func PatchConfig(key string, value any, path ...string) error {
|
||||
func PatchConfig(path []string, value any) error {
|
||||
if ConfigPath == "" {
|
||||
return errors.New("config file disabled")
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func PatchConfig(key string, value any, path ...string) error {
|
||||
// empty config is OK
|
||||
b, _ := os.ReadFile(ConfigPath)
|
||||
|
||||
b, err := yaml.Patch(b, key, value, path...)
|
||||
b, err := yaml.Patch(b, path, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+33
-15
@@ -3,12 +3,14 @@ package app
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var MemoryLog = newBuffer(16)
|
||||
var MemoryLog = newBuffer()
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
if s, ok := modules[module]; ok {
|
||||
@@ -38,11 +40,17 @@ func initLogger() {
|
||||
|
||||
var writer io.Writer
|
||||
|
||||
switch modules["output"] {
|
||||
switch output, path, _ := strings.Cut(modules["output"], ":"); output {
|
||||
case "stderr":
|
||||
writer = os.Stderr
|
||||
case "stdout":
|
||||
writer = os.Stdout
|
||||
case "file":
|
||||
if path == "" {
|
||||
path = "go2rtc.log"
|
||||
}
|
||||
// if fail - only MemoryLog will be available
|
||||
writer, _ = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
}
|
||||
|
||||
timeFormat := modules["time"]
|
||||
@@ -99,15 +107,19 @@ var modules = map[string]string{
|
||||
"time": zerolog.TimeFormatUnixMs,
|
||||
}
|
||||
|
||||
const chunkSize = 1 << 16
|
||||
const (
|
||||
chunkCount = 16
|
||||
chunkSize = 1 << 16
|
||||
)
|
||||
|
||||
type circularBuffer struct {
|
||||
chunks [][]byte
|
||||
r, w int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newBuffer(chunks int) *circularBuffer {
|
||||
b := &circularBuffer{chunks: make([][]byte, 0, chunks)}
|
||||
func newBuffer() *circularBuffer {
|
||||
b := &circularBuffer{chunks: make([][]byte, 0, chunkCount)}
|
||||
// create first chunk
|
||||
b.chunks = append(b.chunks, make([]byte, 0, chunkSize))
|
||||
return b
|
||||
@@ -116,16 +128,17 @@ func newBuffer(chunks int) *circularBuffer {
|
||||
func (b *circularBuffer) Write(p []byte) (n int, err error) {
|
||||
n = len(p)
|
||||
|
||||
b.mu.Lock()
|
||||
// check if chunk has size
|
||||
if len(b.chunks[b.w])+n > chunkSize {
|
||||
// increase write chunk index
|
||||
if b.w++; b.w == cap(b.chunks) {
|
||||
if b.w++; b.w == chunkCount {
|
||||
b.w = 0
|
||||
}
|
||||
// check overflow
|
||||
if b.r == b.w {
|
||||
// increase read chunk index
|
||||
if b.r++; b.r == cap(b.chunks) {
|
||||
if b.r++; b.r == chunkCount {
|
||||
b.r = 0
|
||||
}
|
||||
}
|
||||
@@ -140,29 +153,34 @@ func (b *circularBuffer) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
b.chunks[b.w] = append(b.chunks[b.w], p...)
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (b *circularBuffer) WriteTo(w io.Writer) (n int64, err error) {
|
||||
for i := b.r; ; {
|
||||
var nn int
|
||||
if nn, err = w.Write(b.chunks[i]); err != nil {
|
||||
return
|
||||
}
|
||||
n += int64(nn)
|
||||
buf := make([]byte, 0, chunkCount*chunkSize)
|
||||
|
||||
// use temp buffer inside mutex because w.Write can take some time
|
||||
b.mu.Lock()
|
||||
for i := b.r; ; {
|
||||
buf = append(buf, b.chunks[i]...)
|
||||
if i == b.w {
|
||||
break
|
||||
}
|
||||
if i++; i == cap(b.chunks) {
|
||||
if i++; i == chunkCount {
|
||||
i = 0
|
||||
}
|
||||
}
|
||||
return
|
||||
b.mu.Unlock()
|
||||
|
||||
nn, err := w.Write(buf)
|
||||
return int64(nn), err
|
||||
}
|
||||
|
||||
func (b *circularBuffer) Reset() {
|
||||
b.mu.Lock()
|
||||
b.chunks[0] = b.chunks[0][:0]
|
||||
b.r = 0
|
||||
b.w = 0
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package doorbird
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/doorbird"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.RedirectFunc("doorbird", func(rawURL string) (string, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// https://www.doorbird.com/downloads/api_lan.pdf
|
||||
switch u.Query().Get("media") {
|
||||
case "video":
|
||||
u.Path = "/bha-api/video.cgi"
|
||||
case "audio":
|
||||
u.Path = "/bha-api/audio-receive.cgi"
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
u.Scheme = "http"
|
||||
|
||||
return u.String(), nil
|
||||
})
|
||||
|
||||
streams.HandleFunc("doorbird", func(source string) (core.Producer, error) {
|
||||
return doorbird.Dial(source)
|
||||
})
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// closer support custom killsignal with custom killtimeout
|
||||
type closer struct {
|
||||
cmd *exec.Cmd
|
||||
query url.Values
|
||||
}
|
||||
|
||||
func (c *closer) Close() (err error) {
|
||||
sig := os.Kill
|
||||
if s := c.query.Get("killsignal"); s != "" {
|
||||
sig = syscall.Signal(core.Atoi(s))
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[exec] kill with signal=%d", sig)
|
||||
err = c.cmd.Process.Signal(sig)
|
||||
|
||||
if s := c.query.Get("killtimeout"); s != "" {
|
||||
timeout := time.Duration(core.Atoi(s)) * time.Second
|
||||
timer := time.AfterFunc(timeout, func() {
|
||||
log.Trace().Msgf("[exec] kill after timeout=%s", s)
|
||||
_ = c.cmd.Process.Kill()
|
||||
})
|
||||
defer timer.Stop() // stop timer if Wait ends before timeout
|
||||
}
|
||||
|
||||
return errors.Join(err, c.cmd.Wait())
|
||||
}
|
||||
+37
-23
@@ -9,9 +9,9 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
@@ -49,7 +49,7 @@ func Init() {
|
||||
log = app.GetLogger("exec")
|
||||
}
|
||||
|
||||
func execHandle(rawURL string) (core.Producer, error) {
|
||||
func execHandle(rawURL string) (prod core.Producer, err error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
|
||||
@@ -67,39 +67,55 @@ func execHandle(rawURL string) (core.Producer, error) {
|
||||
rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:]
|
||||
}
|
||||
|
||||
args := shell.QuoteSplit(rawURL[5:]) // remove `exec:`
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd := shell.NewCommand(rawURL[5:]) // remove `exec:`
|
||||
cmd.Stderr = &logWriter{
|
||||
buf: make([]byte, 512),
|
||||
debug: log.Debug().Enabled(),
|
||||
}
|
||||
|
||||
if s := query.Get("killsignal"); s != "" {
|
||||
sig := syscall.Signal(core.Atoi(s))
|
||||
cmd.Cancel = func() error {
|
||||
log.Debug().Msgf("[exec] kill with signal=%d", sig)
|
||||
return cmd.Process.Signal(sig)
|
||||
}
|
||||
}
|
||||
|
||||
if s := query.Get("killtimeout"); s != "" {
|
||||
cmd.WaitDelay = time.Duration(core.Atoi(s)) * time.Second
|
||||
}
|
||||
|
||||
if query.Get("backchannel") == "1" {
|
||||
return stdin.NewClient(cmd)
|
||||
}
|
||||
|
||||
cl := &closer{cmd: cmd, query: query}
|
||||
|
||||
if path == "" {
|
||||
return handlePipe(rawURL, cmd, cl)
|
||||
prod, err = handlePipe(rawURL, cmd)
|
||||
} else {
|
||||
prod, err = handleRTSP(rawURL, cmd, path)
|
||||
}
|
||||
|
||||
return handleRTSP(rawURL, cmd, cl, path)
|
||||
if err != nil {
|
||||
_ = cmd.Close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, error) {
|
||||
func handlePipe(source string, cmd *shell.Command) (core.Producer, error) {
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc := struct {
|
||||
rd := struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
// add buffer for pipe reader to reduce syscall
|
||||
bufio.NewReaderSize(stdout, core.BufferSize),
|
||||
cl,
|
||||
// stop cmd on close pipe call
|
||||
cmd,
|
||||
}
|
||||
|
||||
log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe")
|
||||
@@ -110,9 +126,8 @@ func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prod, err := magic.Open(rc)
|
||||
prod, err := magic.Open(rd)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr)
|
||||
}
|
||||
|
||||
@@ -126,7 +141,7 @@ func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, erro
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, path string) (core.Producer, error) {
|
||||
func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, error) {
|
||||
if log.Trace().Enabled() {
|
||||
cmd.Stdout = os.Stdout
|
||||
}
|
||||
@@ -152,23 +167,22 @@ func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, path string) (core.P
|
||||
return nil, err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
timeout := time.NewTimer(30 * time.Second)
|
||||
defer timeout.Stop()
|
||||
|
||||
select {
|
||||
case <-time.After(time.Minute):
|
||||
case <-timeout.C:
|
||||
// haven't received data from app in timeout
|
||||
log.Error().Str("source", source).Msg("[exec] timeout")
|
||||
_ = cl.Close()
|
||||
return nil, errors.New("exec: timeout")
|
||||
case <-done:
|
||||
// limit message size
|
||||
case <-cmd.Done():
|
||||
// app fail before we receive any data
|
||||
return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr)
|
||||
case prod := <-waiter:
|
||||
// app started successfully
|
||||
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run rtsp")
|
||||
setRemoteInfo(prod, source, cmd.Args)
|
||||
prod.OnClose = cl.Close
|
||||
prod.OnClose = cmd.Close
|
||||
return prod, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func apiPair(id, url string) error {
|
||||
|
||||
streams.New(id, conn.URL())
|
||||
|
||||
return app.PatchConfig(id, conn.URL(), "streams")
|
||||
return app.PatchConfig([]string{"streams", id}, conn.URL())
|
||||
}
|
||||
|
||||
func apiUnpair(id string) error {
|
||||
@@ -112,7 +112,7 @@ func apiUnpair(id string) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
rawURL := findHomeKitURL(stream)
|
||||
rawURL := findHomeKitURL(stream.Sources())
|
||||
if rawURL == "" {
|
||||
return errors.New("not homekit source")
|
||||
}
|
||||
@@ -123,15 +123,15 @@ func apiUnpair(id string) error {
|
||||
|
||||
streams.Delete(id)
|
||||
|
||||
return app.PatchConfig(id, nil, "streams")
|
||||
return app.PatchConfig([]string{"streams", id}, nil)
|
||||
}
|
||||
|
||||
func findHomeKitURLs() map[string]*url.URL {
|
||||
urls := map[string]*url.URL{}
|
||||
for id, stream := range streams.Streams() {
|
||||
if rawURL := findHomeKitURL(stream); rawURL != "" {
|
||||
for name, sources := range streams.GetAllSources() {
|
||||
if rawURL := findHomeKitURL(sources); rawURL != "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
urls[id] = u
|
||||
urls[name] = u
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-24
@@ -79,7 +79,7 @@ func Init() {
|
||||
Handler: homekit.ServerHandler(srv),
|
||||
}
|
||||
|
||||
if url := findHomeKitURL(stream); url != "" {
|
||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||
// 1. Act as transparent proxy for HomeKit camera
|
||||
dial := func() (net.Conn, error) {
|
||||
client, err := homekit.Dial(url, srtp.Server)
|
||||
@@ -118,8 +118,8 @@ func Init() {
|
||||
servers[host] = srv
|
||||
}
|
||||
|
||||
api.HandleFunc(hap.PathPairSetup, hapPairSetup)
|
||||
api.HandleFunc(hap.PathPairVerify, hapPairVerify)
|
||||
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
||||
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
||||
|
||||
log.Trace().Msgf("[homekit] mdns: %s", entries)
|
||||
|
||||
@@ -148,13 +148,19 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
||||
return client, err
|
||||
}
|
||||
|
||||
func hapPairSetup(w http.ResponseWriter, r *http.Request) {
|
||||
srv, ok := servers[r.Host]
|
||||
if !ok {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
return
|
||||
func resolve(host string) *server {
|
||||
if len(servers) == 1 {
|
||||
for _, srv := range servers {
|
||||
return srv
|
||||
}
|
||||
}
|
||||
if srv, ok := servers[host]; ok {
|
||||
return srv
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -162,32 +168,29 @@ func hapPairSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = srv.hap.PairSetup(r, rw, conn); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func hapPairVerify(w http.ResponseWriter, r *http.Request) {
|
||||
srv, ok := servers[r.Host]
|
||||
if !ok {
|
||||
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
||||
// Doesn't support Home Assistant and any other open source projects
|
||||
// because they don't send the host header in requests.
|
||||
srv := resolve(r.Host)
|
||||
if srv == nil {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
_ = hap.WriteBackoff(rw)
|
||||
return
|
||||
}
|
||||
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
switch r.RequestURI {
|
||||
case hap.PathPairSetup:
|
||||
err = srv.hap.PairSetup(r, rw, conn)
|
||||
case hap.PathPairVerify:
|
||||
err = srv.hap.PairVerify(r, rw, conn)
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = srv.hap.PairVerify(r, rw, conn); err != nil && err != io.EOF {
|
||||
if err != nil && err != io.EOF {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func findHomeKitURL(stream *streams.Stream) string {
|
||||
sources := stream.Sources()
|
||||
func findHomeKitURL(sources []string) string {
|
||||
if len(sources) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ func (s *server) DelPair(conn net.Conn, id string) {
|
||||
}
|
||||
|
||||
func (s *server) PatchConfig() {
|
||||
if err := app.PatchConfig("pairings", s.pairings, "homekit", s.stream); err != nil {
|
||||
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
|
||||
log.Error().Err(err).Msgf(
|
||||
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/image"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/pcm"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
@@ -87,6 +88,9 @@ func do(req *http.Request) (core.Producer, error) {
|
||||
return image.Open(res)
|
||||
case ct == "multipart/x-mixed-replace":
|
||||
return mpjpeg.Open(res.Body)
|
||||
//https://www.iana.org/assignments/media-types/audio/basic
|
||||
case ct == "audio/basic":
|
||||
return pcm.Open(res.Body)
|
||||
}
|
||||
|
||||
return magic.Open(res.Body)
|
||||
|
||||
@@ -2,6 +2,7 @@ package nest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
@@ -38,11 +39,12 @@ func apiNest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var items []*api.Source
|
||||
|
||||
for name, deviceID := range devices {
|
||||
query.Set("device_id", deviceID)
|
||||
for _, device := range devices {
|
||||
query.Set("device_id", device.DeviceID)
|
||||
query.Set("protocols", strings.Join(device.Protocols, ","))
|
||||
|
||||
items = append(items, &api.Source{
|
||||
Name: name, URL: "nest:?" + query.Encode(),
|
||||
Name: device.Name, URL: "nest:?" + query.Encode(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# ONVIF
|
||||
|
||||
A regular camera has a single video source (`GetVideoSources`) and two profiles (`GetProfiles`).
|
||||
|
||||
Go2rtc has one video source and one profile per stream.
|
||||
|
||||
## Tested clients
|
||||
|
||||
Go2rtc works as ONVIF server:
|
||||
|
||||
- Happytime onvif client (windows)
|
||||
- Home Assistant ONVIF integration (linux)
|
||||
- Onvier (android)
|
||||
- ONVIF Device Manager (windows)
|
||||
|
||||
PS. Support only TCP transport for RTSP protocol. UDP and HTTP transports - unsupported yet.
|
||||
|
||||
## Tested cameras
|
||||
|
||||
Go2rtc works as ONVIF client:
|
||||
|
||||
- Dahua IPC-K42
|
||||
- OpenIPC
|
||||
- Reolink RLC-520A
|
||||
- TP-Link Tapo TC60
|
||||
@@ -55,49 +55,73 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
action := onvif.GetRequestAction(b)
|
||||
if action == "" {
|
||||
operation := onvif.GetRequestAction(b)
|
||||
if operation == "" {
|
||||
http.Error(w, "malformed request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[onvif] %s", action)
|
||||
log.Trace().Msgf("[onvif] server request %s %s:\n%s", r.Method, r.RequestURI, b)
|
||||
|
||||
var res string
|
||||
switch operation {
|
||||
case onvif.DeviceGetNetworkInterfaces, // important for Hass
|
||||
onvif.DeviceGetSystemDateAndTime, // important for Hass
|
||||
onvif.DeviceGetDiscoveryMode,
|
||||
onvif.DeviceGetDNS,
|
||||
onvif.DeviceGetHostname,
|
||||
onvif.DeviceGetNetworkDefaultGateway,
|
||||
onvif.DeviceGetNetworkProtocols,
|
||||
onvif.DeviceGetNTP,
|
||||
onvif.DeviceGetScopes,
|
||||
onvif.MediaGetVideoEncoderConfigurations,
|
||||
onvif.MediaGetAudioEncoderConfigurations,
|
||||
onvif.MediaGetAudioSources,
|
||||
onvif.MediaGetAudioSourceConfigurations:
|
||||
b = onvif.StaticResponse(operation)
|
||||
|
||||
switch action {
|
||||
case onvif.ActionGetCapabilities:
|
||||
case onvif.DeviceGetCapabilities:
|
||||
// important for Hass: Media section
|
||||
res = onvif.GetCapabilitiesResponse(r.Host)
|
||||
b = onvif.GetCapabilitiesResponse(r.Host)
|
||||
|
||||
case onvif.ActionGetSystemDateAndTime:
|
||||
// important for Hass
|
||||
res = onvif.GetSystemDateAndTimeResponse()
|
||||
case onvif.DeviceGetServices:
|
||||
b = onvif.GetServicesResponse(r.Host)
|
||||
|
||||
case onvif.ActionGetNetworkInterfaces:
|
||||
// important for Hass: none
|
||||
res = onvif.GetNetworkInterfacesResponse()
|
||||
|
||||
case onvif.ActionGetDeviceInformation:
|
||||
case onvif.DeviceGetDeviceInformation:
|
||||
// important for Hass: SerialNumber (unique server ID)
|
||||
res = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host)
|
||||
b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host)
|
||||
|
||||
case onvif.ActionGetServiceCapabilities:
|
||||
case onvif.ServiceGetServiceCapabilities:
|
||||
// important for Hass
|
||||
res = onvif.GetServiceCapabilitiesResponse()
|
||||
// TODO: check path links to media
|
||||
b = onvif.GetMediaServiceCapabilitiesResponse()
|
||||
|
||||
case onvif.ActionSystemReboot:
|
||||
res = onvif.SystemRebootResponse()
|
||||
case onvif.DeviceSystemReboot:
|
||||
b = onvif.StaticResponse(operation)
|
||||
|
||||
time.AfterFunc(time.Second, func() {
|
||||
os.Exit(0)
|
||||
})
|
||||
|
||||
case onvif.ActionGetProfiles:
|
||||
// important for Hass: H264 codec, width, height
|
||||
res = onvif.GetProfilesResponse(streams.GetAll())
|
||||
case onvif.MediaGetVideoSources:
|
||||
b = onvif.GetVideoSourcesResponse(streams.GetAllNames())
|
||||
|
||||
case onvif.ActionGetStreamUri:
|
||||
case onvif.MediaGetProfiles:
|
||||
// important for Hass: H264 codec, width, height
|
||||
b = onvif.GetProfilesResponse(streams.GetAllNames())
|
||||
|
||||
case onvif.MediaGetProfile:
|
||||
token := onvif.FindTagValue(b, "ProfileToken")
|
||||
b = onvif.GetProfileResponse(token)
|
||||
|
||||
case onvif.MediaGetVideoSourceConfigurations:
|
||||
// important for Happytime Onvif Client
|
||||
b = onvif.GetVideoSourceConfigurationsResponse(streams.GetAllNames())
|
||||
|
||||
case onvif.MediaGetVideoSourceConfiguration:
|
||||
token := onvif.FindTagValue(b, "ConfigurationToken")
|
||||
b = onvif.GetVideoSourceConfigurationResponse(token)
|
||||
|
||||
case onvif.MediaGetStreamUri:
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -105,16 +129,23 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken")
|
||||
res = onvif.GetStreamUriResponse(uri)
|
||||
b = onvif.GetStreamUriResponse(uri)
|
||||
|
||||
case onvif.MediaGetSnapshotUri:
|
||||
uri := "http://" + r.Host + "/api/frame.jpeg?src=" + onvif.FindTagValue(b, "ProfileToken")
|
||||
b = onvif.GetSnapshotUriResponse(uri)
|
||||
|
||||
default:
|
||||
http.Error(w, "unsupported action", http.StatusBadRequest)
|
||||
http.Error(w, "unsupported operation", http.StatusBadRequest)
|
||||
log.Warn().Msgf("[onvif] unsupported operation: %s", operation)
|
||||
log.Debug().Msgf("[onvif] unsupported request:\n%s", b)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[onvif] server response:\n%s", b)
|
||||
|
||||
w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
if _, err = w.Write([]byte(res)); err != nil {
|
||||
if _, err = w.Write(b); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -160,7 +191,7 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if l := log.Trace(); l.Enabled() {
|
||||
b, _ := client.GetProfiles()
|
||||
b, _ := client.MediaRequest(onvif.MediaGetProfiles)
|
||||
l.Msgf("[onvif] src=%s profiles:\n%s", src, b)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ring"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("ring", func(source string) (core.Producer, error) {
|
||||
return ring.Dial(source)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/ring", apiRing)
|
||||
}
|
||||
|
||||
func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
var ringAPI *ring.RingRestClient
|
||||
var err error
|
||||
|
||||
// Check auth method
|
||||
if email := query.Get("email"); email != "" {
|
||||
// Email/Password Flow
|
||||
password := query.Get("password")
|
||||
code := query.Get("code")
|
||||
|
||||
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}, nil)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Try authentication (this will trigger 2FA if needed)
|
||||
if _, err = ringAPI.GetAuth(code); err != nil {
|
||||
if ringAPI.Using2FA {
|
||||
// Return 2FA prompt
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"needs_2fa": true,
|
||||
"prompt": ringAPI.PromptFor2FA,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 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{
|
||||
RefreshToken: refreshToken,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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("device_id", camera.DeviceID)
|
||||
|
||||
// Stream source
|
||||
items = append(items, &api.Source{
|
||||
Name: camera.Description,
|
||||
URL: "ring:?" + cleanQuery.Encode(),
|
||||
})
|
||||
|
||||
// Snapshot source
|
||||
items = append(items, &api.Source{
|
||||
Name: camera.Description + " Snapshot",
|
||||
URL: "ring:?" + cleanQuery.Encode() + "&snapshot",
|
||||
})
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
## Tested client
|
||||
|
||||
| From | To | Comment |
|
||||
|--------|---------------------------------|---------|
|
||||
| go2rtc | Reolink RLC-520A fw. v3.1.0.801 | OK |
|
||||
|
||||
**go2rtc.yaml**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
rtmp-reolink1: rtmp://192.168.10.92/bcs/channel0_main.bcs?channel=0&stream=0&user=admin&password=password
|
||||
rtmp-reolink2: rtmp://192.168.10.92/bcs/channel0_sub.bcs?channel=0&stream=1&user=admin&password=password
|
||||
rtmp-reolink3: rtmp://192.168.10.92/bcs/channel0_ext.bcs?channel=0&stream=1&user=admin&password=password
|
||||
```
|
||||
|
||||
## Tested server
|
||||
|
||||
| From | To | Comment |
|
||||
|------------------------|--------|---------------------|
|
||||
| OBS 31.0.2 | go2rtc | OK |
|
||||
| OpenIPC 2.5.03.02-lite | go2rtc | OK |
|
||||
| FFmpeg 6.1 | go2rtc | OK |
|
||||
| GoPro Black 12 | go2rtc | OK, 1080p, 5000kbps |
|
||||
|
||||
**go2rtc.yaml**
|
||||
|
||||
```yaml
|
||||
rtmp:
|
||||
listen: :1935
|
||||
streams:
|
||||
tmp:
|
||||
```
|
||||
|
||||
**OBS**
|
||||
|
||||
Settings > Stream:
|
||||
|
||||
- Service: Custom
|
||||
- Server: rtmp://192.168.10.101/tmp
|
||||
- Stream Key: <empty>
|
||||
- Use auth: <disabled>
|
||||
|
||||
**OpenIPC**
|
||||
|
||||
WebUI > Majestic > Settings > Outgoing
|
||||
|
||||
- Enable
|
||||
- Address: rtmp://192.168.10.101/tmp
|
||||
- Save
|
||||
- Restart
|
||||
|
||||
**FFmpeg**
|
||||
|
||||
```shell
|
||||
ffmpeg -re -i bbb.mp4 -c copy -f flv rtmp://192.168.10.101/tmp
|
||||
```
|
||||
|
||||
**GoPro**
|
||||
|
||||
GoPro Quik > Camera > Translation > Other
|
||||
+20
-1
@@ -1,6 +1,7 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -185,6 +186,22 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
if query.Get("backchannel") == "1" {
|
||||
conn.Medias = append(conn.Medias, &core.Media{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecOpus, ClockRate: 48000, Channels: 2},
|
||||
{Name: core.CodecPCM, ClockRate: 16000},
|
||||
{Name: core.CodecPCMA, ClockRate: 16000},
|
||||
{Name: core.CodecPCMU, ClockRate: 16000},
|
||||
{Name: core.CodecPCM, ClockRate: 8000},
|
||||
{Name: core.CodecPCMA, ClockRate: 8000},
|
||||
{Name: core.CodecPCMU, ClockRate: 8000},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if s := query.Get("pkt_size"); s != "" {
|
||||
conn.PacketSize = uint16(core.Atoi(s))
|
||||
}
|
||||
@@ -237,7 +254,9 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
})
|
||||
|
||||
if err := conn.Accept(); err != nil {
|
||||
if err != io.EOF {
|
||||
if errors.Is(err, rtsp.FailedAuth) {
|
||||
log.Warn().Str("remote_addr", conn.Connection.RemoteAddr).Msg("[rtsp] failed authentication")
|
||||
} else if err != io.EOF {
|
||||
log.WithLevel(level).Err(err).Caller().Send()
|
||||
}
|
||||
if closer != nil {
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
## Testing notes
|
||||
## Examples
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
test1-basic: ffmpeg:virtual?video#video=h264
|
||||
test2-reconnect: ffmpeg:virtual?video&duration=10#video=h264
|
||||
test3-execkill: exec:./examples/rtsp_client/rtsp_client/rtsp_client {output}
|
||||
# known RTSP sources
|
||||
rtsp-dahua1: rtsp://admin:password@192.168.10.90/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif
|
||||
rtsp-dahua2: rtsp://admin:password@192.168.10.90/cam/realmonitor?channel=1&subtype=1
|
||||
rtsp-tplink1: rtsp://admin:password@192.168.10.91/stream1
|
||||
rtsp-tplink2: rtsp://admin:password@192.168.10.91/stream2
|
||||
rtsp-reolink1: rtsp://admin:password@192.168.10.92/h264Preview_01_main
|
||||
rtsp-reolink2: rtsp://admin:password@192.168.10.92/h264Preview_01_sub
|
||||
rtsp-sonoff1: rtsp://admin:password@192.168.10.93/av_stream/ch0
|
||||
rtsp-sonoff2: rtsp://admin:password@192.168.10.93/av_stream/ch1
|
||||
|
||||
# known RTMP sources
|
||||
rtmp-reolink1: rtmp://192.168.10.92/bcs/channel0_main.bcs?channel=0&stream=0&user=admin&password=password
|
||||
rtmp-reolink2: rtmp://192.168.10.92/bcs/channel0_sub.bcs?channel=0&stream=1&user=admin&password=password
|
||||
rtmp-reolink3: rtmp://192.168.10.92/bcs/channel0_ext.bcs?channel=0&stream=1&user=admin&password=password
|
||||
|
||||
# known HTTP sources
|
||||
http-reolink1: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=admin&password=password
|
||||
http-reolink2: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_sub.bcs&user=admin&password=password
|
||||
http-reolink3: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=admin&password=password
|
||||
|
||||
# known ONVIF sources
|
||||
onvif-dahua1: onvif://admin:password@192.168.10.90?subtype=MediaProfile00000
|
||||
onvif-dahua2: onvif://admin:password@192.168.10.90?subtype=MediaProfile00001
|
||||
onvif-dahua3: onvif://admin:password@192.168.10.90?subtype=MediaProfile00000&snapshot
|
||||
onvif-tplink1: onvif://admin:password@192.168.10.91:2020?subtype=profile_1
|
||||
onvif-tplink2: onvif://admin:password@192.168.10.91:2020?subtype=profile_2
|
||||
onvif-reolink1: onvif://admin:password@192.168.10.92:8000?subtype=000
|
||||
onvif-reolink2: onvif://admin:password@192.168.10.92:8000?subtype=001
|
||||
onvif-reolink3: onvif://admin:password@192.168.10.92:8000?subtype=000&snapshot
|
||||
onvif-openipc1: onvif://admin:password@192.168.10.95:80?subtype=PROFILE_000
|
||||
onvif-openipc2: onvif://admin:password@192.168.10.95:80?subtype=PROFILE_001
|
||||
|
||||
# some EXEC examples
|
||||
exec-h264-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f h264 -
|
||||
exec-flv-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f flv -
|
||||
exec-mpegts-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f mpegts -
|
||||
exec-adts-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f adts -
|
||||
exec-mjpeg-pipe: exec:ffmpeg -re -i bbb.mp4 -c mjpeg -f mjpeg -
|
||||
exec-hevc-pipe: exec:ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc -
|
||||
exec-wav-pipe: exec:ffmpeg -re -i bbb.mp4 -c pcm_alaw -ar 8000 -ac 1 -f wav -
|
||||
exec-y4m-pipe: exec:ffmpeg -re -i bbb.mp4 -c rawvideo -f yuv4mpegpipe -
|
||||
exec-pcma-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_alaw -ar:a 8000 -ac:a 1 -f wav -
|
||||
exec-pcmu-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -f wav -
|
||||
exec-s16le-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_s16le -ar:a 16000 -ac:a 1 -f wav -
|
||||
|
||||
# some FFmpeg examples
|
||||
ffmpeg-video-h264: ffmpeg:virtual?video#video=h264
|
||||
ffmpeg-video-4K: ffmpeg:virtual?video&size=4K#video=h264
|
||||
ffmpeg-video-10s: ffmpeg:virtual?video&duration=10#video=h264
|
||||
ffmpeg-video-src2: ffmpeg:virtual?video=testsrc2&size=2K#video=h264
|
||||
```
|
||||
|
||||
@@ -53,7 +53,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.PatchConfig(name, query["src"], "streams"); err != nil {
|
||||
if err := app.PatchConfig([]string{"streams", name}, query["src"]); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
|
||||
if err := app.PatchConfig(src, nil, "streams"); err != nil {
|
||||
if err := app.PatchConfig([]string{"streams", src}, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,5 +171,6 @@ func (c *conn) label() string {
|
||||
if c.UserAgent != "" {
|
||||
sb.WriteString("\nuser_agent=" + c.UserAgent)
|
||||
}
|
||||
return sb.String()
|
||||
// escape quotes https://github.com/AlexxIT/go2rtc/issues/1603
|
||||
return strings.ReplaceAll(sb.String(), `"`, `'`)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func (s *Stream) Play(source string) error {
|
||||
}
|
||||
|
||||
func (s *Stream) AddInternalProducer(conn core.Producer) {
|
||||
producer := &Producer{conn: conn, state: stateInternal}
|
||||
producer := &Producer{conn: conn, state: stateInternal, url: "internal"}
|
||||
s.mu.Lock()
|
||||
s.producers = append(s.producers, producer)
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -47,11 +47,12 @@ func NewStream(source any) *Stream {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) Sources() (sources []string) {
|
||||
func (s *Stream) Sources() []string {
|
||||
sources := make([]string, 0, len(s.producers))
|
||||
for _, prod := range s.producers {
|
||||
sources = append(sources, prod.url)
|
||||
}
|
||||
return
|
||||
return sources
|
||||
}
|
||||
|
||||
func (s *Stream) SetSource(source string) {
|
||||
@@ -76,7 +77,7 @@ func (s *Stream) RemoveConsumer(cons core.Consumer) {
|
||||
}
|
||||
|
||||
func (s *Stream) AddProducer(prod core.Producer) {
|
||||
producer := &Producer{conn: prod, state: stateExternal}
|
||||
producer := &Producer{conn: prod, state: stateExternal, url: "external"}
|
||||
s.mu.Lock()
|
||||
s.producers = append(s.producers, producer)
|
||||
s.mu.Unlock()
|
||||
|
||||
+34
-17
@@ -42,10 +42,6 @@ func Init() {
|
||||
})
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
var sanitize = regexp.MustCompile(`\s`)
|
||||
|
||||
// Validate - not allow creating dynamic streams with spaces in the source
|
||||
@@ -68,6 +64,7 @@ func New(name string, sources ...string) *Stream {
|
||||
streamsMu.Lock()
|
||||
streams[name] = stream
|
||||
streamsMu.Unlock()
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -124,7 +121,7 @@ func GetOrPatch(query url.Values) *Stream {
|
||||
}
|
||||
|
||||
// check if src is stream name
|
||||
if stream, ok := streams[source]; ok {
|
||||
if stream := Get(source); stream != nil {
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -139,21 +136,41 @@ func GetOrPatch(query url.Values) *Stream {
|
||||
return Patch(source, source)
|
||||
}
|
||||
|
||||
func GetAll() (names []string) {
|
||||
var log zerolog.Logger
|
||||
|
||||
// streams map
|
||||
|
||||
var streams = map[string]*Stream{}
|
||||
var streamsMu sync.Mutex
|
||||
|
||||
func Get(name string) *Stream {
|
||||
streamsMu.Lock()
|
||||
defer streamsMu.Unlock()
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
streamsMu.Lock()
|
||||
defer streamsMu.Unlock()
|
||||
delete(streams, name)
|
||||
}
|
||||
|
||||
func GetAllNames() []string {
|
||||
streamsMu.Lock()
|
||||
names := make([]string, 0, len(streams))
|
||||
for name := range streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return
|
||||
streamsMu.Unlock()
|
||||
return names
|
||||
}
|
||||
|
||||
func Streams() map[string]*Stream {
|
||||
return streams
|
||||
func GetAllSources() map[string][]string {
|
||||
streamsMu.Lock()
|
||||
sources := make(map[string][]string, len(streams))
|
||||
for name, stream := range streams {
|
||||
sources[name] = stream.Sources()
|
||||
}
|
||||
streamsMu.Unlock()
|
||||
return sources
|
||||
}
|
||||
|
||||
func Delete(id string) {
|
||||
delete(streams, id)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var streams = map[string]*Stream{}
|
||||
var streamsMu sync.Mutex
|
||||
|
||||
@@ -15,4 +15,8 @@ func Init() {
|
||||
streams.HandleFunc("tapo", func(source string) (core.Producer, error) {
|
||||
return tapo.Dial(source)
|
||||
})
|
||||
|
||||
streams.HandleFunc("vigi", func(source string) (core.Producer, error) {
|
||||
return tapo.Dial(source)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# V4L2
|
||||
|
||||
What you should to know about [V4L2](https://en.wikipedia.org/wiki/Video4Linux):
|
||||
|
||||
- V4L2 (Video for Linux API version 2) works only in Linux
|
||||
- supports USB cameras and other similar devices
|
||||
- one device can only be connected to one software simultaneously
|
||||
- cameras support a fixed list of formats, resolutions and frame rates
|
||||
- basic cameras supports only RAW (non-compressed) pixel formats
|
||||
- regular cameras supports MJPEG format (series of JPEG frames)
|
||||
- advances cameras support H264 format (MSE/MP4, WebRTC compatible)
|
||||
- using MJPEG and H264 formats (if the camera supports them) won't cost you the CPU usage
|
||||
- transcoding RAW format to MJPEG or H264 - will cost you a significant CPU usage
|
||||
- H265 (HEVC) format is also supported (if the camera supports it)
|
||||
|
||||
Tests show that the basic Keenetic router with MIPS processor can broadcast three MJPEG cameras in the following resolutions: 1600х1200 + 640х480 + 640х480. The USB bus bandwidth is no more enough for larger resolutions. CPU consumption is no more than 5%.
|
||||
|
||||
Supported formats for your camera can be found here: **Go2rtc > WebUI > Add > V4L2**.
|
||||
|
||||
## RAW format
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
camera1: v4l2:device?video=/dev/video0&input_format=yuyv422&video_size=1280x720&framerate=10
|
||||
```
|
||||
|
||||
Go2rtc supports built-in transcoding of RAW to MJPEG format. This does not need to be additionally configured.
|
||||
|
||||
```
|
||||
ffplay http://localhost:1984/api/stream.mjpeg?src=camera1
|
||||
```
|
||||
|
||||
**Important.** You don't have to transcode the RAW format to transmit it over the network. You can stream it in `y4m` format, which is perfectly supported by ffmpeg. It won't cost you a CPU usage. But will require high network bandwidth.
|
||||
|
||||
```
|
||||
ffplay http://localhost:1984/api/stream.y4m?src=camera1
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package v4l2
|
||||
|
||||
func Init() {
|
||||
// not supported
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package v4l2
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/v4l2"
|
||||
"github.com/AlexxIT/go2rtc/pkg/v4l2/device"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("v4l2", func(source string) (core.Producer, error) {
|
||||
return v4l2.Open(source)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/v4l2", apiV4L2)
|
||||
}
|
||||
|
||||
func apiV4L2(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := os.ReadDir("/dev")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var sources []*api.Source
|
||||
|
||||
for _, file := range files {
|
||||
if !strings.HasPrefix(file.Name(), core.KindVideo) {
|
||||
continue
|
||||
}
|
||||
|
||||
path := "/dev/" + file.Name()
|
||||
|
||||
dev, err := device.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
formats, _ := dev.ListFormats()
|
||||
for _, fourCC := range formats {
|
||||
name, ffmpeg := findFormat(fourCC)
|
||||
source := &api.Source{Name: name}
|
||||
|
||||
sizes, _ := dev.ListSizes(fourCC)
|
||||
for _, wh := range sizes {
|
||||
if source.Info != "" {
|
||||
source.Info += " "
|
||||
}
|
||||
|
||||
source.Info += fmt.Sprintf("%dx%d", wh[0], wh[1])
|
||||
|
||||
frameRates, _ := dev.ListFrameRates(fourCC, wh[0], wh[1])
|
||||
for _, fr := range frameRates {
|
||||
source.Info += fmt.Sprintf("@%d", fr)
|
||||
|
||||
if source.URL == "" && ffmpeg != "" {
|
||||
source.URL = fmt.Sprintf(
|
||||
"v4l2:device?video=%s&input_format=%s&video_size=%dx%d&framerate=%d",
|
||||
path, ffmpeg, wh[0], wh[1], fr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if source.Info != "" {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
|
||||
_ = dev.Close()
|
||||
}
|
||||
|
||||
api.ResponseSources(w, sources)
|
||||
}
|
||||
|
||||
func findFormat(fourCC uint32) (name, ffmpeg string) {
|
||||
for _, format := range device.Formats {
|
||||
if format.FourCC == fourCC {
|
||||
return format.Name, format.FFmpeg
|
||||
}
|
||||
}
|
||||
return string(binary.LittleEndian.AppendUint32(nil, fourCC)), ""
|
||||
}
|
||||
@@ -11,13 +11,15 @@ If an external connection via STUN is used:
|
||||
|
||||
- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you not open your server to the World
|
||||
- For about 20% of users, the techology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/)
|
||||
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate
|
||||
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate:
|
||||
- https://habr.com/ru/companies/flashphoner/articles/480006/
|
||||
- https://www.youtube.com/watch?v=FXVg2ckuKfs
|
||||
|
||||
## Default config
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555/tcp"
|
||||
listen: ":8555"
|
||||
ice_servers:
|
||||
- urls: [ "stun:stun.l.google.com:19302" ]
|
||||
```
|
||||
@@ -29,7 +31,7 @@ webrtc:
|
||||
```yaml
|
||||
webrtc:
|
||||
# fix local TCP or UDP or both ports for WebRTC media
|
||||
listen: ":8555/tcp" # address of your local server
|
||||
listen: ":8555" # address of your local server
|
||||
|
||||
# add additional host candidates manually
|
||||
# order is important, the first will have a higher priority
|
||||
@@ -53,17 +55,20 @@ webrtc:
|
||||
# including candidates from the `listen` option
|
||||
# use `candidates: []` to remove all auto discovery candidates
|
||||
candidates: [ 192.168.1.123 ]
|
||||
|
||||
# enable localhost candidates
|
||||
loopback: true
|
||||
|
||||
# list of network types to be used for connection
|
||||
# including candidates from the `listen` option
|
||||
networks: [ udp4, udp6, tcp4, tcp6 ]
|
||||
|
||||
# list of interfaces to be used for connection
|
||||
# not related to the `listen` option
|
||||
# including interfaces from unspecified `listen` option (empty host)
|
||||
interfaces: [ eno1 ]
|
||||
|
||||
# list of host IP-addresses to be used for connection
|
||||
# not related to the `listen` option
|
||||
# including IPs from unspecified `listen` option (empty host)
|
||||
ips: [ 192.168.1.123 ]
|
||||
|
||||
# range for random UDP ports [min, max] to be used for connection
|
||||
@@ -71,14 +76,16 @@ webrtc:
|
||||
udp_ports: [ 50000, 50100 ]
|
||||
```
|
||||
|
||||
By default go2rtc uses **fixed TCP** port and multiple **random UDP** ports for each WebRTC connection - `listen: ":8555/tcp"`.
|
||||
By default go2rtc uses **fixed TCP** port and **fixed UDP** ports for each **direct** WebRTC connection - `listen: ":8555"`.
|
||||
|
||||
You can set **fixed TCP** and **fixed UDP** port for all connections - `listen: ":8555"`. This may has lower performance, but it's your choice.
|
||||
You can set **fixed TCP** and **random UDP** port for all connections - `listen: ":8555/tcp"`.
|
||||
|
||||
Don't know why, but you can disable TCP port and leave only random UDP ports - `listen: ""`.
|
||||
|
||||
## Config filters
|
||||
|
||||
**Importan!** By default go2rtc exclude all Docker-like candidates (`172.16.0.0/12`). This can not be disabled.
|
||||
|
||||
Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it.
|
||||
|
||||
For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not.
|
||||
@@ -97,8 +104,6 @@ For example, go2rtc inside closed docker container (ex. [Frigate](https://frigat
|
||||
webrtc:
|
||||
listen: ":8555" # use fixed TCP and UDP ports
|
||||
candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding)
|
||||
filters:
|
||||
candidates: [] # skip all internal docker candidates
|
||||
```
|
||||
|
||||
## Userful links
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/xnet"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,11 @@ func FilterCandidate(candidate *pion.ICECandidate) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// remove any Docker-like IP from candidates
|
||||
if ip := net.ParseIP(candidate.Address); ip != nil && xnet.Docker.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
|
||||
// host candidate should be in the hosts list
|
||||
if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil {
|
||||
if !core.Contains(filters.Candidates, candidate.Address) {
|
||||
|
||||
@@ -41,9 +41,11 @@ func streamsHandler(rawURL string) (core.Producer, error) {
|
||||
// https://aws.amazon.com/kinesis/video-streams/
|
||||
// https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html
|
||||
// https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc
|
||||
return kinesisClient(rawURL, query, "webrtc/kinesis")
|
||||
return kinesisClient(rawURL, query, "webrtc/kinesis", nil)
|
||||
} else if format == "openipc" {
|
||||
return openIPCClient(rawURL, query)
|
||||
} else if format == "switchbot" {
|
||||
return switchbotClient(rawURL, query)
|
||||
} else {
|
||||
return go2rtcClient(rawURL)
|
||||
}
|
||||
@@ -54,6 +56,8 @@ func streamsHandler(rawURL string) (core.Producer, error) {
|
||||
} else if format == "wyze" {
|
||||
// https://github.com/mrlt8/docker-wyze-bridge
|
||||
return wyzeClient(rawURL)
|
||||
} else if format == "creality" {
|
||||
return crealityClient(rawURL)
|
||||
} else {
|
||||
return whepClient(rawURL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
)
|
||||
|
||||
// https://github.com/AlexxIT/go2rtc/issues/1600
|
||||
func crealityClient(url string) (core.Producer, error) {
|
||||
pc, err := PeerConnection(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.FormatName = "webrtc/creality"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "http"
|
||||
prod.URL = url
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// TODO: return webrtc.SessionDescription
|
||||
offer, err := prod.CreateCompleteOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := offerToB64(offer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "plain/text")
|
||||
|
||||
// TODO: change http.DefaultClient settings
|
||||
client := http.Client{Timeout: time.Second * 5000}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
answer, err := answerFromB64(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = prod.SetAnswer(answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
func offerToB64(sdp string) (io.Reader, error) {
|
||||
// JS object
|
||||
v := map[string]string{
|
||||
"type": "offer",
|
||||
"sdp": sdp,
|
||||
}
|
||||
|
||||
// bytes
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// base64, why? who knows...
|
||||
s := base64.StdEncoding.EncodeToString(b)
|
||||
|
||||
return strings.NewReader(s), nil
|
||||
}
|
||||
|
||||
func answerFromB64(r io.Reader) (string, error) {
|
||||
// base64
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// bytes
|
||||
if b, err = base64.StdEncoding.DecodeString(string(b)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// JS object
|
||||
var v map[string]string
|
||||
if err = json.Unmarshal(b, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// string "v=0..."
|
||||
return v["sdp"], nil
|
||||
}
|
||||
+29
-16
@@ -34,7 +34,10 @@ func (k kinesisResponse) String() string {
|
||||
return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload)
|
||||
}
|
||||
|
||||
func kinesisClient(rawURL string, query url.Values, format string) (core.Producer, error) {
|
||||
func kinesisClient(
|
||||
rawURL string, query url.Values, format string,
|
||||
sdpOffer func(prod *webrtc.Conn, query url.Values) (any, error),
|
||||
) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -108,23 +111,33 @@ func kinesisClient(rawURL string, query url.Values, format string) (core.Produce
|
||||
}
|
||||
})
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
var payload any
|
||||
|
||||
if sdpOffer == nil {
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// 4. Create offer
|
||||
var offer string
|
||||
if offer, err = prod.CreateOffer(medias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Send offer
|
||||
payload = pion.SessionDescription{
|
||||
Type: pion.SDPTypeOffer,
|
||||
SDP: offer,
|
||||
}
|
||||
} else {
|
||||
if payload, err = sdpOffer(prod, query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create offer
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Send offer
|
||||
req.Action = "SDP_OFFER"
|
||||
req.Payload, _ = json.Marshal(pion.SessionDescription{
|
||||
Type: pion.SDPTypeOffer,
|
||||
SDP: offer,
|
||||
})
|
||||
req.Payload, _ = json.Marshal(payload)
|
||||
if err = conn.WriteJSON(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -218,5 +231,5 @@ func wyzeClient(rawURL string) (core.Producer, error) {
|
||||
"ice_servers": []string{string(kvs.Servers)},
|
||||
}
|
||||
|
||||
return kinesisClient(kvs.URL, query, "webrtc/wyze")
|
||||
return kinesisClient(kvs.URL, query, "webrtc/wyze", nil)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -62,8 +63,8 @@ func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 2. application/sdp - receive/response SDP via WebRTC-HTTP Egress Protocol (WHEP)
|
||||
// 3. other - receive/response raw SDP
|
||||
func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("src")
|
||||
stream := streams.Get(url)
|
||||
u := r.URL.Query().Get("src")
|
||||
stream := streams.Get(u)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -87,6 +88,21 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
offer = desc.SDP
|
||||
|
||||
case "application/x-www-form-urlencoded":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offerB64 := r.Form.Get("data")
|
||||
b, err := base64.StdEncoding.DecodeString(offerB64)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offer = string(b)
|
||||
|
||||
default:
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -124,6 +140,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(v)
|
||||
|
||||
case "application/x-www-form-urlencoded":
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
answerB64 := base64.StdEncoding.EncodeToString([]byte(answer))
|
||||
_, err = w.Write([]byte(answerB64))
|
||||
|
||||
case MimeSDP:
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
)
|
||||
|
||||
func switchbotClient(rawURL string, query url.Values) (core.Producer, error) {
|
||||
return kinesisClient(rawURL, query, "webrtc/switchbot", func(prod *webrtc.Conn, query url.Values) (any, error) {
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := struct {
|
||||
Type string `json:"type"`
|
||||
SDP string `json:"sdp"`
|
||||
Resolution int `json:"resolution"`
|
||||
PlayType int `json:"play_type"`
|
||||
}{
|
||||
Type: "offer",
|
||||
SDP: offer,
|
||||
}
|
||||
|
||||
switch query.Get("resolution") {
|
||||
case "hd":
|
||||
v.Resolution = 0
|
||||
case "sd":
|
||||
v.Resolution = 1
|
||||
}
|
||||
|
||||
return v, nil
|
||||
})
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func Init() {
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
cfg.Mod.Listen = ":8555/tcp"
|
||||
cfg.Mod.Listen = ":8555"
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package webtorrent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/webtorrent"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var upgrader *websocket.Upgrader
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/bubble"
|
||||
"github.com/AlexxIT/go2rtc/internal/debug"
|
||||
"github.com/AlexxIT/go2rtc/internal/doorbird"
|
||||
"github.com/AlexxIT/go2rtc/internal/dvrip"
|
||||
"github.com/AlexxIT/go2rtc/internal/echo"
|
||||
"github.com/AlexxIT/go2rtc/internal/exec"
|
||||
@@ -24,19 +25,21 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/nest"
|
||||
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/internal/onvif"
|
||||
"github.com/AlexxIT/go2rtc/internal/ring"
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/tapo"
|
||||
"github.com/AlexxIT/go2rtc/internal/v4l2"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/webtorrent"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Version = "1.9.7"
|
||||
app.Version = "1.9.9"
|
||||
|
||||
// 1. Core modules: app, api/ws, streams
|
||||
|
||||
@@ -78,10 +81,13 @@ func main() {
|
||||
mpegts.Init() // mpegts passive source
|
||||
roborock.Init() // roborock source
|
||||
homekit.Init() // homekit source
|
||||
ring.Init() // ring source
|
||||
nest.Init() // nest source
|
||||
bubble.Init() // bubble source
|
||||
expr.Init() // expr source
|
||||
gopro.Init() // gopro source
|
||||
doorbird.Init() // doorbird source
|
||||
v4l2.Init() // v4l2 source
|
||||
|
||||
// 6. Helper modules
|
||||
|
||||
|
||||
@@ -141,6 +141,10 @@ func MarshalSDP(name string, medias []*Media) ([]byte, error) {
|
||||
}
|
||||
md.WithCodec(codec.PayloadType, name, codec.ClockRate, codec.Channels, codec.FmtpLine)
|
||||
|
||||
if media.Direction != "" {
|
||||
md.WithPropertyAttribute(media.Direction)
|
||||
}
|
||||
|
||||
if media.ID != "" {
|
||||
md.WithValueAttribute("control", media.ID)
|
||||
}
|
||||
|
||||
+7
-4
@@ -140,6 +140,7 @@ func (s *Sender) Start() {
|
||||
s.done = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
// for range on nil chan is OK
|
||||
for packet := range s.buf {
|
||||
s.Output(packet)
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func (s *Sender) Start() {
|
||||
}
|
||||
|
||||
func (s *Sender) Wait() {
|
||||
if done := s.done; s.done != nil {
|
||||
if done := s.done; done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -165,10 +166,12 @@ func (s *Sender) State() string {
|
||||
|
||||
func (s *Sender) Close() {
|
||||
// close buffer if exists
|
||||
if buf := s.buf; buf != nil {
|
||||
s.buf = nil
|
||||
defer close(buf)
|
||||
s.mu.Lock()
|
||||
if s.buf != nil {
|
||||
close(s.buf) // exit from for range loop
|
||||
s.buf = nil // prevent writing to closed chan
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.Node.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSenser(t *testing.T) {
|
||||
recv := make(chan *Packet) // blocking receiver
|
||||
|
||||
sender := NewSender(nil, &Codec{})
|
||||
sender.Output = func(packet *Packet) {
|
||||
recv <- packet
|
||||
}
|
||||
require.Equal(t, "new", sender.State())
|
||||
|
||||
sender.Start()
|
||||
require.Equal(t, "connected", sender.State())
|
||||
|
||||
sender.Input(&Packet{})
|
||||
sender.Input(&Packet{})
|
||||
|
||||
require.Equal(t, 2, sender.Packets)
|
||||
require.Equal(t, 0, sender.Drops)
|
||||
|
||||
// important to read one before close
|
||||
// because goroutine in Start() can run with nil chan
|
||||
// it's OK in real life, but bad for test
|
||||
_, ok := <-recv
|
||||
require.True(t, ok)
|
||||
|
||||
sender.Close()
|
||||
require.Equal(t, "closed", sender.State())
|
||||
|
||||
sender.Input(&Packet{})
|
||||
|
||||
require.Equal(t, 2, sender.Packets)
|
||||
require.Equal(t, 1, sender.Drops)
|
||||
|
||||
// read 2nd
|
||||
_, ok = <-recv
|
||||
require.True(t, ok)
|
||||
|
||||
// read 3rd
|
||||
select {
|
||||
case <-recv:
|
||||
ok = true
|
||||
default:
|
||||
ok = false
|
||||
}
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package doorbird
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
core.Connection
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := u.User.Username()
|
||||
pass, _ := u.User.Password()
|
||||
|
||||
if u.Port() == "" {
|
||||
u.Host += ":80"
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := fmt.Sprintf("POST /bha-api/audio-transmit.cgi?http-user=%s&http-password=%s HTTP/1.0\r\n", user, pass) +
|
||||
"Content-Type: audio/basic\r\n" +
|
||||
"Content-Length: 9999999\r\n" +
|
||||
"Connection: Keep-Alive\r\n" +
|
||||
"Cache-Control: no-cache\r\n" +
|
||||
"\r\n"
|
||||
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline))
|
||||
if _, err = conn.Write([]byte(s)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecPCMU, ClockRate: 8000},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &Client{
|
||||
core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "doorbird",
|
||||
Protocol: "http",
|
||||
URL: rawURL,
|
||||
Medias: medias,
|
||||
Transport: conn,
|
||||
},
|
||||
conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return nil, core.ErrCantGetTrack
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
sender := core.NewSender(media, track.Codec)
|
||||
|
||||
sender.Handler = func(pkt *rtp.Packet) {
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline))
|
||||
if n, err := c.conn.Write(pkt.Payload); err == nil {
|
||||
c.Send += n
|
||||
}
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
c.Senders = append(c.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() (err error) {
|
||||
_, err = c.conn.Read(nil)
|
||||
return
|
||||
}
|
||||
+27
-15
@@ -140,23 +140,29 @@ func (c *Producer) probe() error {
|
||||
// 1. Empty video/audio flag
|
||||
// 2. MedaData without stereo key for AAC
|
||||
// 3. Audio header after Video keyframe tag
|
||||
waitType := []byte{TagData}
|
||||
timeout := time.Now().Add(core.ProbeTimeout)
|
||||
|
||||
for len(waitType) != 0 && time.Now().Before(timeout) {
|
||||
// OpenIPC camera (on old firmwares) sends:
|
||||
// 1. Empty video/audio flag
|
||||
// 2. No MetaData packet
|
||||
// 3. Sends a video packet in more than 3 seconds
|
||||
waitVideo := true
|
||||
waitAudio := true
|
||||
timeout := time.Now().Add(time.Second * 5)
|
||||
|
||||
for (waitVideo || waitAudio) && time.Now().Before(timeout) {
|
||||
pkt, err := c.readPacket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if i := bytes.IndexByte(waitType, pkt.PayloadType); i < 0 {
|
||||
continue
|
||||
} else {
|
||||
waitType = append(waitType[:i], waitType[i+1:]...)
|
||||
}
|
||||
//log.Printf("%d %0.20s", pkt.PayloadType, pkt.Payload)
|
||||
|
||||
switch pkt.PayloadType {
|
||||
case TagAudio:
|
||||
if !waitAudio {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = pkt.Payload[1] // bounds
|
||||
|
||||
codecID := pkt.Payload[0] >> 4 // SoundFormat
|
||||
@@ -179,8 +185,13 @@ func (c *Producer) probe() error {
|
||||
Codecs: []*core.Codec{codec},
|
||||
}
|
||||
c.Medias = append(c.Medias, media)
|
||||
waitAudio = false
|
||||
|
||||
case TagVideo:
|
||||
if !waitVideo {
|
||||
continue
|
||||
}
|
||||
|
||||
var codec *core.Codec
|
||||
|
||||
if isExHeader(pkt.Payload) {
|
||||
@@ -213,19 +224,20 @@ func (c *Producer) probe() error {
|
||||
Codecs: []*core.Codec{codec},
|
||||
}
|
||||
c.Medias = append(c.Medias, media)
|
||||
waitVideo = false
|
||||
|
||||
case TagData:
|
||||
if !bytes.Contains(pkt.Payload, []byte("onMetaData")) {
|
||||
waitType = append(waitType, TagData)
|
||||
continue
|
||||
}
|
||||
// Dahua cameras doesn't send videocodecid
|
||||
if bytes.Contains(pkt.Payload, []byte("videocodecid")) ||
|
||||
bytes.Contains(pkt.Payload, []byte("width")) ||
|
||||
bytes.Contains(pkt.Payload, []byte("framerate")) {
|
||||
waitType = append(waitType, TagVideo)
|
||||
if !bytes.Contains(pkt.Payload, []byte("videocodecid")) &&
|
||||
!bytes.Contains(pkt.Payload, []byte("width")) &&
|
||||
!bytes.Contains(pkt.Payload, []byte("framerate")) {
|
||||
waitVideo = false
|
||||
}
|
||||
if bytes.Contains(pkt.Payload, []byte("audiocodecid")) {
|
||||
waitType = append(waitType, TagAudio)
|
||||
if !bytes.Contains(pkt.Payload, []byte("audiocodecid")) {
|
||||
waitAudio = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,3 +83,15 @@ func TestDahua(t *testing.T) {
|
||||
n := naluTypes(b)
|
||||
require.Equal(t, []byte{0x40, 0x42, 0x44, 0x26}, n)
|
||||
}
|
||||
|
||||
func TestUSB(t *testing.T) {
|
||||
s := "00 00 00 01 67 4D 00 1F 8D 8D 40 28 02 DD 37 01 01 01 40 00 01 C2 00 00 57 E4 01 00 00 00 01 68 EE 3C 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 65 88 80 00"
|
||||
b := EncodeToAVCC(decode(s))
|
||||
n := naluTypes(b)
|
||||
require.Equal(t, []byte{0x67, 0x68, 0x65}, n)
|
||||
|
||||
s = "00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 41 9A 00 4C"
|
||||
b = EncodeToAVCC(decode(s))
|
||||
n = naluTypes(b)
|
||||
require.Equal(t, []byte{0x41}, n)
|
||||
}
|
||||
|
||||
+4
-1
@@ -22,7 +22,7 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
buf := make([]byte, 0, 512*1024) // 512K
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
//log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", track.Codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker)
|
||||
//log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker)
|
||||
|
||||
payload, err := depack.Unmarshal(packet.Payload)
|
||||
if len(payload) == 0 || err != nil {
|
||||
@@ -68,6 +68,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
|
||||
payload = payload[i:]
|
||||
continue
|
||||
case NALUTypePFrame, NALUTypeSPS, NALUTypePPS: // pass
|
||||
default:
|
||||
return // skip any unknown NAL unit type
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package h265
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ package h265
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"math"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
)
|
||||
|
||||
//
|
||||
|
||||
@@ -2,12 +2,21 @@ package camera
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNilCharacter(t *testing.T) {
|
||||
var res SetupEndpoints
|
||||
char := &hap.Character{}
|
||||
err := char.ReadTLV8(&res)
|
||||
require.NotNil(t, err)
|
||||
require.NotNil(t, strings.Contains(err.Error(), "can't read value"))
|
||||
}
|
||||
|
||||
type testTLV8 struct {
|
||||
name string
|
||||
value string
|
||||
|
||||
+10
-3
@@ -3,6 +3,7 @@ package hap
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
@@ -126,11 +127,17 @@ func (c *Character) Write(v any) (err error) {
|
||||
|
||||
// ReadTLV8 value to right struct
|
||||
func (c *Character) ReadTLV8(v any) (err error) {
|
||||
return tlv8.UnmarshalBase64(c.Value.(string), v)
|
||||
if s, ok := c.Value.(string); ok {
|
||||
return tlv8.UnmarshalBase64(s, v)
|
||||
}
|
||||
return fmt.Errorf("hap: can't read value: %v", v)
|
||||
}
|
||||
|
||||
func (c *Character) ReadBool() bool {
|
||||
return c.Value.(bool)
|
||||
func (c *Character) ReadBool() (bool, error) {
|
||||
if v, ok := c.Value.(bool); ok {
|
||||
return v, nil
|
||||
}
|
||||
return false, fmt.Errorf("hap: can't read value: %v", c.Value)
|
||||
}
|
||||
|
||||
func (c *Character) String() string {
|
||||
|
||||
@@ -235,3 +235,18 @@ func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []b
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func WriteBackoff(rw *bufio.ReadWriter) error {
|
||||
plainM2 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{
|
||||
State: StateM2,
|
||||
Error: 3, // BackoffError
|
||||
}
|
||||
body, err := tlv8.Marshal(plainM2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,8 +2,9 @@ package hass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/websocket"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewKeyframe() *Keyframe {
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecJPEG},
|
||||
{Name: core.CodecRAW},
|
||||
{Name: core.CodecH264},
|
||||
{Name: core.CodecH265},
|
||||
},
|
||||
@@ -87,6 +88,15 @@ func (k *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
|
||||
if track.Codec.IsRTP() {
|
||||
sender.Handler = mjpeg.RTPDepay(sender.Handler)
|
||||
}
|
||||
|
||||
case core.CodecRAW:
|
||||
sender.Handler = func(packet *rtp.Packet) {
|
||||
if n, err := k.wr.Write(packet.Payload); err == nil {
|
||||
k.Send += n
|
||||
}
|
||||
}
|
||||
|
||||
sender.Handler = mjpeg.Encoder(track.Codec, 5, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
|
||||
+12
-37
@@ -10,10 +10,14 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/xnet"
|
||||
"github.com/miekg/dns" // awesome library for parsing mDNS records
|
||||
)
|
||||
|
||||
const ServiceHAP = "_hap._tcp.local." // HomeKit Accessory Protocol
|
||||
const (
|
||||
ServiceDNSSD = "_services._dns-sd._udp.local."
|
||||
ServiceHAP = "_hap._tcp.local." // HomeKit Accessory Protocol
|
||||
)
|
||||
|
||||
type ServiceEntry struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -153,6 +157,7 @@ type Browser struct {
|
||||
Service string
|
||||
|
||||
Addr net.Addr
|
||||
Nets []*net.IPNet
|
||||
Recv net.PacketConn
|
||||
Sends []net.PacketConn
|
||||
|
||||
@@ -165,7 +170,9 @@ type Browser struct {
|
||||
// Receiver will get multicast responses on senders requests.
|
||||
func (b *Browser) ListenMulticastUDP() error {
|
||||
// 1. Collect IPv4 interfaces
|
||||
ip4s, err := InterfacesIP4()
|
||||
nets, err := xnet.IPNets(func(ip net.IP) bool {
|
||||
return !xnet.Docker.Contains(ip)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,11 +189,12 @@ func (b *Browser) ListenMulticastUDP() error {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, ip4 := range ip4s {
|
||||
conn, err := lc1.ListenPacket(ctx, "udp4", ip4.String()+":5353") // same port important
|
||||
for _, ipn := range nets {
|
||||
conn, err := lc1.ListenPacket(ctx, "udp4", ipn.IP.String()+":5353") // same port important
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Nets = append(b.Nets, ipn)
|
||||
b.Sends = append(b.Sends, conn)
|
||||
}
|
||||
|
||||
@@ -364,36 +372,3 @@ func NewServiceEntries(msg *dns.Msg, ip net.IP) (entries []*ServiceEntry) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func InterfacesIP4() ([]net.IP, error) {
|
||||
intfs, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
|
||||
loop:
|
||||
for _, intf := range intfs {
|
||||
if intf.Flags&net.FlagUp == 0 || intf.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := intf.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
if ip := v.IP.To4(); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
|
||||
+101
-96
@@ -20,7 +20,11 @@ func Serve(service string, entries []*ServiceEntry) error {
|
||||
}
|
||||
|
||||
func (b *Browser) Serve(entries []*ServiceEntry) error {
|
||||
var msg dns.Msg
|
||||
names := make(map[string]*ServiceEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.name() + "." + b.Service
|
||||
names[name] = entry
|
||||
}
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
@@ -29,129 +33,130 @@ func (b *Browser) Serve(entries []*ServiceEntry) error {
|
||||
break
|
||||
}
|
||||
|
||||
if err = msg.Unpack(buf[:n]); err != nil {
|
||||
var req dns.Msg // request
|
||||
if err = req.Unpack(buf[:n]); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !HasQuestionPTP(&msg, b.Service) {
|
||||
// skip messages without Questions
|
||||
if req.Question == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
remoteIP := addr.(*net.UDPAddr).IP
|
||||
localIP := MatchLocalIP(remoteIP)
|
||||
localIP := b.MatchLocalIP(remoteIP)
|
||||
|
||||
// skip messages from unknown networks (can be docker network)
|
||||
if localIP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
answer, err := NewDNSAnswer(entries, b.Service, localIP).Pack()
|
||||
var res dns.Msg // response
|
||||
for _, q := range req.Question {
|
||||
if q.Qtype != dns.TypePTR || q.Qclass != dns.ClassINET {
|
||||
continue
|
||||
}
|
||||
|
||||
if q.Name == ServiceDNSSD {
|
||||
AppendDNSSD(&res, b.Service)
|
||||
} else if q.Name == b.Service {
|
||||
for _, entry := range entries {
|
||||
AppendEntry(&res, entry, b.Service, localIP)
|
||||
}
|
||||
} else if entry, ok := names[q.Name]; ok {
|
||||
AppendEntry(&res, entry, b.Service, localIP)
|
||||
}
|
||||
}
|
||||
|
||||
if res.Answer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
res.MsgHdr.Response = true
|
||||
res.MsgHdr.Authoritative = true
|
||||
|
||||
data, err := res.Pack()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, send := range b.Sends {
|
||||
_, _ = send.WriteTo(answer, MulticastAddr)
|
||||
_, _ = send.WriteTo(data, MulticastAddr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func HasQuestionPTP(msg *dns.Msg, name string) bool {
|
||||
for _, q := range msg.Question {
|
||||
if q.Qtype == dns.TypePTR && q.Name == name {
|
||||
return true
|
||||
func (b *Browser) MatchLocalIP(remote net.IP) net.IP {
|
||||
for _, ipn := range b.Nets {
|
||||
if ipn.Contains(remote) {
|
||||
return ipn.IP
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDNSAnswer(entries []*ServiceEntry, service string, ip net.IP) *dns.Msg {
|
||||
msg := dns.Msg{
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
func AppendDNSSD(msg *dns.Msg, service string) {
|
||||
msg.Answer = append(
|
||||
msg.Answer,
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ServiceDNSSD, // _services._dns-sd._udp.local.
|
||||
Rrtype: dns.TypePTR, // 12
|
||||
Class: dns.ClassINET, // 1
|
||||
Ttl: 4500,
|
||||
},
|
||||
Ptr: service, // _home-assistant._tcp.local.
|
||||
},
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
ptrName := entry.name() + "." + service
|
||||
srvName := entry.name() + ".local."
|
||||
|
||||
msg.Answer = append(
|
||||
msg.Answer,
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: service,
|
||||
Rrtype: dns.TypePTR,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: 4500,
|
||||
},
|
||||
Ptr: ptrName,
|
||||
},
|
||||
)
|
||||
msg.Extra = append(
|
||||
msg.Extra,
|
||||
&dns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName,
|
||||
Rrtype: dns.TypeTXT,
|
||||
Class: ClassCacheFlush,
|
||||
Ttl: 4500,
|
||||
},
|
||||
Txt: entry.TXT(),
|
||||
},
|
||||
&dns.SRV{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName,
|
||||
Rrtype: dns.TypeSRV,
|
||||
Class: ClassCacheFlush,
|
||||
Ttl: 120,
|
||||
Rdlength: 0,
|
||||
},
|
||||
Port: entry.Port,
|
||||
Target: srvName,
|
||||
},
|
||||
&dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: srvName,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: ClassCacheFlush,
|
||||
Ttl: 120,
|
||||
Rdlength: 0,
|
||||
},
|
||||
A: ip,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return &msg
|
||||
)
|
||||
}
|
||||
|
||||
func MatchLocalIP(remote net.IP) net.IP {
|
||||
intfs, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
func AppendEntry(msg *dns.Msg, entry *ServiceEntry, service string, ip net.IP) {
|
||||
ptrName := entry.name() + "." + service
|
||||
srvName := entry.name() + ".local."
|
||||
|
||||
for _, intf := range intfs {
|
||||
if intf.Flags&net.FlagUp == 0 || intf.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := intf.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
if local := v.IP.To4(); local != nil && v.Contains(remote) {
|
||||
return local
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
msg.Answer = append(
|
||||
msg.Answer,
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: service, // _home-assistant._tcp.local.
|
||||
Rrtype: dns.TypePTR, // 12
|
||||
Class: dns.ClassINET, // 1
|
||||
Ttl: 4500,
|
||||
},
|
||||
Ptr: ptrName, // Home\ Assistant._home-assistant._tcp.local.
|
||||
},
|
||||
)
|
||||
msg.Extra = append(
|
||||
msg.Extra,
|
||||
&dns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName, // Home\ Assistant._home-assistant._tcp.local.
|
||||
Rrtype: dns.TypeTXT, // 16
|
||||
Class: ClassCacheFlush, // 32769
|
||||
Ttl: 4500,
|
||||
},
|
||||
Txt: entry.TXT(),
|
||||
},
|
||||
&dns.SRV{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName, // Home\ Assistant._home-assistant._tcp.local.
|
||||
Rrtype: dns.TypeSRV, // 33
|
||||
Class: ClassCacheFlush, // 32769
|
||||
Ttl: 120,
|
||||
},
|
||||
Port: entry.Port, // 8123
|
||||
Target: srvName, // 963f1fa82b7142809711cebe7c826322.local.
|
||||
},
|
||||
&dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: srvName, // 963f1fa82b7142809711cebe7c826322.local.
|
||||
Rrtype: dns.TypeA, // 1
|
||||
Class: ClassCacheFlush, // 32769
|
||||
Ttl: 120,
|
||||
},
|
||||
A: ip,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
|
||||
if track.Codec.IsRTP() {
|
||||
sender.Handler = RTPDepay(sender.Handler)
|
||||
} else if track.Codec.Name == core.CodecRAW {
|
||||
sender.Handler = Encoder(track.Codec, sender.Handler)
|
||||
sender.Handler = Encoder(track.Codec, 0, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
|
||||
+58
-14
@@ -9,24 +9,38 @@ import (
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
// FixJPEG - reencode JPEG if it has wrong header
|
||||
//
|
||||
// for example, this app produce "bad" images:
|
||||
// https://github.com/jacksonliam/mjpg-streamer
|
||||
//
|
||||
// and they can't be uploaded to the Telegram servers:
|
||||
// {"ok":false,"error_code":400,"description":"Bad Request: IMAGE_PROCESS_FAILED"}
|
||||
func FixJPEG(b []byte) []byte {
|
||||
// skip non-JPEG
|
||||
if len(b) < 10 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
return b
|
||||
}
|
||||
// skip if header OK for imghdr library
|
||||
// https://docs.python.org/3/library/imghdr.html
|
||||
if string(b[2:4]) == "\xFF\xDB" || string(b[6:10]) == "JFIF" || string(b[6:10]) == "Exif" {
|
||||
if len(b) < 10 || b[0] != 0xFF || b[1] != markerSOI {
|
||||
return b
|
||||
}
|
||||
|
||||
// skip JPEG without app marker
|
||||
if b[2] == 0xFF && b[3] == markerDQT {
|
||||
return b
|
||||
}
|
||||
|
||||
switch string(b[6:10]) {
|
||||
case "JFIF", "Exif":
|
||||
// skip if header OK for imghdr library
|
||||
// - https://docs.python.org/3/library/imghdr.html
|
||||
return b
|
||||
case "AVI1":
|
||||
// adds DHT tables to JPEG file before SOS marker
|
||||
// useful when you want to save a JPEG frame from an MJPEG stream
|
||||
// - https://github.com/image-rs/jpeg-decoder/issues/76
|
||||
// - https://github.com/pion/mediadevices/pull/493
|
||||
// - https://bugzilla.mozilla.org/show_bug.cgi?id=963907#c18
|
||||
return InjectDHT(b)
|
||||
}
|
||||
|
||||
// reencode JPEG if it has wrong header
|
||||
//
|
||||
// for example, this app produce "bad" images:
|
||||
// https://github.com/jacksonliam/mjpg-streamer
|
||||
//
|
||||
// and they can't be uploaded to the Telegram servers:
|
||||
// {"ok":false,"error_code":400,"description":"Bad Request: IMAGE_PROCESS_FAILED"}
|
||||
img, err := jpeg.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return b
|
||||
@@ -38,12 +52,19 @@ func FixJPEG(b []byte) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func Encoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
// Encoder convert YUV frame to Img.
|
||||
// Support skipping empty frames, for example if USB cam needs time to start.
|
||||
func Encoder(codec *core.Codec, skipEmpty int, handler core.HandlerFunc) core.HandlerFunc {
|
||||
newImage := y4m.NewImage(codec.FmtpLine)
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
img := newImage(packet.Payload)
|
||||
|
||||
if skipEmpty != 0 && y4m.HasSameColor(img) {
|
||||
skipEmpty--
|
||||
return
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := jpeg.Encode(buf, img, nil); err != nil {
|
||||
return
|
||||
@@ -54,3 +75,26 @@ func Encoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
handler(&clone)
|
||||
}
|
||||
}
|
||||
|
||||
const dhtSize = 432 // known size for 4 default tables
|
||||
|
||||
func InjectDHT(b []byte) []byte {
|
||||
if bytes.Index(b, []byte{0xFF, markerDHT}) > 0 {
|
||||
return b // already exist
|
||||
}
|
||||
|
||||
i := bytes.Index(b, []byte{0xFF, markerSOS})
|
||||
if i < 0 {
|
||||
return b
|
||||
}
|
||||
|
||||
dht := make([]byte, 0, dhtSize)
|
||||
dht = MakeHuffmanHeaders(dht)
|
||||
|
||||
tmp := make([]byte, len(b)+dhtSize)
|
||||
copy(tmp, b[:i])
|
||||
copy(tmp[i:], dht)
|
||||
copy(tmp[i+dhtSize:], b[i:])
|
||||
|
||||
return tmp
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package mjpeg
|
||||
|
||||
const (
|
||||
markerSOF = 0xC0 // Start Of Frame (Baseline Sequential)
|
||||
markerSOI = 0xD8 // Start Of Image
|
||||
markerEOI = 0xD9 // End Of Image
|
||||
markerSOS = 0xDA // Start Of Scan
|
||||
markerDQT = 0xDB // Define Quantization Table
|
||||
markerDHT = 0xC4 // Define Huffman Table
|
||||
)
|
||||
+15
-15
@@ -143,9 +143,7 @@ var chm_ac_symbols = []byte{
|
||||
|
||||
func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
// Appendix A from https://www.rfc-editor.org/rfc/rfc2435
|
||||
p = append(p, 0xFF,
|
||||
0xD8, // SOI
|
||||
)
|
||||
p = append(p, 0xFF, markerSOI)
|
||||
|
||||
p = MakeQuantHeader(p, lqt, 0)
|
||||
p = MakeQuantHeader(p, cqt, 1)
|
||||
@@ -156,8 +154,7 @@ func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
t = 0x22 // hsamp = 2, vsamp = 2
|
||||
}
|
||||
|
||||
p = append(p, 0xFF,
|
||||
0xC0, // SOF
|
||||
p = append(p, 0xFF, markerSOF,
|
||||
0, 17, // size
|
||||
8, // bits per component
|
||||
byte(h>>8), byte(h&0xFF),
|
||||
@@ -174,13 +171,9 @@ func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
1, // quant table 1
|
||||
)
|
||||
|
||||
p = MakeHuffmanHeader(p, lum_dc_codelens, lum_dc_symbols, 0, 0)
|
||||
p = MakeHuffmanHeader(p, lum_ac_codelens, lum_ac_symbols, 0, 1)
|
||||
p = MakeHuffmanHeader(p, chm_dc_codelens, chm_dc_symbols, 1, 0)
|
||||
p = MakeHuffmanHeader(p, chm_ac_codelens, chm_ac_symbols, 1, 1)
|
||||
p = MakeHuffmanHeaders(p)
|
||||
|
||||
return append(p, 0xFF,
|
||||
0xDA, // SOS
|
||||
return append(p, 0xFF, markerSOS,
|
||||
0, 12, // size
|
||||
3, // 3 components
|
||||
0, // comp 0
|
||||
@@ -196,16 +189,23 @@ func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
}
|
||||
|
||||
func MakeQuantHeader(p []byte, qt []byte, tableNo byte) []byte {
|
||||
p = append(p, 0xFF, 0xDB, 0, 67, tableNo)
|
||||
p = append(p, 0xFF, markerDQT, 0, 67, tableNo)
|
||||
return append(p, qt...)
|
||||
}
|
||||
|
||||
func MakeHuffmanHeader(p, codelens, symbols []byte, tableNo, tableClass byte) []byte {
|
||||
p = append(p,
|
||||
0xFF, 0xC4, 0,
|
||||
byte(3+len(codelens)+len(symbols)),
|
||||
p = append(p, 0xFF, markerDHT,
|
||||
0, byte(3+len(codelens)+len(symbols)), // size
|
||||
(tableClass<<4)|tableNo,
|
||||
)
|
||||
p = append(p, codelens...)
|
||||
return append(p, symbols...)
|
||||
}
|
||||
|
||||
func MakeHuffmanHeaders(p []byte) []byte {
|
||||
p = MakeHuffmanHeader(p, lum_dc_codelens, lum_dc_symbols, 0, 0)
|
||||
p = MakeHuffmanHeader(p, lum_ac_codelens, lum_ac_symbols, 0, 1)
|
||||
p = MakeHuffmanHeader(p, chm_dc_codelens, chm_dc_symbols, 1, 0)
|
||||
p = MakeHuffmanHeader(p, chm_ac_codelens, chm_ac_symbols, 1, 1)
|
||||
return p
|
||||
}
|
||||
|
||||
+3
-2
@@ -3,10 +3,11 @@ package mjpeg
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func RTPDepay(handlerFunc core.HandlerFunc) core.HandlerFunc {
|
||||
|
||||
+151
-18
@@ -17,9 +17,15 @@ type API struct {
|
||||
|
||||
StreamProjectID string
|
||||
StreamDeviceID string
|
||||
StreamSessionID string
|
||||
StreamExpiresAt time.Time
|
||||
|
||||
// WebRTC
|
||||
StreamSessionID string
|
||||
|
||||
// RTSP
|
||||
StreamToken string
|
||||
StreamExtensionToken string
|
||||
|
||||
extendTimer *time.Timer
|
||||
}
|
||||
|
||||
@@ -27,6 +33,12 @@ type Auth struct {
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
type DeviceInfo struct {
|
||||
Name string
|
||||
DeviceID string
|
||||
Protocols []string
|
||||
}
|
||||
|
||||
var cache = map[string]*API{}
|
||||
var cacheMu sync.Mutex
|
||||
|
||||
@@ -80,7 +92,7 @@ func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) {
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (a *API) GetDevices(projectID string) (map[string]string, error) {
|
||||
func (a *API) GetDevices(projectID string) ([]DeviceInfo, error) {
|
||||
uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices"
|
||||
req, err := http.NewRequest("GET", uri, nil)
|
||||
if err != nil {
|
||||
@@ -108,24 +120,30 @@ func (a *API) GetDevices(projectID string) (map[string]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices := map[string]string{}
|
||||
devices := make([]DeviceInfo, 0, len(resv.Devices))
|
||||
|
||||
for _, device := range resv.Devices {
|
||||
// only RTSP and WEB_RTC available (both supported)
|
||||
if len(device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols[0] != "WEB_RTC" {
|
||||
continue
|
||||
}
|
||||
|
||||
i := strings.LastIndexByte(device.Name, '/')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := device.Traits.SdmDevicesTraitsInfo.CustomName
|
||||
devices[name] = device.Name[i+1:]
|
||||
// Devices configured through the Nest app use the container/room name as opposed to the customName trait
|
||||
if name == "" && len(device.ParentRelations) > 0 {
|
||||
name = device.ParentRelations[0].DisplayName
|
||||
}
|
||||
|
||||
devices = append(devices, DeviceInfo{
|
||||
Name: name,
|
||||
DeviceID: device.Name[i+1:],
|
||||
Protocols: device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols,
|
||||
})
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
@@ -190,11 +208,20 @@ func (a *API) ExtendStream() error {
|
||||
var reqv struct {
|
||||
Command string `json:"command"`
|
||||
Params struct {
|
||||
MediaSessionID string `json:"mediaSessionId"`
|
||||
MediaSessionID string `json:"mediaSessionId,omitempty"`
|
||||
StreamExtensionToken string `json:"streamExtensionToken,omitempty"`
|
||||
} `json:"params"`
|
||||
}
|
||||
reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream"
|
||||
reqv.Params.MediaSessionID = a.StreamSessionID
|
||||
|
||||
if a.StreamToken != "" {
|
||||
// RTSP
|
||||
reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendRtspStream"
|
||||
reqv.Params.StreamExtensionToken = a.StreamExtensionToken
|
||||
} else {
|
||||
// WebRTC
|
||||
reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream"
|
||||
reqv.Params.MediaSessionID = a.StreamSessionID
|
||||
}
|
||||
|
||||
b, err := json.Marshal(reqv)
|
||||
if err != nil {
|
||||
@@ -223,8 +250,10 @@ func (a *API) ExtendStream() error {
|
||||
|
||||
var resv struct {
|
||||
Results struct {
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MediaSessionID string `json:"mediaSessionId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MediaSessionID string `json:"mediaSessionId"`
|
||||
StreamExtensionToken string `json:"streamExtensionToken"`
|
||||
StreamToken string `json:"streamToken"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
@@ -234,6 +263,111 @@ func (a *API) ExtendStream() error {
|
||||
|
||||
a.StreamSessionID = resv.Results.MediaSessionID
|
||||
a.StreamExpiresAt = resv.Results.ExpiresAt
|
||||
a.StreamExtensionToken = resv.Results.StreamExtensionToken
|
||||
a.StreamToken = resv.Results.StreamToken
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) GenerateRtspStream(projectID, deviceID string) (string, error) {
|
||||
var reqv struct {
|
||||
Command string `json:"command"`
|
||||
Params struct{} `json:"params"`
|
||||
}
|
||||
reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateRtspStream"
|
||||
|
||||
b, err := json.Marshal(reqv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" +
|
||||
projectID + "/devices/" + deviceID + ":executeCommand"
|
||||
req, err := http.NewRequest("POST", uri, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||
|
||||
client := &http.Client{Timeout: time.Second * 5000}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return "", errors.New("nest: wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
var resv struct {
|
||||
Results struct {
|
||||
StreamURLs map[string]string `json:"streamUrls"`
|
||||
StreamExtensionToken string `json:"streamExtensionToken"`
|
||||
StreamToken string `json:"streamToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(res.Body).Decode(&resv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, ok := resv.Results.StreamURLs["rtspUrl"]; !ok {
|
||||
return "", errors.New("nest: failed to generate rtsp url")
|
||||
}
|
||||
|
||||
a.StreamProjectID = projectID
|
||||
a.StreamDeviceID = deviceID
|
||||
a.StreamToken = resv.Results.StreamToken
|
||||
a.StreamExtensionToken = resv.Results.StreamExtensionToken
|
||||
a.StreamExpiresAt = resv.Results.ExpiresAt
|
||||
|
||||
return resv.Results.StreamURLs["rtspUrl"], nil
|
||||
}
|
||||
|
||||
func (a *API) StopRTSPStream() error {
|
||||
if a.StreamProjectID == "" || a.StreamDeviceID == "" {
|
||||
return errors.New("nest: tried to stop rtsp stream without a project or device ID")
|
||||
}
|
||||
|
||||
var reqv struct {
|
||||
Command string `json:"command"`
|
||||
Params struct {
|
||||
StreamExtensionToken string `json:"streamExtensionToken"`
|
||||
} `json:"params"`
|
||||
}
|
||||
reqv.Command = "sdm.devices.commands.CameraLiveStream.StopRtspStream"
|
||||
reqv.Params.StreamExtensionToken = a.StreamExtensionToken
|
||||
|
||||
b, err := json.Marshal(reqv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" +
|
||||
a.StreamProjectID + "/devices/" + a.StreamDeviceID + ":executeCommand"
|
||||
req, err := http.NewRequest("POST", uri, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||
|
||||
client := &http.Client{Timeout: time.Second * 5000}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return errors.New("nest: wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
a.StreamProjectID = ""
|
||||
a.StreamDeviceID = ""
|
||||
a.StreamExtensionToken = ""
|
||||
a.StreamToken = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -266,10 +400,10 @@ type Device struct {
|
||||
//SdmDevicesTraitsCameraClipPreview struct {
|
||||
//} `json:"sdm.devices.traits.CameraClipPreview"`
|
||||
} `json:"traits"`
|
||||
//ParentRelations []struct {
|
||||
// Parent string `json:"parent"`
|
||||
// DisplayName string `json:"displayName"`
|
||||
//} `json:"parentRelations"`
|
||||
ParentRelations []struct {
|
||||
Parent string `json:"parent"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"parentRelations"`
|
||||
}
|
||||
|
||||
func (a *API) StartExtendStreamTimer() {
|
||||
@@ -282,7 +416,6 @@ func (a *API) StartExtendStreamTimer() {
|
||||
duration = time.Until(a.StreamExpiresAt.Add(-30 * time.Second))
|
||||
a.extendTimer.Reset(duration)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (a *API) StopExtendStreamTimer() {
|
||||
|
||||
+71
-13
@@ -3,18 +3,25 @@ package nest
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
type WebRTCClient struct {
|
||||
conn *webrtc.Conn
|
||||
api *API
|
||||
}
|
||||
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
type RTSPClient struct {
|
||||
conn *rtsp.Conn
|
||||
api *API
|
||||
}
|
||||
|
||||
func Dial(rawURL string) (core.Producer, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -36,6 +43,42 @@ func Dial(rawURL string) (*Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
protocols := strings.Split(query.Get("protocols"), ",")
|
||||
if len(protocols) > 0 && protocols[0] == "RTSP" {
|
||||
return rtspConn(nestAPI, rawURL, projectID, deviceID)
|
||||
}
|
||||
|
||||
// Default to WEB_RTC for backwards compataiility
|
||||
return rtcConn(nestAPI, rawURL, projectID, deviceID)
|
||||
}
|
||||
|
||||
func (c *WebRTCClient) GetMedias() []*core.Media {
|
||||
return c.conn.GetMedias()
|
||||
}
|
||||
|
||||
func (c *WebRTCClient) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return c.conn.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *WebRTCClient) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
return c.conn.AddTrack(media, codec, track)
|
||||
}
|
||||
|
||||
func (c *WebRTCClient) Start() error {
|
||||
c.api.StartExtendStreamTimer()
|
||||
return c.conn.Start()
|
||||
}
|
||||
|
||||
func (c *WebRTCClient) Stop() error {
|
||||
c.api.StopExtendStreamTimer()
|
||||
return c.conn.Stop()
|
||||
}
|
||||
|
||||
func (c *WebRTCClient) MarshalJSON() ([]byte, error) {
|
||||
return c.conn.MarshalJSON()
|
||||
}
|
||||
|
||||
func rtcConn(nestAPI *API, rawURL, projectID, deviceID string) (*WebRTCClient, error) {
|
||||
rtcAPI, err := webrtc.NewAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -77,31 +120,46 @@ func Dial(rawURL string) (*Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{conn: conn, api: nestAPI}, nil
|
||||
return &WebRTCClient{conn: conn, api: nestAPI}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
return c.conn.GetMedias()
|
||||
func rtspConn(nestAPI *API, rawURL, projectID, deviceID string) (*RTSPClient, error) {
|
||||
rtspURL, err := nestAPI.GenerateRtspStream(projectID, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rtspClient := rtsp.NewClient(rtspURL)
|
||||
if err := rtspClient.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rtspClient.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RTSPClient{conn: rtspClient, api: nestAPI}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
func (c *RTSPClient) GetMedias() []*core.Media {
|
||||
result := c.conn.GetMedias()
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *RTSPClient) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return c.conn.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
return c.conn.AddTrack(media, codec, track)
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
func (c *RTSPClient) Start() error {
|
||||
c.api.StartExtendStreamTimer()
|
||||
return c.conn.Start()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
func (c *RTSPClient) Stop() error {
|
||||
c.api.StopRTSPStream()
|
||||
c.api.StopExtendStreamTimer()
|
||||
return c.conn.Stop()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
func (c *RTSPClient) MarshalJSON() ([]byte, error) {
|
||||
return c.conn.MarshalJSON()
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,10 +3,11 @@ package ngrok
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Ngrok struct {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
## Profiles
|
||||
|
||||
- Profile A - For access control configuration
|
||||
- Profile C - For door control and event management
|
||||
- Profile S - For basic video streaming
|
||||
- Video streaming and configuration
|
||||
- Profile T - For advanced video streaming
|
||||
- H.264 / H.265 video compression
|
||||
- Imaging settings
|
||||
- Motion alarm and tampering events
|
||||
- Metadata streaming
|
||||
- Bi-directional audio
|
||||
|
||||
## Services
|
||||
|
||||
https://www.onvif.org/profiles/specifications/
|
||||
|
||||
- https://www.onvif.org/ver10/device/wsdl/devicemgmt.wsdl
|
||||
- https://www.onvif.org/ver20/imaging/wsdl/imaging.wsdl
|
||||
- https://www.onvif.org/ver10/media/wsdl/media.wsdl
|
||||
|
||||
## TMP
|
||||
|
||||
| | Dahua | Reolink | TP-Link |
|
||||
|------------------------|---------|---------|---------|
|
||||
| GetCapabilities | no auth | no auth | no auth |
|
||||
| GetServices | no auth | no auth | no auth |
|
||||
| GetServiceCapabilities | no auth | no auth | auth |
|
||||
| GetSystemDateAndTime | no auth | no auth | no auth |
|
||||
| GetNetworkInterfaces | auth | auth | auth |
|
||||
| GetDeviceInformation | auth | auth | auth |
|
||||
| GetProfiles | auth | auth | auth |
|
||||
| GetScopes | auth | auth | auth |
|
||||
|
||||
- Dahua - onvif://192.168.10.90:80
|
||||
- Reolink - onvif://192.168.10.92:8000
|
||||
- TP-Link - onvif://192.168.10.91:2020/onvif/device_service
|
||||
-
|
||||
+29
-87
@@ -2,8 +2,6 @@ package onvif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
@@ -12,8 +10,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
const PathDevice = "/onvif/device_service"
|
||||
@@ -41,7 +37,7 @@ func NewClient(rawURL string) (*Client, error) {
|
||||
client.deviceURL = baseURL + u.Path
|
||||
}
|
||||
|
||||
b, err := client.GetCapabilities()
|
||||
b, err := client.DeviceRequest(DeviceGetCapabilities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,7 +91,7 @@ func (c *Client) GetURI() (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) GetName() (string, error) {
|
||||
b, err := c.GetDeviceInformation()
|
||||
b, err := c.DeviceRequest(DeviceGetDeviceInformation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -104,7 +100,7 @@ func (c *Client) GetName() (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) GetProfilesTokens() ([]string, error) {
|
||||
b, err := c.GetProfiles()
|
||||
b, err := c.MediaRequest(MediaGetProfiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,86 +123,56 @@ func (c *Client) HasSnapshots() bool {
|
||||
return strings.Contains(string(b), `SnapshotUri="true"`)
|
||||
}
|
||||
|
||||
func (c *Client) GetCapabilities() ([]byte, error) {
|
||||
func (c *Client) GetProfile(token string) ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL,
|
||||
`<tds:GetCapabilities xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:Category>All</tds:Category>
|
||||
</tds:GetCapabilities>`,
|
||||
c.mediaURL, `<trt:GetProfile><trt:ProfileToken>`+token+`</trt:ProfileToken></trt:GetProfile>`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) GetNetworkInterfaces() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL, `<tds:GetNetworkInterfaces xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) GetDeviceInformation() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL, `<tds:GetDeviceInformation xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) GetProfiles() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.mediaURL, `<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>`,
|
||||
)
|
||||
func (c *Client) GetVideoSourceConfiguration(token string) ([]byte, error) {
|
||||
return c.Request(c.mediaURL, `<trt:GetVideoSourceConfiguration>
|
||||
<trt:ConfigurationToken>`+token+`</trt:ConfigurationToken>
|
||||
</trt:GetVideoSourceConfiguration>`)
|
||||
}
|
||||
|
||||
func (c *Client) GetStreamUri(token string) ([]byte, error) {
|
||||
return c.Request(
|
||||
c.mediaURL,
|
||||
`<trt:GetStreamUri xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
return c.Request(c.mediaURL, `<trt:GetStreamUri>
|
||||
<trt:StreamSetup>
|
||||
<tt:Stream>RTP-Unicast</tt:Stream>
|
||||
<tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport>
|
||||
</trt:StreamSetup>
|
||||
<trt:ProfileToken>`+token+`</trt:ProfileToken>
|
||||
</trt:GetStreamUri>`,
|
||||
)
|
||||
</trt:GetStreamUri>`)
|
||||
}
|
||||
|
||||
func (c *Client) GetSnapshotUri(token string) ([]byte, error) {
|
||||
return c.Request(
|
||||
c.imaginURL,
|
||||
`<trt:GetSnapshotUri xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
|
||||
<trt:ProfileToken>`+token+`</trt:ProfileToken>
|
||||
</trt:GetSnapshotUri>`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) GetSystemDateAndTime() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL, `<tds:GetSystemDateAndTime xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>`,
|
||||
c.imaginURL, `<trt:GetSnapshotUri><trt:ProfileToken>`+token+`</trt:ProfileToken></trt:GetSnapshotUri>`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) GetServiceCapabilities() ([]byte, error) {
|
||||
// some cameras answer GetServiceCapabilities for media only for path = "/onvif/media"
|
||||
return c.Request(
|
||||
c.mediaURL, `<trt:GetServiceCapabilities xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>`,
|
||||
c.mediaURL, `<trt:GetServiceCapabilities />`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) SystemReboot() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL, `<tds:SystemReboot xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>`,
|
||||
)
|
||||
func (c *Client) DeviceRequest(operation string) ([]byte, error) {
|
||||
switch operation {
|
||||
case DeviceGetServices:
|
||||
operation = `<tds:GetServices><tds:IncludeCapability>true</tds:IncludeCapability></tds:GetServices>`
|
||||
case DeviceGetCapabilities:
|
||||
operation = `<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>`
|
||||
default:
|
||||
operation = `<tds:` + operation + `/>`
|
||||
}
|
||||
return c.Request(c.deviceURL, operation)
|
||||
}
|
||||
|
||||
func (c *Client) GetServices() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL, `<tds:GetServices xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:IncludeCapability>true</tds:IncludeCapability>
|
||||
</tds:GetServices>`,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) GetScopes() ([]byte, error) {
|
||||
return c.Request(
|
||||
c.deviceURL, `<tds:GetScopes xmlns:tds="http://www.onvif.org/ver10/device/wsdl" />`,
|
||||
)
|
||||
func (c *Client) MediaRequest(operation string) ([]byte, error) {
|
||||
operation = `<trt:` + operation + `/>`
|
||||
return c.Request(c.mediaURL, operation)
|
||||
}
|
||||
|
||||
func (c *Client) Request(url, body string) ([]byte, error) {
|
||||
@@ -214,35 +180,11 @@ func (c *Client) Request(url, body string) ([]byte, error) {
|
||||
return nil, errors.New("onvif: unsupported service")
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">`,
|
||||
)
|
||||
|
||||
if user := c.url.User; user != nil {
|
||||
nonce := core.RandString(16, 36)
|
||||
created := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
pass, _ := user.Password()
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(nonce + created + pass))
|
||||
|
||||
buf.WriteString(`<s:Header>
|
||||
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
|
||||
<wsse:UsernameToken>
|
||||
<wsse:Username>` + user.Username() + `</wsse:Username>
|
||||
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` + base64.StdEncoding.EncodeToString(h.Sum(nil)) + `</wsse:Password>
|
||||
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` + base64.StdEncoding.EncodeToString([]byte(nonce)) + `</wsse:Nonce>
|
||||
<wsu:Created xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` + created + `</wsu:Created>
|
||||
</wsse:UsernameToken>
|
||||
</wsse:Security>
|
||||
</s:Header>`)
|
||||
}
|
||||
|
||||
buf.WriteString(`<s:Body>` + body + `</s:Body></s:Envelope>`)
|
||||
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`, buf)
|
||||
res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Envelope struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
const (
|
||||
prefix1 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
|
||||
`
|
||||
prefix2 = `<s:Body>
|
||||
`
|
||||
suffix = `
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
)
|
||||
|
||||
func NewEnvelope() *Envelope {
|
||||
e := &Envelope{buf: make([]byte, 0, 1024)}
|
||||
e.Append(prefix1, prefix2)
|
||||
return e
|
||||
}
|
||||
|
||||
func NewEnvelopeWithUser(user *url.Userinfo) *Envelope {
|
||||
if user == nil {
|
||||
return NewEnvelope()
|
||||
}
|
||||
|
||||
nonce := core.RandString(16, 36)
|
||||
created := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
pass, _ := user.Password()
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(nonce + created + pass))
|
||||
|
||||
e := &Envelope{buf: make([]byte, 0, 1024)}
|
||||
e.Append(prefix1)
|
||||
e.Appendf(`<s:Header>
|
||||
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
|
||||
<wsse:UsernameToken>
|
||||
<wsse:Username>%s</wsse:Username>
|
||||
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">%s</wsse:Password>
|
||||
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">%s</wsse:Nonce>
|
||||
<wsu:Created xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">%s</wsu:Created>
|
||||
</wsse:UsernameToken>
|
||||
</wsse:Security>
|
||||
</s:Header>
|
||||
`,
|
||||
user.Username(),
|
||||
base64.StdEncoding.EncodeToString(h.Sum(nil)),
|
||||
base64.StdEncoding.EncodeToString([]byte(nonce)),
|
||||
created)
|
||||
e.Append(prefix2)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Envelope) Append(args ...string) {
|
||||
for _, s := range args {
|
||||
e.buf = append(e.buf, s...)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Envelope) Appendf(format string, args ...any) {
|
||||
e.buf = fmt.Appendf(e.buf, format, args...)
|
||||
}
|
||||
|
||||
func (e *Envelope) Bytes() []byte {
|
||||
return append(e.buf, suffix...)
|
||||
}
|
||||
+24
-1
@@ -1,6 +1,7 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func FindTagValue(b []byte, tag string) string {
|
||||
re := regexp.MustCompile(`(?s)[:<]` + tag + `>([^<]+)`)
|
||||
re := regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`)
|
||||
m := re.FindSubmatch(b)
|
||||
if len(m) != 2 {
|
||||
return ""
|
||||
@@ -106,3 +107,25 @@ func atoi(s string) int {
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func GetPosixTZ(current time.Time) string {
|
||||
// Thanks to https://github.com/Path-Variable/go-posix-time
|
||||
_, offset := current.Zone()
|
||||
|
||||
if current.IsDST() {
|
||||
_, end := current.ZoneBounds()
|
||||
endPlus1 := end.Add(time.Hour * 25)
|
||||
_, offset = endPlus1.Zone()
|
||||
}
|
||||
|
||||
var prefix string
|
||||
if offset < 0 {
|
||||
prefix = "GMT+"
|
||||
offset = -offset / 60
|
||||
} else {
|
||||
prefix = "GMT-"
|
||||
offset = offset / 60
|
||||
}
|
||||
|
||||
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,34 @@ func TestGetStreamUri(t *testing.T) {
|
||||
</SOAP-ENV:Envelope>`,
|
||||
url: "rtsp://192.168.5.53:8090/profile1=r",
|
||||
},
|
||||
{
|
||||
name: "go2rtc 1.9.4",
|
||||
xml: `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
|
||||
<trt:MediaUri>
|
||||
<tt:Uri xmlns:tt="http://www.onvif.org/ver10/schema">rtsp://192.168.1.123:8554/rtsp-dahua1</tt:Uri>
|
||||
</trt:MediaUri>
|
||||
</trt:GetStreamUriResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`,
|
||||
url: "rtsp://192.168.1.123:8554/rtsp-dahua1",
|
||||
},
|
||||
{
|
||||
name: "go2rtc 1.9.8",
|
||||
xml: `<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
<s:Body>
|
||||
<trt:GetStreamUriResponse>
|
||||
<trt:MediaUri>
|
||||
<tt:Uri>rtsp://192.168.1.123:8554/rtsp-dahua2</tt:Uri>
|
||||
</trt:MediaUri>
|
||||
</trt:GetStreamUriResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
`,
|
||||
url: "rtsp://192.168.1.123:8554/rtsp-dahua2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
+229
-155
@@ -2,30 +2,40 @@ package onvif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionGetCapabilities = "GetCapabilities"
|
||||
ActionGetSystemDateAndTime = "GetSystemDateAndTime"
|
||||
ActionGetNetworkInterfaces = "GetNetworkInterfaces"
|
||||
ActionGetDeviceInformation = "GetDeviceInformation"
|
||||
ActionGetServiceCapabilities = "GetServiceCapabilities"
|
||||
ActionGetProfiles = "GetProfiles"
|
||||
ActionGetStreamUri = "GetStreamUri"
|
||||
ActionSystemReboot = "SystemReboot"
|
||||
const ServiceGetServiceCapabilities = "GetServiceCapabilities"
|
||||
|
||||
ActionGetServices = "GetServices"
|
||||
ActionGetScopes = "GetScopes"
|
||||
ActionGetVideoSources = "GetVideoSources"
|
||||
ActionGetAudioSources = "GetAudioSources"
|
||||
ActionGetVideoSourceConfigurations = "GetVideoSourceConfigurations"
|
||||
ActionGetAudioSourceConfigurations = "GetAudioSourceConfigurations"
|
||||
ActionGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations"
|
||||
ActionGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations"
|
||||
const (
|
||||
DeviceGetCapabilities = "GetCapabilities"
|
||||
DeviceGetDeviceInformation = "GetDeviceInformation"
|
||||
DeviceGetDiscoveryMode = "GetDiscoveryMode"
|
||||
DeviceGetDNS = "GetDNS"
|
||||
DeviceGetHostname = "GetHostname"
|
||||
DeviceGetNetworkDefaultGateway = "GetNetworkDefaultGateway"
|
||||
DeviceGetNetworkInterfaces = "GetNetworkInterfaces"
|
||||
DeviceGetNetworkProtocols = "GetNetworkProtocols"
|
||||
DeviceGetNTP = "GetNTP"
|
||||
DeviceGetScopes = "GetScopes"
|
||||
DeviceGetServices = "GetServices"
|
||||
DeviceGetSystemDateAndTime = "GetSystemDateAndTime"
|
||||
DeviceSystemReboot = "SystemReboot"
|
||||
)
|
||||
|
||||
const (
|
||||
MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations"
|
||||
MediaGetAudioSources = "GetAudioSources"
|
||||
MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations"
|
||||
MediaGetProfile = "GetProfile"
|
||||
MediaGetProfiles = "GetProfiles"
|
||||
MediaGetSnapshotUri = "GetSnapshotUri"
|
||||
MediaGetStreamUri = "GetStreamUri"
|
||||
MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations"
|
||||
MediaGetVideoSources = "GetVideoSources"
|
||||
MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration"
|
||||
MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations"
|
||||
)
|
||||
|
||||
func GetRequestAction(b []byte) string {
|
||||
@@ -42,163 +52,227 @@ func GetRequestAction(b []byte) string {
|
||||
return string(m[1])
|
||||
}
|
||||
|
||||
func GetCapabilitiesResponse(host string) string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<tds:GetCapabilitiesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:Capabilities xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
<tt:Device>
|
||||
<tt:XAddr>http://` + host + `/onvif/device_service</tt:XAddr>
|
||||
</tt:Device>
|
||||
<tt:Media>
|
||||
<tt:XAddr>http://` + host + `/onvif/media_service</tt:XAddr>
|
||||
<tt:StreamingCapabilities>
|
||||
<tt:RTPMulticast>false</tt:RTPMulticast>
|
||||
<tt:RTP_TCP>false</tt:RTP_TCP>
|
||||
<tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP>
|
||||
</tt:StreamingCapabilities>
|
||||
</tt:Media>
|
||||
</tds:Capabilities>
|
||||
</tds:GetCapabilitiesResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
func GetCapabilitiesResponse(host string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<tds:GetCapabilitiesResponse>
|
||||
<tds:Capabilities>
|
||||
<tt:Device>
|
||||
<tt:XAddr>http://`, host, `/onvif/device_service</tt:XAddr>
|
||||
</tt:Device>
|
||||
<tt:Media>
|
||||
<tt:XAddr>http://`, host, `/onvif/media_service</tt:XAddr>
|
||||
<tt:StreamingCapabilities>
|
||||
<tt:RTPMulticast>false</tt:RTPMulticast>
|
||||
<tt:RTP_TCP>false</tt:RTP_TCP>
|
||||
<tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP>
|
||||
</tt:StreamingCapabilities>
|
||||
</tt:Media>
|
||||
</tds:Capabilities>
|
||||
</tds:GetCapabilitiesResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetSystemDateAndTimeResponse() string {
|
||||
func GetServicesResponse(host string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<tds:GetServicesResponse>
|
||||
<tds:Service>
|
||||
<tds:Namespace>http://www.onvif.org/ver10/device/wsdl</tds:Namespace>
|
||||
<tds:XAddr>http://`, host, `/onvif/device_service</tds:XAddr>
|
||||
<tds:Version><tt:Major>2</tt:Major><tt:Minor>5</tt:Minor></tds:Version>
|
||||
</tds:Service>
|
||||
<tds:Service>
|
||||
<tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace>
|
||||
<tds:XAddr>http://`, host, `/onvif/media_service</tds:XAddr>
|
||||
<tds:Version><tt:Major>2</tt:Major><tt:Minor>5</tt:Minor></tds:Version>
|
||||
</tds:Service>
|
||||
</tds:GetServicesResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetSystemDateAndTimeResponse() []byte {
|
||||
loc := time.Now()
|
||||
utc := loc.UTC()
|
||||
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<tds:GetSystemDateAndTimeResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:SystemDateAndTime xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
<tt:DateTimeType>NTP</tt:DateTimeType>
|
||||
<tt:DaylightSavings>false</tt:DaylightSavings>
|
||||
<tt:TimeZone>
|
||||
<tt:TZ>GMT%s</tt:TZ>
|
||||
</tt:TimeZone>
|
||||
<tt:UTCDateTime>
|
||||
<tt:Time>
|
||||
<tt:Hour>%d</tt:Hour>
|
||||
<tt:Minute>%d</tt:Minute>
|
||||
<tt:Second>%d</tt:Second>
|
||||
</tt:Time>
|
||||
<tt:Date>
|
||||
<tt:Year>%d</tt:Year>
|
||||
<tt:Month>%d</tt:Month>
|
||||
<tt:Day>%d</tt:Day>
|
||||
</tt:Date>
|
||||
</tt:UTCDateTime>
|
||||
<tt:LocalDateTime>
|
||||
<tt:Time>
|
||||
<tt:Hour>%d</tt:Hour>
|
||||
<tt:Minute>%d</tt:Minute>
|
||||
<tt:Second>%d</tt:Second>
|
||||
</tt:Time>
|
||||
<tt:Date>
|
||||
<tt:Year>%d</tt:Year>
|
||||
<tt:Month>%d</tt:Month>
|
||||
<tt:Day>%d</tt:Day>
|
||||
</tt:Date>
|
||||
</tt:LocalDateTime>
|
||||
</tds:SystemDateAndTime>
|
||||
</tds:GetSystemDateAndTimeResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`,
|
||||
loc.Format("-07:00"),
|
||||
e := NewEnvelope()
|
||||
e.Appendf(`<tds:GetSystemDateAndTimeResponse>
|
||||
<tds:SystemDateAndTime>
|
||||
<tt:DateTimeType>NTP</tt:DateTimeType>
|
||||
<tt:DaylightSavings>true</tt:DaylightSavings>
|
||||
<tt:TimeZone>
|
||||
<tt:TZ>%s</tt:TZ>
|
||||
</tt:TimeZone>
|
||||
<tt:UTCDateTime>
|
||||
<tt:Time><tt:Hour>%d</tt:Hour><tt:Minute>%d</tt:Minute><tt:Second>%d</tt:Second></tt:Time>
|
||||
<tt:Date><tt:Year>%d</tt:Year><tt:Month>%d</tt:Month><tt:Day>%d</tt:Day></tt:Date>
|
||||
</tt:UTCDateTime>
|
||||
<tt:LocalDateTime>
|
||||
<tt:Time><tt:Hour>%d</tt:Hour><tt:Minute>%d</tt:Minute><tt:Second>%d</tt:Second></tt:Time>
|
||||
<tt:Date><tt:Year>%d</tt:Year><tt:Month>%d</tt:Month><tt:Day>%d</tt:Day></tt:Date>
|
||||
</tt:LocalDateTime>
|
||||
</tds:SystemDateAndTime>
|
||||
</tds:GetSystemDateAndTimeResponse>`,
|
||||
GetPosixTZ(loc),
|
||||
utc.Hour(), utc.Minute(), utc.Second(), utc.Year(), utc.Month(), utc.Day(),
|
||||
loc.Hour(), loc.Minute(), loc.Second(), loc.Year(), loc.Month(), loc.Day(),
|
||||
)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetNetworkInterfacesResponse() string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<tds:GetNetworkInterfacesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
func GetDeviceInformationResponse(manuf, model, firmware, serial string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<tds:GetDeviceInformationResponse>
|
||||
<tds:Manufacturer>`, manuf, `</tds:Manufacturer>
|
||||
<tds:Model>`, model, `</tds:Model>
|
||||
<tds:FirmwareVersion>`, firmware, `</tds:FirmwareVersion>
|
||||
<tds:SerialNumber>`, serial, `</tds:SerialNumber>
|
||||
<tds:HardwareId>1.00</tds:HardwareId>
|
||||
</tds:GetDeviceInformationResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetDeviceInformationResponse(manuf, model, firmware, serial string) string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<tds:GetDeviceInformationResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:Manufacturer>` + manuf + `</tds:Manufacturer>
|
||||
<tds:Model>` + model + `</tds:Model>
|
||||
<tds:FirmwareVersion>` + firmware + `</tds:FirmwareVersion>
|
||||
<tds:SerialNumber>` + serial + `</tds:SerialNumber>
|
||||
<tds:HardwareId>1.00</tds:HardwareId>
|
||||
</tds:GetDeviceInformationResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
func GetMediaServiceCapabilitiesResponse() []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetServiceCapabilitiesResponse>
|
||||
<trt:Capabilities SnapshotUri="true" Rotation="false" VideoSourceMode="false" OSD="false" TemporaryOSDText="false" EXICompression="false">
|
||||
<trt:StreamingCapabilities RTPMulticast="false" RTP_TCP="false" RTP_RTSP_TCP="true" NonAggregateControl="false" NoRTSPStreaming="false" />
|
||||
</trt:Capabilities>
|
||||
</trt:GetServiceCapabilitiesResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetServiceCapabilitiesResponse() string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<trt:GetServiceCapabilitiesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
|
||||
<trt:Capabilities SnapshotUri="false" Rotation="false" VideoSourceMode="false" OSD="false" TemporaryOSDText="false" EXICompression="false">
|
||||
<trt:StreamingCapabilities RTPMulticast="false" RTP_TCP="false" RTP_RTSP_TCP="true" NonAggregateControl="false" NoRTSPStreaming="false" />
|
||||
</trt:Capabilities>
|
||||
</trt:GetServiceCapabilitiesResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
func GetProfilesResponse(names []string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetProfilesResponse>
|
||||
`)
|
||||
for _, name := range names {
|
||||
appendProfile(e, "Profiles", name)
|
||||
}
|
||||
e.Append(`</trt:GetProfilesResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func SystemRebootResponse() string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<tds:SystemRebootResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:Message>system reboot in 1 second...</tds:Message>
|
||||
</tds:SystemRebootResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
func GetProfileResponse(name string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetProfileResponse>
|
||||
`)
|
||||
appendProfile(e, "Profile", name)
|
||||
e.Append(`</trt:GetProfileResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetProfilesResponse(names []string) string {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<trt:GetProfilesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema">`)
|
||||
func appendProfile(e *Envelope, tag, name string) {
|
||||
// empty `RateControl` important for UniFi Protect
|
||||
e.Append(`<trt:`, tag, ` token="`, name, `" fixed="true">
|
||||
<tt:Name>`, name, `</tt:Name>
|
||||
<tt:VideoSourceConfiguration token="`, name, `">
|
||||
<tt:Name>VSC</tt:Name>
|
||||
<tt:SourceToken>`, name, `</tt:SourceToken>
|
||||
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
|
||||
</tt:VideoSourceConfiguration>
|
||||
<tt:VideoEncoderConfiguration token="vec">
|
||||
<tt:Name>VEC</tt:Name>
|
||||
<tt:Encoding>H264</tt:Encoding>
|
||||
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
|
||||
<tt:RateControl />
|
||||
</tt:VideoEncoderConfiguration>
|
||||
</trt:`, tag, `>
|
||||
`)
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
buf.WriteString(`
|
||||
<trt:Profiles token="` + name + `" fixed="true">
|
||||
<tt:Name>` + name + `</tt:Name>
|
||||
<tt:VideoEncoderConfiguration token="` + strconv.Itoa(i) + `">
|
||||
<tt:Encoding>H264</tt:Encoding>
|
||||
<tt:Resolution>
|
||||
<tt:Width>1920</tt:Width>
|
||||
<tt:Height>1080</tt:Height>
|
||||
</tt:Resolution>
|
||||
</tt:VideoEncoderConfiguration>
|
||||
</trt:Profiles>`)
|
||||
func GetVideoSourceConfigurationsResponse(names []string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetVideoSourceConfigurationsResponse>
|
||||
`)
|
||||
for _, name := range names {
|
||||
appendProfile(e, "Configurations", name)
|
||||
}
|
||||
e.Append(`</trt:GetVideoSourceConfigurationsResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetVideoSourceConfigurationResponse(name string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetVideoSourceConfigurationResponse>
|
||||
`)
|
||||
appendVideoSourceConfiguration(e, "Configuration", name)
|
||||
e.Append(`</trt:GetVideoSourceConfigurationResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func appendVideoSourceConfiguration(e *Envelope, tag, name string) {
|
||||
e.Append(`<trt:`, tag, ` token="`, name, `" fixed="true">
|
||||
<tt:Name>VSC</tt:Name>
|
||||
<tt:SourceToken>`, name, `</tt:SourceToken>
|
||||
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
|
||||
</trt:`, tag, `>
|
||||
`)
|
||||
}
|
||||
|
||||
func GetVideoSourcesResponse(names []string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetVideoSourcesResponse>
|
||||
`)
|
||||
for _, name := range names {
|
||||
e.Append(`<trt:VideoSources token="`, name, `">
|
||||
<tt:Framerate>30.000000</tt:Framerate>
|
||||
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
|
||||
</trt:VideoSources>
|
||||
`)
|
||||
}
|
||||
e.Append(`</trt:GetVideoSourcesResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetStreamUriResponse(uri string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetStreamUriResponse><trt:MediaUri><tt:Uri>`, uri, `</tt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetSnapshotUriResponse(uri string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetSnapshotUriResponse><trt:MediaUri><tt:Uri>`, uri, `</tt:Uri></trt:MediaUri></trt:GetSnapshotUriResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func StaticResponse(operation string) []byte {
|
||||
switch operation {
|
||||
case DeviceGetSystemDateAndTime:
|
||||
return GetSystemDateAndTimeResponse()
|
||||
}
|
||||
|
||||
buf.WriteString(`
|
||||
</trt:GetProfilesResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`)
|
||||
|
||||
return buf.String()
|
||||
e := NewEnvelope()
|
||||
e.Append(responses[operation])
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetStreamUriResponse(uri string) string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
|
||||
<trt:MediaUri>
|
||||
<tt:Uri xmlns:tt="http://www.onvif.org/ver10/schema">` + uri + `</tt:Uri>
|
||||
</trt:MediaUri>
|
||||
</trt:GetStreamUriResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
var responses = map[string]string{
|
||||
DeviceGetDiscoveryMode: `<tds:GetDiscoveryModeResponse><tds:DiscoveryMode>Discoverable</tds:DiscoveryMode></tds:GetDiscoveryModeResponse>`,
|
||||
DeviceGetDNS: `<tds:GetDNSResponse><tds:DNSInformation /></tds:GetDNSResponse>`,
|
||||
DeviceGetHostname: `<tds:GetHostnameResponse><tds:HostnameInformation /></tds:GetHostnameResponse>`,
|
||||
DeviceGetNetworkDefaultGateway: `<tds:GetNetworkDefaultGatewayResponse><tds:NetworkGateway /></tds:GetNetworkDefaultGatewayResponse>`,
|
||||
DeviceGetNTP: `<tds:GetNTPResponse><tds:NTPInformation /></tds:GetNTPResponse>`,
|
||||
DeviceSystemReboot: `<tds:SystemRebootResponse><tds:Message>OK</tds:Message></tds:SystemRebootResponse>`,
|
||||
|
||||
DeviceGetNetworkInterfaces: `<tds:GetNetworkInterfacesResponse />`,
|
||||
DeviceGetNetworkProtocols: `<tds:GetNetworkProtocolsResponse />`,
|
||||
DeviceGetScopes: `<tds:GetScopesResponse>
|
||||
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/name/go2rtc</tt:ScopeItem></tds:Scopes>
|
||||
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/location/github</tt:ScopeItem></tds:Scopes>
|
||||
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/Profile/Streaming</tt:ScopeItem></tds:Scopes>
|
||||
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/type/Network_Video_Transmitter</tt:ScopeItem></tds:Scopes>
|
||||
</tds:GetScopesResponse>`,
|
||||
|
||||
MediaGetVideoEncoderConfigurations: `<trt:GetVideoEncoderConfigurationsResponse>
|
||||
<tt:VideoEncoderConfiguration token="vec">
|
||||
<tt:Name>VEC</tt:Name>
|
||||
<tt:Encoding>H264</tt:Encoding>
|
||||
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
|
||||
<tt:RateControl />
|
||||
</tt:VideoEncoderConfiguration>
|
||||
</trt:GetVideoEncoderConfigurationsResponse>`,
|
||||
|
||||
MediaGetAudioEncoderConfigurations: `<trt:GetAudioEncoderConfigurationsResponse />`,
|
||||
MediaGetAudioSources: `<trt:GetAudioSourcesResponse />`,
|
||||
MediaGetAudioSourceConfigurations: `<trt:GetAudioSourceConfigurationsResponse />`,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package pcm
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
core.Connection
|
||||
rd io.Reader
|
||||
}
|
||||
|
||||
func Open(rd io.Reader) (*Producer, error) {
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecPCMU, ClockRate: 8000},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &Producer{
|
||||
core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "pcm",
|
||||
Medias: medias,
|
||||
Transport: rd,
|
||||
},
|
||||
rd,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Producer) Start() error {
|
||||
for {
|
||||
payload := make([]byte, 1024)
|
||||
if _, err := io.ReadFull(c.rd, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Recv += 1024
|
||||
|
||||
if len(c.Receivers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: core.Now90000()},
|
||||
Payload: payload,
|
||||
}
|
||||
c.Receivers[0].WriteRTP(pkt)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
v2 "github.com/AlexxIT/go2rtc/pkg/pcm"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCMUtoPCM(t *testing.T) {
|
||||
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RefreshTokenAuth struct {
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
type EmailAuth struct {
|
||||
Email string
|
||||
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"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"` // Always "client"
|
||||
TokenType string `json:"token_type"` // Always "Bearer"
|
||||
}
|
||||
|
||||
type Auth2faResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
TSVState string `json:"tsv_state"`
|
||||
Phone string `json:"phone"`
|
||||
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 {
|
||||
httpClient *http.Client
|
||||
authConfig *AuthConfig
|
||||
hardwareID string
|
||||
authToken *AuthTokenResponse
|
||||
Using2FA bool
|
||||
PromptFor2FA string
|
||||
RefreshToken string
|
||||
auth interface{} // EmailAuth or RefreshTokenAuth
|
||||
onTokenRefresh func(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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
StickupCams []CameraData `json:"stickup_cams"`
|
||||
AllCameras []CameraData `json:"all_cameras"`
|
||||
Chimes []CameraData `json:"chimes"`
|
||||
Other []map[string]interface{} `json:"other"`
|
||||
}
|
||||
|
||||
const (
|
||||
Doorbot CameraKind = "doorbot"
|
||||
Doorbell CameraKind = "doorbell"
|
||||
DoorbellV3 CameraKind = "doorbell_v3"
|
||||
DoorbellV4 CameraKind = "doorbell_v4"
|
||||
DoorbellV5 CameraKind = "doorbell_v5"
|
||||
DoorbellOyster CameraKind = "doorbell_oyster"
|
||||
DoorbellPortal CameraKind = "doorbell_portal"
|
||||
DoorbellScallop CameraKind = "doorbell_scallop"
|
||||
DoorbellScallopLite CameraKind = "doorbell_scallop_lite"
|
||||
DoorbellGraham CameraKind = "doorbell_graham_cracker"
|
||||
LpdV1 CameraKind = "lpd_v1"
|
||||
LpdV2 CameraKind = "lpd_v2"
|
||||
LpdV4 CameraKind = "lpd_v4"
|
||||
JboxV1 CameraKind = "jbox_v1"
|
||||
StickupCam CameraKind = "stickup_cam"
|
||||
StickupCamV3 CameraKind = "stickup_cam_v3"
|
||||
StickupCamElite CameraKind = "stickup_cam_elite"
|
||||
StickupCamLongfin CameraKind = "stickup_cam_longfin"
|
||||
StickupCamLunar CameraKind = "stickup_cam_lunar"
|
||||
SpotlightV2 CameraKind = "spotlightw_v2"
|
||||
HpCamV1 CameraKind = "hp_cam_v1"
|
||||
HpCamV2 CameraKind = "hp_cam_v2"
|
||||
StickupCamV4 CameraKind = "stickup_cam_v4"
|
||||
FloodlightV1 CameraKind = "floodlight_v1"
|
||||
FloodlightV2 CameraKind = "floodlight_v2"
|
||||
FloodlightPro CameraKind = "floodlight_pro"
|
||||
CocoaCamera CameraKind = "cocoa_camera"
|
||||
CocoaDoorbell CameraKind = "cocoa_doorbell"
|
||||
CocoaFloodlight CameraKind = "cocoa_floodlight"
|
||||
CocoaSpotlight CameraKind = "cocoa_spotlight"
|
||||
StickupCamMini CameraKind = "stickup_cam_mini"
|
||||
OnvifCamera CameraKind = "onvif_camera"
|
||||
)
|
||||
|
||||
const (
|
||||
IntercomHandsetAudio RingDeviceType = "intercom_handset_audio"
|
||||
OnvifCameraType RingDeviceType = "onvif_camera"
|
||||
)
|
||||
|
||||
const (
|
||||
clientAPIBaseURL = "https://api.ring.com/clients_api/"
|
||||
deviceAPIBaseURL = "https://api.ring.com/devices/v1/"
|
||||
commandsAPIBaseURL = "https://api.ring.com/commands/v1/"
|
||||
appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/"
|
||||
oauthURL = "https://oauth.ring.com/oauth/token"
|
||||
apiVersion = 11
|
||||
defaultTimeout = 20 * time.Second
|
||||
maxRetries = 3
|
||||
)
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
switch a := auth.(type) {
|
||||
case RefreshTokenAuth:
|
||||
if a.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
|
||||
config, err := parseAuthConfig(a.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// getAuth makes an authentication request to the Ring API
|
||||
func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||
var grantData map[string]string
|
||||
|
||||
if c.authConfig != nil && twoFactorAuthCode == "" {
|
||||
grantData = map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": c.authConfig.RT,
|
||||
}
|
||||
} else {
|
||||
authEmail, ok := c.auth.(EmailAuth)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid auth type for email authentication")
|
||||
}
|
||||
grantData = map[string]string{
|
||||
"grant_type": "password",
|
||||
"username": authEmail.Email,
|
||||
"password": authEmail.Password,
|
||||
}
|
||||
}
|
||||
|
||||
grantData["client_id"] = "ring_official_android"
|
||||
grantData["scope"] = "client"
|
||||
|
||||
body, err := json.Marshal(grantData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal auth request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 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")
|
||||
if twoFactorAuthCode != "" {
|
||||
req.Header.Set("2fa-code", twoFactorAuthCode)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle 2FA Responses
|
||||
if resp.StatusCode == http.StatusPreconditionFailed ||
|
||||
(resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) {
|
||||
|
||||
var tfaResp Auth2faResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Using2FA = true
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
c.PromptFor2FA = "Invalid 2fa code entered. Please try again."
|
||||
return nil, fmt.Errorf("invalid 2FA code")
|
||||
}
|
||||
|
||||
if tfaResp.TSVState != "" {
|
||||
prompt := "from your authenticator app"
|
||||
if tfaResp.TSVState != "totp" {
|
||||
prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState)
|
||||
}
|
||||
c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt)
|
||||
} else {
|
||||
c.PromptFor2FA = "Please enter the code sent to your text/email"
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("2FA required")
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, 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 nil, fmt.Errorf("failed to decode auth response: %w", err)
|
||||
}
|
||||
|
||||
c.authToken = &authResp
|
||||
c.authConfig = &AuthConfig{
|
||||
RT: authResp.RefreshToken,
|
||||
HID: c.hardwareID,
|
||||
}
|
||||
|
||||
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
||||
if c.onTokenRefresh != nil {
|
||||
c.onTokenRefresh(c.RefreshToken)
|
||||
}
|
||||
|
||||
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) {
|
||||
response, err := c.Request("GET", ClientAPI("ring_devices"), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch ring devices: %w", err)
|
||||
}
|
||||
|
||||
var devices RingDevicesResponse
|
||||
if err := json.Unmarshal(response, &devices); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal devices response: %w", err)
|
||||
}
|
||||
|
||||
// Process "other" devices
|
||||
var onvifCameras []CameraData
|
||||
var intercoms []CameraData
|
||||
|
||||
for _, device := range devices.Other {
|
||||
kind, ok := device["kind"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch RingDeviceType(kind) {
|
||||
case OnvifCameraType:
|
||||
var camera CameraData
|
||||
if deviceJson, err := json.Marshal(device); err == nil {
|
||||
if err := json.Unmarshal(deviceJson, &camera); err == nil {
|
||||
onvifCameras = append(onvifCameras, camera)
|
||||
}
|
||||
}
|
||||
case IntercomHandsetAudio:
|
||||
var intercom CameraData
|
||||
if deviceJson, err := json.Marshal(device); err == nil {
|
||||
if err := json.Unmarshal(deviceJson, &intercom); err == nil {
|
||||
intercoms = append(intercoms, intercom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all cameras into AllCameras slice
|
||||
allCameras := make([]CameraData, 0)
|
||||
allCameras = append(allCameras, interfaceSlice(devices.Doorbots)...)
|
||||
allCameras = append(allCameras, interfaceSlice(devices.StickupCams)...)
|
||||
allCameras = append(allCameras, interfaceSlice(devices.AuthorizedDoorbots)...)
|
||||
allCameras = append(allCameras, interfaceSlice(onvifCameras)...)
|
||||
allCameras = append(allCameras, interfaceSlice(intercoms)...)
|
||||
|
||||
devices.AllCameras = allCameras
|
||||
|
||||
return &devices, nil
|
||||
}
|
||||
|
||||
func (c *RingRestClient) 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)
|
||||
}
|
||||
|
||||
var ticket SocketTicketResponse
|
||||
if err := json.Unmarshal(response, &ticket); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal socket ticket response: %w", err)
|
||||
}
|
||||
|
||||
return &ticket, nil
|
||||
}
|
||||
|
||||
func generateHardwareID() string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("ring-client-go2rtc"))
|
||||
return hex.EncodeToString(h.Sum(nil)[:16])
|
||||
}
|
||||
|
||||
func interfaceSlice(slice interface{}) []CameraData {
|
||||
s := reflect.ValueOf(slice)
|
||||
if s.Kind() != reflect.Slice {
|
||||
return nil
|
||||
}
|
||||
|
||||
ret := make([]CameraData, s.Len())
|
||||
for i := 0; i < s.Len(); i++ {
|
||||
if camera, ok := s.Index(i).Interface().(CameraData); ok {
|
||||
ret[i] = camera
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
api *RingRestClient
|
||||
ws *websocket.Conn
|
||||
prod core.Producer
|
||||
camera *CameraData
|
||||
dialogID string
|
||||
sessionID string
|
||||
wsMutex sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
encodedToken := query.Get("refresh_token")
|
||||
deviceID := query.Get("device_id")
|
||||
_, isSnapshot := query["snapshot"]
|
||||
|
||||
if encodedToken == "" || deviceID == "" {
|
||||
return nil, errors.New("ring: wrong query")
|
||||
}
|
||||
|
||||
// URL-decode the refresh token
|
||||
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)
|
||||
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
|
||||
if isSnapshot {
|
||||
client.prod = NewSnapshotProducer(ringAPI, camera)
|
||||
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"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create Peer Connection
|
||||
conf := pion.Configuration{
|
||||
ICEServers: []pion.ICEServer{
|
||||
{URLs: []string{
|
||||
"stun:stun.kinesisvideo.us-east-1.amazonaws.com:443",
|
||||
"stun:stun.kinesisvideo.us-east-2.amazonaws.com:443",
|
||||
"stun:stun.kinesisvideo.us-west-2.amazonaws.com:443",
|
||||
"stun:stun.l.google.com:19302",
|
||||
"stun:stun1.l.google.com:19302",
|
||||
"stun:stun2.l.google.com:19302",
|
||||
"stun:stun3.l.google.com:19302",
|
||||
"stun:stun4.l.google.com:19302",
|
||||
}},
|
||||
},
|
||||
ICETransportPolicy: pion.ICETransportPolicyAll,
|
||||
BundlePolicy: pion.BundlePolicyBalanced,
|
||||
}
|
||||
|
||||
api, err := webrtc.NewAPI()
|
||||
if err != nil {
|
||||
client.ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc, err := api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
client.ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// protect from sending ICE candidate before Offer
|
||||
var sendOffer core.Waiter
|
||||
|
||||
// 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
|
||||
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
_ = sendOffer.Wait()
|
||||
|
||||
iceCandidate := msg.ToJSON()
|
||||
|
||||
// skip empty ICE candidates
|
||||
if iceCandidate.Candidate == "" {
|
||||
return
|
||||
}
|
||||
|
||||
icePayload := map[string]interface{}{
|
||||
"ice": iceCandidate.Candidate,
|
||||
"mlineindex": iceCandidate.SDPMLineIndex,
|
||||
}
|
||||
|
||||
if err = client.sendSessionMessage("ice", icePayload); err != nil {
|
||||
connState.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
case pion.PeerConnectionStateConnected:
|
||||
connState.Done(nil)
|
||||
default:
|
||||
connState.Done(errors.New("ring: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Setup media configuration
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendRecv,
|
||||
Codecs: []*core.Codec{
|
||||
{
|
||||
Name: "opus",
|
||||
ClockRate: 48000,
|
||||
Channels: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{
|
||||
Name: "H264",
|
||||
ClockRate: 90000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create offer
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send offer
|
||||
offerPayload := map[string]interface{}{
|
||||
"stream_options": map[string]bool{
|
||||
"audio_enabled": true,
|
||||
"video_enabled": true,
|
||||
},
|
||||
"sdp": offer,
|
||||
}
|
||||
|
||||
if err = client.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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) startPingLoop(pc *pion.PeerConnection) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if pc.ConnectionState() == pion.PeerConnectionStateConnected {
|
||||
if err := c.sendSessionMessage("ping", nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
return
|
||||
default:
|
||||
var res BaseMessage
|
||||
if err = c.ws.ReadJSON(&res); err != nil {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
c.Stop()
|
||||
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":
|
||||
c.Stop()
|
||||
return
|
||||
|
||||
case "pong":
|
||||
// Ignore
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return c.prod.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
|
||||
if media.Kind == core.KindAudio {
|
||||
// Enable speaker
|
||||
speakerPayload := map[string]interface{}{
|
||||
"stealth_mode": false,
|
||||
}
|
||||
_ = c.sendSessionMessage("camera_options", speakerPayload)
|
||||
}
|
||||
return webrtcProd.AddTrack(media, codec, track)
|
||||
}
|
||||
|
||||
return fmt.Errorf("add track not supported for snapshot")
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
return c.prod.Start()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
return nil
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
|
||||
return webrtcProd.MarshalJSON()
|
||||
}
|
||||
|
||||
return json.Marshal(c.prod)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type SnapshotProducer struct {
|
||||
core.Connection
|
||||
|
||||
client *RingRestClient
|
||||
camera *CameraData
|
||||
}
|
||||
|
||||
func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer {
|
||||
return &SnapshotProducer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "ring/snapshot",
|
||||
Protocol: "https",
|
||||
RemoteAddr: "app-snaps.ring.com",
|
||||
Medias: []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{
|
||||
Name: core.CodecJPEG,
|
||||
ClockRate: 90000,
|
||||
PayloadType: core.PayloadTypeRAW,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
client: client,
|
||||
camera: camera,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: core.Now90000()},
|
||||
Payload: response,
|
||||
}
|
||||
|
||||
p.Receivers[0].WriteRTP(pkt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SnapshotProducer) Stop() error {
|
||||
return p.Connection.Stop()
|
||||
}
|
||||
+2
-1
@@ -7,11 +7,12 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## Tests
|
||||
|
||||
- go2rtc rtmp client => Reolink
|
||||
- go2rtc rtmp server <= Dahua
|
||||
- go2rtc rtmp publish => YouTube
|
||||
- go2rtc rtmp publish => Telegram
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
|
||||
+15
-6
@@ -46,7 +46,7 @@ func (c *Conn) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Conn) readResponse(transID float64) ([]any, error) {
|
||||
func (c *Conn) readResponse(wait func(items []any) bool) ([]any, error) {
|
||||
for {
|
||||
msgType, _, b, err := c.readMessage()
|
||||
if err != nil {
|
||||
@@ -59,7 +59,7 @@ func (c *Conn) readResponse(transID float64) ([]any, error) {
|
||||
c.rdPacketSize = binary.BigEndian.Uint32(b)
|
||||
case TypeCommand:
|
||||
items, _ := amf.NewReader(b).ReadItems()
|
||||
if len(items) >= 3 && (items[1] == transID || items[1] == float64(0)) {
|
||||
if wait(items) {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,9 @@ func (c *Conn) writeConnect() error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := c.readResponse(1)
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "_result" && items[1] == float64(1)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -280,7 +282,9 @@ func (c *Conn) writeCreateStream() error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := c.readResponse(4)
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "_result" && items[1] == float64(4)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -301,7 +305,10 @@ func (c *Conn) writePublish() error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := c.readResponse(5)
|
||||
// YouTube can response with "onBWDone 0"
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "onStatus"
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -321,7 +328,9 @@ func (c *Conn) writePlay() error {
|
||||
}
|
||||
|
||||
// Reolink response with ID=0, other software respose with ID=5
|
||||
v, err := c.readResponse(5)
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "onStatus"
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
+52
-13
@@ -2,10 +2,13 @@ package rtmp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/flv/amf"
|
||||
@@ -34,23 +37,54 @@ func NewServer(conn net.Conn) (*Conn, error) {
|
||||
}
|
||||
|
||||
func (c *Conn) serverHandshake() error {
|
||||
b := make([]byte, 1+1536)
|
||||
// read C0+C1
|
||||
// based on https://rtmp.veriskope.com/docs/spec/
|
||||
_ = c.conn.SetDeadline(time.Now().Add(core.ConnDeadline))
|
||||
|
||||
// read C0
|
||||
b := make([]byte, 1)
|
||||
if _, err := io.ReadFull(c.rd, b); err != nil {
|
||||
return err
|
||||
}
|
||||
// write S0+S1, skip random
|
||||
|
||||
if b[0] != 3 {
|
||||
return errors.New("rtmp: wrong handshake")
|
||||
}
|
||||
|
||||
// write S0
|
||||
if _, err := c.conn.Write([]byte{3}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = make([]byte, 1536)
|
||||
|
||||
// write S1
|
||||
tsS1 := nowMS()
|
||||
binary.BigEndian.PutUint32(b, tsS1)
|
||||
binary.BigEndian.PutUint32(b[4:], 0)
|
||||
_, _ = rand.Read(b[8:])
|
||||
if _, err := c.conn.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
// read S1, skip check
|
||||
if _, err := io.ReadFull(c.rd, make([]byte, 1536)); err != nil {
|
||||
|
||||
// read C1
|
||||
if _, err := io.ReadFull(c.rd, b); err != nil {
|
||||
return err
|
||||
}
|
||||
// write C1
|
||||
if _, err := c.conn.Write(b[1:]); err != nil {
|
||||
|
||||
// write S2
|
||||
tsS2 := nowMS()
|
||||
binary.BigEndian.PutUint32(b, tsS1)
|
||||
binary.BigEndian.PutUint32(b[4:], tsS2)
|
||||
if _, err := c.conn.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// read C2
|
||||
if _, err := io.ReadFull(c.rd, b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = c.conn.SetDeadline(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -117,10 +151,6 @@ func (c *Conn) acceptCommand(b []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.App == "" {
|
||||
return fmt.Errorf("rtmp: read command %x", b)
|
||||
}
|
||||
|
||||
payload := amf.EncodeItems(
|
||||
"_result", tID,
|
||||
map[string]any{"fmsVer": "FMS/3,0,1,123"},
|
||||
@@ -129,9 +159,16 @@ func (c *Conn) acceptCommand(b []byte) error {
|
||||
return c.writeMessage(3, TypeCommand, 0, payload)
|
||||
|
||||
case CommandReleaseStream:
|
||||
// if app is empty - will use key as app
|
||||
if c.App == "" && len(items) == 4 {
|
||||
c.App, _ = items[3].(string)
|
||||
}
|
||||
|
||||
payload := amf.EncodeItems("_result", tID, nil)
|
||||
return c.writeMessage(3, TypeCommand, 0, payload)
|
||||
|
||||
case CommandFCPublish: // no response
|
||||
|
||||
case CommandCreateStream:
|
||||
payload := amf.EncodeItems("_result", tID, nil, 1)
|
||||
return c.writeMessage(3, TypeCommand, 0, payload)
|
||||
@@ -140,8 +177,6 @@ func (c *Conn) acceptCommand(b []byte) error {
|
||||
c.Intent = cmd
|
||||
c.streamID = 1
|
||||
|
||||
case CommandFCPublish: // no response
|
||||
|
||||
default:
|
||||
println("rtmp: unknown command: " + cmd)
|
||||
}
|
||||
@@ -160,3 +195,7 @@ func (c *Conn) WriteStart() error {
|
||||
payload := amf.EncodeItems("onStatus", 0, nil, map[string]any{"code": code})
|
||||
return c.writeMessage(3, TypeCommand, 0, payload)
|
||||
}
|
||||
|
||||
func nowMS() uint32 {
|
||||
return uint32(time.Now().UnixNano() / int64(time.Millisecond))
|
||||
}
|
||||
|
||||
+2
-4
@@ -237,13 +237,11 @@ func (c *Conn) SetupMedia(media *core.Media) (byte, error) {
|
||||
rawURL := media.ID // control
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
rawURL = c.URL.String()
|
||||
if !strings.HasSuffix(rawURL, "/") {
|
||||
// prefix check for https://github.com/AlexxIT/go2rtc/issues/1236
|
||||
if !strings.HasSuffix(rawURL, "/") && !strings.HasPrefix(media.ID, "/") {
|
||||
rawURL += "/"
|
||||
}
|
||||
rawURL += media.ID
|
||||
} else if strings.HasPrefix(rawURL, "rtsp://rtsp://") {
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/830
|
||||
rawURL = rawURL[7:]
|
||||
}
|
||||
trackURL, err := urlParse(rawURL)
|
||||
if err != nil {
|
||||
|
||||
+20
-2
@@ -70,8 +70,25 @@ func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) {
|
||||
// Check buggy SDP with fmtp for H264 on another track
|
||||
// https://github.com/AlexxIT/WebRTC/issues/419
|
||||
for _, codec := range media.Codecs {
|
||||
if codec.Name == core.CodecH264 && codec.FmtpLine == "" {
|
||||
codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions)
|
||||
switch codec.Name {
|
||||
case core.CodecH264:
|
||||
if codec.FmtpLine == "" {
|
||||
codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions)
|
||||
}
|
||||
case core.CodecH265:
|
||||
if codec.FmtpLine != "" {
|
||||
// all three parameters are needed for a valid fmtp line
|
||||
// https://github.com/AlexxIT/go2rtc/pull/1588
|
||||
if !strings.Contains(codec.FmtpLine, "sprop-vps=") ||
|
||||
!strings.Contains(codec.FmtpLine, "sprop-sps=") ||
|
||||
!strings.Contains(codec.FmtpLine, "sprop-pps=") {
|
||||
codec.FmtpLine = ""
|
||||
}
|
||||
}
|
||||
case core.CodecOpus:
|
||||
// fix OPUS for some cameras https://datatracker.ietf.org/doc/html/rfc7587
|
||||
codec.ClockRate = 48000
|
||||
codec.Channels = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +117,7 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin
|
||||
// 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/
|
||||
// 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/
|
||||
func urlParse(rawURL string) (*url.URL, error) {
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/830
|
||||
if strings.HasPrefix(rawURL, "rtsp://rtsp://") {
|
||||
rawURL = rawURL[7:]
|
||||
}
|
||||
|
||||
+19
-8
@@ -19,19 +19,30 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
if c.state == StatePlay {
|
||||
if err := c.Reconnect(); err != nil {
|
||||
var channel byte
|
||||
|
||||
switch c.mode {
|
||||
case core.ModeActiveProducer:
|
||||
if c.state == StatePlay {
|
||||
if err := c.Reconnect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
channel, err = c.SetupMedia(media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
channel, err := c.SetupMedia(media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
c.state = StateSetup
|
||||
case core.ModePassiveConsumer:
|
||||
// Backchannel
|
||||
channel = byte(len(c.Senders)) * 2
|
||||
default:
|
||||
return nil, errors.New("rtsp: wrong mode for GetTrack")
|
||||
}
|
||||
|
||||
c.state = StateSetup
|
||||
|
||||
track := core.NewReceiver(media, codec)
|
||||
track.ID = channel
|
||||
c.Receivers = append(c.Receivers, track)
|
||||
|
||||
+29
-10
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
var FailedAuth = errors.New("failed authentication")
|
||||
|
||||
func NewServer(conn net.Conn) *Conn {
|
||||
return &Conn{
|
||||
Connection: core.Connection{
|
||||
@@ -45,7 +47,7 @@ func (c *Conn) Accept() error {
|
||||
|
||||
c.Fire(req)
|
||||
|
||||
if !c.auth.Validate(req) {
|
||||
if valid, empty := c.auth.Validate(req); !valid {
|
||||
res := &tcp.Response{
|
||||
Status: "401 Unauthorized",
|
||||
Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}},
|
||||
@@ -54,7 +56,12 @@ func (c *Conn) Accept() error {
|
||||
if err = c.WriteResponse(res); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
if empty {
|
||||
// eliminate false positive: ffmpeg sends first request without
|
||||
// authorization header even if the user provides credentials
|
||||
continue
|
||||
}
|
||||
return FailedAuth
|
||||
}
|
||||
|
||||
// Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN
|
||||
@@ -129,6 +136,16 @@ func (c *Conn) Accept() error {
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
for i, track := range c.Receivers {
|
||||
media := &core.Media{
|
||||
Kind: core.GetKind(track.Codec.Name),
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{track.Codec},
|
||||
ID: "trackID=" + strconv.Itoa(i+len(c.Senders)),
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
res.Body, err = core.MarshalSDP(c.SessionName, medias)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -141,29 +158,31 @@ func (c *Conn) Accept() error {
|
||||
}
|
||||
|
||||
case MethodSetup:
|
||||
tr := req.Header.Get("Transport")
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{},
|
||||
Request: req,
|
||||
}
|
||||
|
||||
const transport = "RTP/AVP/TCP;unicast;interleaved="
|
||||
if strings.HasPrefix(tr, transport) {
|
||||
// Test if client requests TCP transport, otherwise return 461 Transport not supported
|
||||
// This allows smart clients who initially requested UDP to fall back on TCP transport
|
||||
if tr := req.Header.Get("Transport"); strings.HasPrefix(tr, "RTP/AVP/TCP") {
|
||||
c.session = core.RandString(8, 10)
|
||||
c.state = StateSetup
|
||||
|
||||
if c.mode == core.ModePassiveConsumer {
|
||||
if i := reqTrackID(req); i >= 0 && i < len(c.Senders) {
|
||||
// mark sender as SETUP
|
||||
c.Senders[i].Media.ID = MethodSetup
|
||||
if i := reqTrackID(req); i >= 0 && i < len(c.Senders)+len(c.Receivers) {
|
||||
if i < len(c.Senders) {
|
||||
c.Senders[i].Media.ID = MethodSetup
|
||||
} else {
|
||||
c.Receivers[i-len(c.Senders)].Media.ID = MethodSetup
|
||||
}
|
||||
tr = fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1)
|
||||
res.Header.Set("Transport", tr)
|
||||
} else {
|
||||
res.Status = "400 Bad Request"
|
||||
}
|
||||
} else {
|
||||
res.Header.Set("Transport", tr[:len(transport)+3])
|
||||
res.Header.Set("Transport", tr)
|
||||
}
|
||||
} else {
|
||||
res.Status = "461 Unsupported transport"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Command like exec.Cmd, but with support:
|
||||
// - io.Closer interface
|
||||
// - Wait from multiple places
|
||||
// - Done channel
|
||||
type Command struct {
|
||||
*exec.Cmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
err error
|
||||
}
|
||||
|
||||
func NewCommand(s string) *Command {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
args := QuoteSplit(s)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.SysProcAttr = procAttr
|
||||
return &Command{cmd, ctx, cancel, nil}
|
||||
}
|
||||
|
||||
func (c *Command) Start() error {
|
||||
if err := c.Cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
c.err = c.Cmd.Wait()
|
||||
c.cancel() // release context resources
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Command) Wait() error {
|
||||
<-c.ctx.Done()
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *Command) Run() error {
|
||||
if err := c.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Wait()
|
||||
}
|
||||
|
||||
func (c *Command) Done() <-chan struct{} {
|
||||
return c.ctx.Done()
|
||||
}
|
||||
|
||||
func (c *Command) Close() error {
|
||||
c.cancel()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package shell
|
||||
|
||||
import "syscall"
|
||||
|
||||
var procAttr *syscall.SysProcAttr
|
||||
@@ -0,0 +1,6 @@
|
||||
package shell
|
||||
|
||||
import "syscall"
|
||||
|
||||
// will stop child if parent died (even with SIGKILL)
|
||||
var procAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
|
||||
+9
-2
@@ -65,6 +65,13 @@ func (s *Server) DelSession(session *Session) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) GetSession(ssrc uint32) (session *Session) {
|
||||
s.mu.Lock()
|
||||
session = s.sessions[ssrc]
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handle() error {
|
||||
b := make([]byte, 2048)
|
||||
for {
|
||||
@@ -80,14 +87,14 @@ func (s *Server) handle() error {
|
||||
case 99, 110, 0x80 | 99, 0x80 | 110:
|
||||
// this is default position for SSRC in RTP packet
|
||||
ssrc := binary.BigEndian.Uint32(b[8:])
|
||||
if session, ok := s.sessions[ssrc]; ok {
|
||||
if session := s.GetSession(ssrc); session != nil {
|
||||
session.ReadRTP(b[:n])
|
||||
}
|
||||
|
||||
case 200, 201, 202, 203, 204, 205, 206, 207:
|
||||
// this is default position for SSRC in RTCP packet
|
||||
ssrc := binary.BigEndian.Uint32(b[4:])
|
||||
if session, ok := s.sessions[ssrc]; ok {
|
||||
if session := s.GetSession(ssrc); session != nil {
|
||||
session.ReadRTCP(b[:n])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package stdin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
@@ -42,10 +41,7 @@ func (c *Client) Stop() (err error) {
|
||||
if c.sender != nil {
|
||||
c.sender.Close()
|
||||
}
|
||||
if c.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(c.cmd.Process.Kill(), c.cmd.Wait())
|
||||
return c.cmd.Close()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
|
||||
+3
-4
@@ -1,21 +1,20 @@
|
||||
package stdin
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
// Deprecated: should be rewritten to core.Connection
|
||||
type Client struct {
|
||||
cmd *exec.Cmd
|
||||
cmd *shell.Command
|
||||
|
||||
medias []*core.Media
|
||||
sender *core.Sender
|
||||
send int
|
||||
}
|
||||
|
||||
func NewClient(cmd *exec.Cmd) (*Client, error) {
|
||||
func NewClient(cmd *shell.Command) (*Client, error) {
|
||||
c := &Client{
|
||||
cmd: cmd,
|
||||
medias: []*core.Media{
|
||||
|
||||
+75
-32
@@ -27,7 +27,7 @@ import (
|
||||
type Client struct {
|
||||
core.Listener
|
||||
|
||||
url string
|
||||
url *url.URL
|
||||
|
||||
medias []*core.Media
|
||||
receivers []*core.Receiver
|
||||
@@ -52,17 +52,15 @@ type cbcMode interface {
|
||||
SetIV([]byte)
|
||||
}
|
||||
|
||||
func Dial(url string) (*Client, error) {
|
||||
var err error
|
||||
c := &Client{url: url}
|
||||
if c.conn1, err = c.newConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) newConn() (net.Conn, error) {
|
||||
u, err := url.Parse(c.url)
|
||||
// Dial support different urls:
|
||||
// - tapo://{cloud-password}@192.168.1.123 - auth to Tapo cameras
|
||||
// with cloud password (autodetect hash method)
|
||||
// - tapo://admin:{hashed-cloud-password}@192.168.1.123 - auth to Tapo cameras
|
||||
// with pre-hashed cloud password
|
||||
// - vigi://admin:{password}@192.168.1.123 - auth to Vigi cameras with password
|
||||
// for admin account (other not supported)
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,21 +69,31 @@ func (c *Client) newConn() (net.Conn, error) {
|
||||
u.Host += ":8800"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "http://"+u.Host+"/stream", nil)
|
||||
c := &Client{url: u}
|
||||
if c.conn1, err = c.newConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) newConn() (net.Conn, error) {
|
||||
req, err := http.NewRequest("POST", "http://"+c.url.Host+"/stream", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query := c.url.Query()
|
||||
|
||||
if deviceId := query.Get("deviceId"); deviceId != "" {
|
||||
req.URL.RawQuery = "deviceId=" + deviceId
|
||||
}
|
||||
|
||||
req.URL.User = u.User
|
||||
req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--")
|
||||
|
||||
conn, res, err := dial(req)
|
||||
username := c.url.User.Username()
|
||||
password, _ := c.url.User.Password()
|
||||
|
||||
conn, res, err := dial(req, c.url.Scheme, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,7 +103,7 @@ func (c *Client) newConn() (net.Conn, error) {
|
||||
}
|
||||
|
||||
if c.decrypt == nil {
|
||||
c.newDectypter(res)
|
||||
c.newDectypter(res, c.url.Scheme, username, password)
|
||||
}
|
||||
|
||||
channel := query.Get("channel")
|
||||
@@ -119,14 +127,18 @@ func (c *Client) newConn() (net.Conn, error) {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Client) newDectypter(res *http.Response) {
|
||||
username := res.Request.URL.User.Username()
|
||||
password, _ := res.Request.URL.User.Password()
|
||||
func (c *Client) newDectypter(res *http.Response, brand, username, password string) {
|
||||
exchange := res.Header.Get("Key-Exchange")
|
||||
nonce := core.Between(exchange, `nonce="`, `"`)
|
||||
|
||||
// extract nonce from response
|
||||
// cipher="AES_128_CBC" username="admin" padding="PKCS7_16" algorithm="MD5" nonce="***"
|
||||
nonce := res.Header.Get("Key-Exchange")
|
||||
nonce = core.Between(nonce, `nonce="`, `"`)
|
||||
if brand == "tapo" && password == "" {
|
||||
if strings.Contains(exchange, `encrypt_type="3"`) {
|
||||
password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username)))
|
||||
} else {
|
||||
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
|
||||
}
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
key := md5.Sum([]byte(nonce + ":" + password))
|
||||
iv := md5.Sum([]byte(username + ":" + nonce))
|
||||
@@ -263,16 +275,12 @@ func (c *Client) Request(conn net.Conn, body []byte) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func dial(req *http.Request) (net.Conn, *http.Response, error) {
|
||||
func dial(req *http.Request, brand, username, password string) (net.Conn, *http.Response, error) {
|
||||
conn, err := net.DialTimeout("tcp", req.URL.Host, core.ConnDialTimeout)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
username := req.URL.User.Username()
|
||||
password, _ := req.URL.User.Password()
|
||||
req.URL.User = nil
|
||||
|
||||
if err = req.Write(conn); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -283,6 +291,7 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
||||
_ = res.Body.Close() // ignore response body
|
||||
|
||||
auth := res.Header.Get("WWW-Authenticate")
|
||||
@@ -291,7 +300,7 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
|
||||
return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
if brand == "tapo" && password == "" {
|
||||
// support cloud password in place of username
|
||||
if strings.Contains(auth, `encrypt_type="3"`) {
|
||||
password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username)))
|
||||
@@ -299,6 +308,8 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
|
||||
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
|
||||
}
|
||||
username = "admin"
|
||||
} else if brand == "vigi" && username == "admin" {
|
||||
password = securityEncode(password)
|
||||
}
|
||||
|
||||
realm := tcp.Between(auth, `realm="`, `"`)
|
||||
@@ -331,7 +342,39 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
req.URL.User = url.UserPassword(username, password)
|
||||
|
||||
return conn, res, nil
|
||||
}
|
||||
|
||||
const (
|
||||
keyShort = "RDpbLfCPsJZ7fiv"
|
||||
keyLong = "yLwVl0zKqws7LgKPRQ84Mdt708T1qQ3Ha7xv3H7NyU84p21BriUWBU43odz3iP4rBL3cD02KZciXTysVXiV8ngg6vL48rPJyAUw0HurW20xqxv9aYb4M9wK1Ae0wlro510qXeU07kV57fQMc8L6aLgMLwygtc0F10a0Dg70TOoouyFhdysuRMO51yY5ZlOZZLEal1h0t9YQW0Ko7oBwmCAHoic4HYbUyVeU3sfQ1xtXcPcf1aT303wAQhv66qzW"
|
||||
)
|
||||
|
||||
func securityEncode(s string) string {
|
||||
size := len(s)
|
||||
|
||||
var n int // max
|
||||
if size > len(keyShort) {
|
||||
n = size
|
||||
} else {
|
||||
n = len(keyShort)
|
||||
}
|
||||
|
||||
b := make([]byte, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
c1 := 187
|
||||
c2 := 187
|
||||
if i >= size {
|
||||
c1 = int(keyShort[i])
|
||||
} else if i >= len(keyShort) {
|
||||
c2 = int(s[i])
|
||||
} else {
|
||||
c1 = int(keyShort[i])
|
||||
c2 = int(s[i])
|
||||
}
|
||||
b[i] = keyLong[(c1^c2)%len(keyLong)]
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (c *Client) Stop() error {
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
info := &core.Connection{
|
||||
ID: core.ID(c),
|
||||
FormatName: "tapo",
|
||||
FormatName: c.url.Scheme,
|
||||
Protocol: "http",
|
||||
Medias: c.medias,
|
||||
Recv: c.recv,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user