Merge branch 'master' of https://github.com/AlexxIT/go2rtc into update-readme
This commit is contained in:
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: '1.24' }
|
||||
with: { go-version: '1.25' }
|
||||
|
||||
- name: Build go2rtc_win64
|
||||
env: { GOOS: windows, GOARCH: amd64 }
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
with: { name: go2rtc_win64, path: go2rtc.exe }
|
||||
|
||||
- name: Build go2rtc_win32
|
||||
env: { GOOS: windows, GOARCH: 386, GOTOOLCHAIN: go1.20.14 }
|
||||
env: { GOOS: windows, GOARCH: 386 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_win32
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
with: { name: go2rtc_linux_mipsel, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_mac_amd64
|
||||
env: { GOOS: darwin, GOARCH: amd64, GOTOOLCHAIN: go1.20.14 }
|
||||
env: { GOOS: darwin, GOARCH: amd64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_mac_amd64
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -44,7 +44,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
||||
- [Fast start](#fast-start)
|
||||
- [go2rtc: Binary](#go2rtc-binary)
|
||||
- [go2rtc: Docker](#go2rtc-docker)
|
||||
- [go2rtc: Home Assistant add-on](#go2rtc-home-assistant-add-on)
|
||||
- [go2rtc: Home Assistant Add-on](#go2rtc-home-assistant-add-on)
|
||||
- [go2rtc: Home Assistant Integration](#go2rtc-home-assistant-integration)
|
||||
- [go2rtc: Dev version](#go2rtc-dev-version)
|
||||
- [Configuration](#configuration)
|
||||
@@ -69,14 +69,14 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
||||
- [Source: Hass](#source-hass)
|
||||
- [Source: ISAPI](#source-isapi)
|
||||
- [Source: Nest](#source-nest)
|
||||
- [Source: Ring](#source-ring)
|
||||
- [Source: Roborock](#source-roborock)
|
||||
- [Source: WebRTC](#source-webrtc)
|
||||
- [Source: WebTorrent](#source-webtorrent)
|
||||
- [Incoming sources](#incoming-sources)
|
||||
- [Incoming: Browser](#incoming-browser)
|
||||
- [Incoming: WebRTC/WHIP](#incoming-webrtcwhip)
|
||||
- [Stream to camera](#stream-to-camera)
|
||||
- [Publish stream](#publish-stream)
|
||||
- [Publish stream](#publish-stream)
|
||||
- [Preload stream](#preload-stream)
|
||||
- [Module: API](#module-api)
|
||||
- [Module: RTSP](#module-rtsp)
|
||||
- [Module: RTMP](#module-rtmp)
|
||||
@@ -92,11 +92,10 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
||||
- [Security](#security)
|
||||
- [Codecs filters](#codecs-filters)
|
||||
- [Codecs madness](#codecs-madness)
|
||||
- [Built-in transcoding](#built-in-transcoding)
|
||||
- [Codecs negotiation](#codecs-negotiation)
|
||||
- [Projects using go2rtc](#projects-using-go2rtc)
|
||||
- [Camera experience](#camera-experience)
|
||||
- [Tips](#tips)
|
||||
- [TIPS](#tips)
|
||||
- [FAQ](#faq)
|
||||
|
||||
## Fast start
|
||||
@@ -119,7 +118,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
||||
Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/):
|
||||
|
||||
- `go2rtc_win64.zip` - Windows 10+ 64-bit
|
||||
- `go2rtc_win32.zip` - Windows 7+ 32-bit
|
||||
- `go2rtc_win32.zip` - Windows 10+ 32-bit
|
||||
- `go2rtc_win_arm64.zip` - Windows ARM 64-bit
|
||||
- `go2rtc_linux_amd64` - Linux 64-bit
|
||||
- `go2rtc_linux_i386` - Linux 32-bit
|
||||
@@ -127,7 +126,7 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
- `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS)
|
||||
- `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero)
|
||||
- `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks))
|
||||
- `go2rtc_mac_amd64.zip` - macOS 10.13+ Intel 64-bit
|
||||
- `go2rtc_mac_amd64.zip` - macOS 11+ Intel 64-bit
|
||||
- `go2rtc_mac_arm64.zip` - macOS ARM 64-bit
|
||||
- `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit
|
||||
- `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit
|
||||
@@ -202,6 +201,7 @@ Available source types:
|
||||
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
|
||||
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR
|
||||
- [tapo](#source-tapo) - TP-Link Tapo cameras with [two-way audio](#two-way-audio) support
|
||||
- [ring](#source-ring) - Ring cameras with [two-way audio](#two-way-audio) support
|
||||
- [kasa](#source-tapo) - TP-Link Kasa cameras
|
||||
- [gopro](#source-gopro) - GoPro cameras
|
||||
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
|
||||
@@ -223,6 +223,7 @@ Supported sources:
|
||||
- [Hikvision ISAPI](#source-isapi) cameras
|
||||
- [Roborock vacuums](#source-roborock) models with cameras
|
||||
- [Exec](#source-exec) audio on server
|
||||
- [Ring](#source-ring) cameras
|
||||
- [Any Browser](#incoming-browser) as IP-camera
|
||||
|
||||
Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)).
|
||||
@@ -534,7 +535,7 @@ streams:
|
||||
|
||||
- stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/)
|
||||
- use the **cloud password**, this is not the RTSP password! you do not need to add a login!
|
||||
- you can also use UPPERCASE MD5 hash from your cloud password with `admin` username
|
||||
- you can also use **UPPERCASE** MD5 hash from your cloud password with `admin` username
|
||||
- some new camera firmwares require SHA256 instead of MD5
|
||||
|
||||
```yaml
|
||||
@@ -545,6 +546,10 @@ streams:
|
||||
camera2: tapo://admin:UPPERCASE-MD5@192.168.1.123
|
||||
# admin username and UPPERCASE SHA256 cloud-password hash
|
||||
camera3: tapo://admin:UPPERCASE-SHA256@192.168.1.123
|
||||
# VGA stream (the so called substream, the lower resolution one)
|
||||
camera4: tapo://cloud-password@192.168.1.123?subtype=1
|
||||
# HD stream (default)
|
||||
camera5: tapo://cloud-password@192.168.1.123?subtype=0
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -645,6 +650,16 @@ streams:
|
||||
nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=***
|
||||
```
|
||||
|
||||
#### Source: Ring
|
||||
|
||||
This source type support Ring cameras with [two-way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras.
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
ring: ring:?device_id=XXX&refresh_token=XXX
|
||||
ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot
|
||||
```
|
||||
|
||||
#### Source: Roborock
|
||||
|
||||
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
|
||||
@@ -828,6 +843,26 @@ streams:
|
||||
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
|
||||
- **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key.
|
||||
|
||||
### Preload stream
|
||||
|
||||
You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up.
|
||||
|
||||
```yaml
|
||||
preload:
|
||||
camera1: # default: video&audio = ANY
|
||||
camera2: "video" # preload only video track
|
||||
camera3: "video=h264&audio=opus" # preload H264 video and OPUS audio
|
||||
|
||||
streams:
|
||||
camera1:
|
||||
- rtsp://192.168.1.100/stream
|
||||
camera2:
|
||||
- rtsp://192.168.1.101/stream
|
||||
camera3:
|
||||
- rtsp://192.168.1.102/h265stream
|
||||
- ffmpeg:camera3#video=h264#audio=opus#hardware
|
||||
```
|
||||
|
||||
### Module: API
|
||||
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`.
|
||||
@@ -1387,6 +1422,7 @@ streams:
|
||||
- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices
|
||||
- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module
|
||||
- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge
|
||||
- [lightNVR](https://github.com/opensensor/lightNVR)
|
||||
|
||||
**Distributions**
|
||||
|
||||
@@ -1394,7 +1430,7 @@ streams:
|
||||
- [Arch User Repository](https://linux-packages.com/aur/package/go2rtc)
|
||||
- [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc)
|
||||
- [NixOS](https://search.nixos.org/packages?query=go2rtc)
|
||||
- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/)
|
||||
- [Proxmox Helper Scripts](https://github.com/community-scripts/ProxmoxVE/)
|
||||
- [QNAP](https://www.myqnap.org/product/go2rtc/)
|
||||
- [Synology NAS](https://synocommunity.com/package/go2rtc)
|
||||
- [Unraid](https://unraid.net/community/apps?q=go2rtc)
|
||||
|
||||
@@ -237,6 +237,54 @@ paths:
|
||||
|
||||
|
||||
|
||||
/api/preload:
|
||||
put:
|
||||
summary: Preload new stream
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (name)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "camera1"
|
||||
- name: video
|
||||
in: query
|
||||
description: Video codecs filter
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: all,h264,h265,...
|
||||
- name: audio
|
||||
in: query
|
||||
description: Audio codecs filter
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: all,aac,opus,...
|
||||
- name: microphone
|
||||
in: query
|
||||
description: Microphone codecs filter
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: all,aac,opus,...
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
delete:
|
||||
summary: Delete preloaded stream
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (name)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "camera1"
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/api/streams?src={src}:
|
||||
get:
|
||||
summary: Get stream info in JSON format
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.24"
|
||||
ARG GO_VERSION="1.25"
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# only debian 13 (trixie) has latest ffmpeg
|
||||
# https://packages.debian.org/trixie/ffmpeg
|
||||
ARG DEBIAN_VERSION="trixie-slim"
|
||||
ARG GO_VERSION="1.24-bookworm"
|
||||
ARG GO_VERSION="1.25-bookworm"
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.13-slim-bookworm"
|
||||
ARG GO_VERSION="1.24-bookworm"
|
||||
ARG GO_VERSION="1.25-bookworm"
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
module github.com/AlexxIT/go2rtc
|
||||
|
||||
go 1.20
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astits v1.13.0
|
||||
github.com/expr-lang/expr v1.17.2
|
||||
github.com/expr-lang/expr v1.17.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/miekg/dns v1.1.63
|
||||
github.com/pion/ice/v4 v4.0.9
|
||||
github.com/pion/interceptor v0.1.37
|
||||
github.com/miekg/dns v1.1.66
|
||||
github.com/pion/ice/v4 v4.0.10
|
||||
github.com/pion/interceptor v0.1.40
|
||||
github.com/pion/rtcp v1.2.15
|
||||
github.com/pion/rtp v1.8.13
|
||||
github.com/pion/sdp/v3 v3.0.11
|
||||
github.com/pion/srtp/v3 v3.0.4
|
||||
github.com/pion/rtp v1.8.20
|
||||
github.com/pion/sdp/v3 v3.0.14
|
||||
github.com/pion/srtp/v3 v3.0.6
|
||||
github.com/pion/stun/v3 v3.0.0
|
||||
github.com/pion/webrtc/v4 v4.0.14
|
||||
github.com/pion/webrtc/v4 v4.1.3
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astikit v0.54.0 // indirect
|
||||
github.com/asticode/go-astikit v0.56.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.37 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/mod v0.20.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ=
|
||||
github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw=
|
||||
github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
|
||||
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
@@ -10,6 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso=
|
||||
github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
|
||||
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -28,16 +32,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
||||
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk=
|
||||
github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
|
||||
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
@@ -46,20 +58,32 @@ github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg=
|
||||
github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||
github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
|
||||
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs=
|
||||
github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI=
|
||||
github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
|
||||
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
|
||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg=
|
||||
github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk=
|
||||
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
||||
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
||||
@@ -84,19 +108,31 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
|
||||
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
@@ -132,7 +133,8 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
if handler := wsHandlers[msg.Type]; handler != nil {
|
||||
go func() {
|
||||
if err = handler(tr, msg); err != nil {
|
||||
tr.Write(&Message{Type: "error", Value: msg.Type + ": " + err.Error()})
|
||||
errMsg := core.StripUserinfo(err.Error())
|
||||
tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||
@@ -18,11 +19,16 @@ func LoadConfig(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
var configMu sync.Mutex
|
||||
|
||||
func PatchConfig(path []string, value any) error {
|
||||
if ConfigPath == "" {
|
||||
return errors.New("config file disabled")
|
||||
}
|
||||
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
// empty config is OK
|
||||
b, _ := os.ReadFile(ConfigPath)
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ var defaults = map[string]string{
|
||||
// `-profile high -level 4.1` - most used streaming profile
|
||||
// `-pix_fmt:v yuv420p` - important for Telegram
|
||||
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v main -x265-params level=5.1:high-tier=0 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||
"mjpeg": "-c:v mjpeg",
|
||||
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
|
||||
|
||||
+14
-10
@@ -1,10 +1,11 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -21,8 +22,7 @@ func Init() {
|
||||
|
||||
func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
var ringAPI *ring.RingRestClient
|
||||
var err error
|
||||
var ringAPI *ring.RingApi
|
||||
|
||||
// Check auth method
|
||||
if email := query.Get("email"); email != "" {
|
||||
@@ -30,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
password := query.Get("password")
|
||||
code := query.Get("code")
|
||||
|
||||
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
|
||||
var err error
|
||||
ringAPI, err = ring.NewRestClient(ring.EmailAuth{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}, nil)
|
||||
@@ -44,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err = ringAPI.GetAuth(code); err != nil {
|
||||
if ringAPI.Using2FA {
|
||||
// Return 2FA prompt
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
api.ResponseJSON(w, map[string]interface{}{
|
||||
"needs_2fa": true,
|
||||
"prompt": ringAPI.PromptFor2FA,
|
||||
})
|
||||
@@ -53,36 +54,39 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
} else if refreshToken := query.Get("refresh_token"); refreshToken != "" {
|
||||
// Refresh Token Flow
|
||||
refreshToken := query.Get("refresh_token")
|
||||
if refreshToken == "" {
|
||||
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{
|
||||
var err error
|
||||
ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{
|
||||
RefreshToken: refreshToken,
|
||||
}, nil)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch devices
|
||||
devices, err := ringAPI.FetchRingDevices()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create clean query with only required parameters
|
||||
cleanQuery := url.Values{}
|
||||
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
|
||||
|
||||
var items []*api.Source
|
||||
for _, camera := range devices.AllCameras {
|
||||
cleanQuery.Set("camera_id", fmt.Sprint(camera.ID))
|
||||
cleanQuery.Set("device_id", camera.DeviceID)
|
||||
|
||||
// Stream source
|
||||
|
||||
+50
-1
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/probe"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cons := probe.NewProbe(query)
|
||||
cons := probe.Create("probe", query)
|
||||
if len(cons.Medias) != 0 {
|
||||
cons.WithRequest(r)
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
@@ -122,3 +123,51 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
api.Response(w, dot, "text/vnd.graphviz")
|
||||
}
|
||||
|
||||
func apiPreload(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
|
||||
// check if stream exists
|
||||
stream := Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
// it's safe to delete from map while iterating
|
||||
for k := range query {
|
||||
switch k {
|
||||
case core.KindVideo, core.KindAudio, "microphone":
|
||||
default:
|
||||
delete(query, k)
|
||||
}
|
||||
}
|
||||
|
||||
rawQuery := query.Encode()
|
||||
|
||||
if err := AddPreload(stream, rawQuery); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
if err := DelPreload(stream); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.PatchConfig([]string{"preload", src}, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/probe"
|
||||
)
|
||||
|
||||
var preloads = map[*Stream]*probe.Probe{}
|
||||
var preloadsMu sync.Mutex
|
||||
|
||||
func Preload(stream *Stream, rawQuery string) {
|
||||
if err := AddPreload(stream, rawQuery); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func AddPreload(stream *Stream, rawQuery string) error {
|
||||
if rawQuery == "" {
|
||||
rawQuery = "video&audio"
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
preloadsMu.Lock()
|
||||
defer preloadsMu.Unlock()
|
||||
|
||||
if cons := preloads[stream]; cons != nil {
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
||||
cons := probe.Create("preload", query)
|
||||
|
||||
if err = stream.AddConsumer(cons); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
preloads[stream] = cons
|
||||
return nil
|
||||
}
|
||||
|
||||
func DelPreload(stream *Stream) error {
|
||||
preloadsMu.Lock()
|
||||
defer preloadsMu.Unlock()
|
||||
|
||||
if cons := preloads[stream]; cons != nil {
|
||||
stream.RemoveConsumer(cons)
|
||||
delete(preloads, stream)
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("streams: preload not found")
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Streams map[string]any `yaml:"streams"`
|
||||
Publish map[string]any `yaml:"publish"`
|
||||
Streams map[string]any `yaml:"streams"`
|
||||
Publish map[string]any `yaml:"publish"`
|
||||
Preload map[string]string `yaml:"preload"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -28,17 +29,24 @@ func Init() {
|
||||
|
||||
api.HandleFunc("api/streams", apiStreams)
|
||||
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
||||
api.HandleFunc("api/preload", apiPreload)
|
||||
|
||||
if cfg.Publish == nil {
|
||||
if cfg.Publish == nil && cfg.Preload == nil {
|
||||
return
|
||||
}
|
||||
|
||||
time.AfterFunc(time.Second, func() {
|
||||
// range for nil map is OK
|
||||
for name, dst := range cfg.Publish {
|
||||
if stream := Get(name); stream != nil {
|
||||
Publish(stream, dst)
|
||||
}
|
||||
}
|
||||
for name, rawQuery := range cfg.Preload {
|
||||
if stream := Get(name); stream != nil {
|
||||
Preload(stream, rawQuery)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Version = "1.9.9"
|
||||
app.Version = "1.9.10"
|
||||
|
||||
// 1. Core modules: app, api/ws, streams
|
||||
|
||||
|
||||
@@ -118,3 +118,17 @@ func TestName(t *testing.T) {
|
||||
// stage3
|
||||
_ = prod2.Stop()
|
||||
}
|
||||
|
||||
func TestStripUserinfo(t *testing.T) {
|
||||
s := `streams:
|
||||
test:
|
||||
- ffmpeg:rtsp://username:password@10.1.2.3:554/stream1
|
||||
- ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy
|
||||
`
|
||||
s = StripUserinfo(s)
|
||||
require.Equal(t, `streams:
|
||||
test:
|
||||
- ffmpeg:rtsp://***@10.1.2.3:554/stream1
|
||||
- ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy
|
||||
`, s)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -77,3 +78,14 @@ func Caller() string {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
return file + ":" + strconv.Itoa(line)
|
||||
}
|
||||
|
||||
const (
|
||||
unreserved = `A-Za-z0-9-._~`
|
||||
subdelims = `!$&'()*+,;=`
|
||||
userinfo = unreserved + subdelims + `%:`
|
||||
)
|
||||
|
||||
func StripUserinfo(s string) string {
|
||||
sanitizer := regexp.MustCompile(`://[` + userinfo + `]+@`)
|
||||
return sanitizer.ReplaceAllString(s, `://***@`)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
ps := JoinNALU(sps, pps)
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
// this can happen for FLV from FFmpeg
|
||||
if NALUType(packet.Payload) == NALUTypeSEI {
|
||||
size := int(binary.BigEndian.Uint32(packet.Payload)) + 4
|
||||
packet.Payload = packet.Payload[size:]
|
||||
}
|
||||
if NALUType(packet.Payload) == NALUTypeIFrame {
|
||||
packet.Payload = Join(ps, packet.Payload)
|
||||
}
|
||||
|
||||
+5
-5
@@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
//vps, sps, pps := GetParameterSet(codec.FmtpLine)
|
||||
//ps := h264.EncodeAVC(vps, sps, pps)
|
||||
vps, sps, pps := GetParameterSet(codec.FmtpLine)
|
||||
ps := h264.JoinNALU(vps, sps, pps)
|
||||
|
||||
buf := make([]byte, 0, 512*1024) // 512K
|
||||
var nuStart int
|
||||
@@ -40,9 +40,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
nuType = data[2] & 0x3F
|
||||
|
||||
// push PS data before keyframe
|
||||
//if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
|
||||
// buf = append(buf, ps...)
|
||||
//}
|
||||
if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
|
||||
buf = append(buf, ps...)
|
||||
}
|
||||
|
||||
nuStart = len(buf)
|
||||
buf = append(buf, 0, 0, 0, 0) // NAL unit size
|
||||
|
||||
+13
-13
@@ -12,7 +12,7 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
|
||||
hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware),
|
||||
ServiceCameraRTPStreamManagement(),
|
||||
//hap.ServiceHAPProtocolInformation(),
|
||||
//ServiceMicrophone(),
|
||||
ServiceMicrophone(),
|
||||
},
|
||||
}
|
||||
acc.InitIID()
|
||||
@@ -30,17 +30,17 @@ func ServiceMicrophone() *hap.Service {
|
||||
Perms: hap.EVPRPW,
|
||||
//Descr: "Mute",
|
||||
},
|
||||
{
|
||||
Type: "119",
|
||||
Format: hap.FormatUInt8,
|
||||
Value: 100,
|
||||
Perms: hap.EVPRPW,
|
||||
//Descr: "Volume",
|
||||
//Unit: hap.UnitPercentage,
|
||||
//MinValue: 0,
|
||||
//MaxValue: 100,
|
||||
//MinStep: 1,
|
||||
},
|
||||
//{
|
||||
// Type: "119",
|
||||
// Format: hap.FormatUInt8,
|
||||
// Value: 100,
|
||||
// Perms: hap.EVPRPW,
|
||||
// //Descr: "Volume",
|
||||
// //Unit: hap.UnitPercentage,
|
||||
// //MinValue: 0,
|
||||
// //MaxValue: 100,
|
||||
// //MinStep: 1,
|
||||
//},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
|
||||
VideoAttrs: []VideoAttrs{
|
||||
{Width: 1920, Height: 1080, Framerate: 30},
|
||||
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
|
||||
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
||||
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,11 +71,17 @@ type JSONCharacter struct {
|
||||
Event any `json:"ev,omitempty"`
|
||||
}
|
||||
|
||||
// 4.2.1.2 Invalid Setup Codes
|
||||
const insecurePINs = "00000000 11111111 22222222 33333333 44444444 55555555 66666666 77777777 88888888 99999999 12345678 87654321"
|
||||
|
||||
func SanitizePin(pin string) (string, error) {
|
||||
s := strings.ReplaceAll(pin, "-", "")
|
||||
if len(s) != 8 {
|
||||
return "", errors.New("hap: wrong PIN format: " + pin)
|
||||
}
|
||||
if strings.Contains(insecurePINs, s) {
|
||||
return "", errors.New("hap: insecure PIN: " + pin)
|
||||
}
|
||||
// 123-45-678
|
||||
return s[:3] + "-" + s[3:5] + "-" + s[5:], nil
|
||||
}
|
||||
|
||||
+86
-45
@@ -46,6 +46,8 @@ func Marshal(v any) ([]byte, error) {
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case reflect.Slice:
|
||||
return appendSlice(nil, value)
|
||||
case reflect.Struct:
|
||||
return appendStruct(nil, value)
|
||||
}
|
||||
@@ -53,6 +55,23 @@ func Marshal(v any) ([]byte, error) {
|
||||
return nil, errors.New("tlv8: not implemented: " + kind.String())
|
||||
}
|
||||
|
||||
// separator the most confusing meaning in the documentation.
|
||||
// It can have a value of 0x00 or 0xFF or even 0x05.
|
||||
const separator = 0xFF
|
||||
|
||||
func appendSlice(b []byte, value reflect.Value) ([]byte, error) {
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
if i > 0 {
|
||||
b = append(b, separator, 0)
|
||||
}
|
||||
var err error
|
||||
if b, err = appendStruct(b, value.Index(i)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func appendStruct(b []byte, value reflect.Value) ([]byte, error) {
|
||||
valueType := value.Type()
|
||||
|
||||
@@ -121,7 +140,7 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) {
|
||||
case reflect.Slice:
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
if i > 0 {
|
||||
b = append(b, 0, 0)
|
||||
b = append(b, separator, 0)
|
||||
}
|
||||
if b, err = appendValue(b, tag, value.Index(i)); err != nil {
|
||||
return nil, err
|
||||
@@ -179,64 +198,86 @@ func Unmarshal(data []byte, v any) error {
|
||||
kind = value.Kind()
|
||||
}
|
||||
|
||||
if kind != reflect.Struct {
|
||||
return errors.New("tlv8: not implemented: " + kind.String())
|
||||
switch kind {
|
||||
case reflect.Slice:
|
||||
return unmarshalSlice(data, value)
|
||||
case reflect.Struct:
|
||||
return unmarshalStruct(data, value)
|
||||
}
|
||||
|
||||
return unmarshalStruct(data, value)
|
||||
return errors.New("tlv8: not implemented: " + kind.String())
|
||||
}
|
||||
|
||||
func unmarshalStruct(b []byte, value reflect.Value) error {
|
||||
var waitSlice bool
|
||||
// unmarshalTLV can return two types of errors:
|
||||
// - critical and then the value of []byte will be nil
|
||||
// - not critical and then []byte will contain the value
|
||||
func unmarshalTLV(b []byte, value reflect.Value) ([]byte, error) {
|
||||
if len(b) < 2 {
|
||||
return nil, errors.New("tlv8: wrong size: " + value.Type().Name())
|
||||
}
|
||||
|
||||
for len(b) >= 2 {
|
||||
t := b[0]
|
||||
l := int(b[1])
|
||||
t := b[0]
|
||||
l := int(b[1])
|
||||
|
||||
// array item divider
|
||||
if t == 0 && l == 0 {
|
||||
b = b[2:]
|
||||
waitSlice = true
|
||||
continue
|
||||
// array item divider (t == 0x00 || t == 0xFF)
|
||||
if l == 0 {
|
||||
return b[2:], errors.New("tlv8: zero item")
|
||||
}
|
||||
|
||||
var v []byte
|
||||
|
||||
for {
|
||||
if len(b) < 2+l {
|
||||
return nil, errors.New("tlv8: wrong size: " + value.Type().Name())
|
||||
}
|
||||
|
||||
var v []byte
|
||||
v = append(v, b[2:2+l]...)
|
||||
b = b[2+l:]
|
||||
|
||||
for {
|
||||
if len(b) < 2+l {
|
||||
return errors.New("tlv8: wrong size: " + value.Type().Name())
|
||||
// if size == 255 and same tag - continue read big payload
|
||||
if l < 255 || len(b) < 2 || b[0] != t {
|
||||
break
|
||||
}
|
||||
|
||||
l = int(b[1])
|
||||
}
|
||||
|
||||
tag := strconv.Itoa(int(t))
|
||||
|
||||
valueField, ok := getStructField(value, tag)
|
||||
if !ok {
|
||||
return b, fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name())
|
||||
}
|
||||
|
||||
if err := unmarshalValue(v, valueField); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unmarshalSlice(b []byte, value reflect.Value) error {
|
||||
valueIndex := value.Index(growSlice(value))
|
||||
for len(b) > 0 {
|
||||
var err error
|
||||
if b, err = unmarshalTLV(b, valueIndex); err != nil {
|
||||
if b != nil {
|
||||
valueIndex = value.Index(growSlice(value))
|
||||
continue
|
||||
}
|
||||
|
||||
v = append(v, b[2:2+l]...)
|
||||
b = b[2+l:]
|
||||
|
||||
// if size == 255 and same tag - continue read big payload
|
||||
if l < 255 || len(b) < 2 || b[0] != t {
|
||||
break
|
||||
}
|
||||
|
||||
l = int(b[1])
|
||||
}
|
||||
|
||||
tag := strconv.Itoa(int(t))
|
||||
|
||||
valueField, ok := getStructField(value, tag)
|
||||
if !ok {
|
||||
return fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name())
|
||||
}
|
||||
|
||||
if waitSlice {
|
||||
if valueField.Kind() != reflect.Slice {
|
||||
return fmt.Errorf("tlv8: should be slice T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name())
|
||||
}
|
||||
waitSlice = false
|
||||
}
|
||||
|
||||
if err := unmarshalValue(v, valueField); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalStruct(b []byte, value reflect.Value) error {
|
||||
for len(b) > 0 {
|
||||
var err error
|
||||
if b, err = unmarshalTLV(b, value); b == nil && err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package tlv8
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -107,3 +108,49 @@ func TestInterface(t *testing.T) {
|
||||
|
||||
require.Equal(t, src, dst)
|
||||
}
|
||||
|
||||
func TestSlice1(t *testing.T) {
|
||||
var v struct {
|
||||
VideoAttrs []struct {
|
||||
Width uint16 `tlv8:"1"`
|
||||
Height uint16 `tlv8:"2"`
|
||||
Framerate uint8 `tlv8:"3"`
|
||||
} `tlv8:"3"`
|
||||
}
|
||||
|
||||
s := `030b010280070202380403011e ff00 030b010200050202d00203011e`
|
||||
b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", ""))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = Unmarshal(b1, &v)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, v.VideoAttrs, 2)
|
||||
|
||||
b2, err := Marshal(v)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, b1, b2)
|
||||
}
|
||||
|
||||
func TestSlice2(t *testing.T) {
|
||||
var v []struct {
|
||||
Width uint16 `tlv8:"1"`
|
||||
Height uint16 `tlv8:"2"`
|
||||
Framerate uint8 `tlv8:"3"`
|
||||
}
|
||||
|
||||
s := `010280070202380403011e ff00 010200050202d00203011e`
|
||||
b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", ""))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = Unmarshal(b1, &v)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, v, 2)
|
||||
|
||||
b2, err := Marshal(v)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, b1, b2)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Probe struct {
|
||||
core.Connection
|
||||
}
|
||||
|
||||
func NewProbe(query url.Values) *Probe {
|
||||
func Create(name string, query url.Values) *Probe {
|
||||
medias := core.ParseQuery(query)
|
||||
|
||||
for _, value := range query["microphone"] {
|
||||
@@ -32,39 +32,18 @@ func NewProbe(query url.Values) *Probe {
|
||||
return &Probe{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "probe",
|
||||
FormatName: name,
|
||||
Medias: medias,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Probe) GetMedias() []*core.Media {
|
||||
return p.Medias
|
||||
}
|
||||
|
||||
func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
sender := core.NewSender(media, track.Codec)
|
||||
sender.Bind(track)
|
||||
sender.Handler = func(pkt *core.Packet) {
|
||||
p.Send += len(pkt.Payload)
|
||||
}
|
||||
sender.HandleRTP(track)
|
||||
p.Senders = append(p.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
receiver := core.NewReceiver(media, codec)
|
||||
p.Receivers = append(p.Receivers, receiver)
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func (p *Probe) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Probe) Stop() error {
|
||||
for _, receiver := range p.Receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
for _, sender := range p.Senders {
|
||||
sender.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+365
-208
@@ -11,9 +11,13 @@ import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var clientCache = map[string]*RingApi{}
|
||||
var cacheMutex sync.Mutex
|
||||
|
||||
type RefreshTokenAuth struct {
|
||||
RefreshToken string
|
||||
}
|
||||
@@ -23,13 +27,11 @@ type EmailAuth struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
// AuthConfig represents the decoded refresh token data
|
||||
type AuthConfig struct {
|
||||
RT string `json:"rt"` // Refresh Token
|
||||
HID string `json:"hid"` // Hardware ID
|
||||
}
|
||||
|
||||
// AuthTokenResponse represents the response from the authentication endpoint
|
||||
type AuthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
@@ -46,41 +48,50 @@ type Auth2faResponse struct {
|
||||
NextTimeInSecs int `json:"next_time_in_secs"`
|
||||
}
|
||||
|
||||
// SocketTicketRequest represents the request to get a socket ticket
|
||||
type SocketTicketResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ResponseTimestamp int64 `json:"response_timestamp"`
|
||||
}
|
||||
|
||||
// RingRestClient handles authentication and requests to Ring API
|
||||
type RingRestClient struct {
|
||||
type SessionResponse struct {
|
||||
Profile struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
} `json:"profile"`
|
||||
}
|
||||
|
||||
type RingApi struct {
|
||||
httpClient *http.Client
|
||||
authConfig *AuthConfig
|
||||
hardwareID string
|
||||
authToken *AuthTokenResponse
|
||||
tokenExpiry time.Time
|
||||
Using2FA bool
|
||||
PromptFor2FA string
|
||||
RefreshToken string
|
||||
auth interface{} // EmailAuth or RefreshTokenAuth
|
||||
onTokenRefresh func(string)
|
||||
authMutex sync.Mutex
|
||||
session *SessionResponse
|
||||
sessionExpiry time.Time
|
||||
sessionMutex sync.Mutex
|
||||
cacheKey string
|
||||
}
|
||||
|
||||
// CameraKind represents the different types of Ring cameras
|
||||
type CameraKind string
|
||||
|
||||
// CameraData contains common fields for all camera types
|
||||
type CameraData struct {
|
||||
ID float64 `json:"id"`
|
||||
Description string `json:"description"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Kind string `json:"kind"`
|
||||
LocationID string `json:"location_id"`
|
||||
ID int `json:"id"`
|
||||
Description string `json:"description"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Kind string `json:"kind"`
|
||||
LocationID string `json:"location_id"`
|
||||
}
|
||||
|
||||
// RingDeviceType represents different types of Ring devices
|
||||
type RingDeviceType string
|
||||
|
||||
// RingDevicesResponse represents the response from the Ring API
|
||||
type RingDevicesResponse struct {
|
||||
Doorbots []CameraData `json:"doorbots"`
|
||||
AuthorizedDoorbots []CameraData `json:"authorized_doorbots"`
|
||||
@@ -139,23 +150,49 @@ const (
|
||||
apiVersion = 11
|
||||
defaultTimeout = 20 * time.Second
|
||||
maxRetries = 3
|
||||
sessionValidTime = 12 * time.Hour
|
||||
)
|
||||
|
||||
// NewRingRestClient creates a new Ring client instance
|
||||
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
|
||||
client := &RingRestClient{
|
||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||
onTokenRefresh: onTokenRefresh,
|
||||
hardwareID: generateHardwareID(),
|
||||
auth: auth,
|
||||
}
|
||||
func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) {
|
||||
var cacheKey string
|
||||
|
||||
// Create cache key based on auth data
|
||||
switch a := auth.(type) {
|
||||
case RefreshTokenAuth:
|
||||
if a.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
cacheKey = "refresh:" + a.RefreshToken
|
||||
case EmailAuth:
|
||||
if a.Email == "" || a.Password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
cacheKey = "email:" + a.Email + ":" + a.Password
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid auth type")
|
||||
}
|
||||
|
||||
cacheMutex.Lock()
|
||||
defer cacheMutex.Unlock()
|
||||
|
||||
if cachedClient, ok := clientCache[cacheKey]; ok {
|
||||
// Check if token is not nil and not expired
|
||||
if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) {
|
||||
cachedClient.onTokenRefresh = onTokenRefresh
|
||||
return cachedClient, nil
|
||||
}
|
||||
}
|
||||
|
||||
client := &RingApi{
|
||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||
onTokenRefresh: onTokenRefresh,
|
||||
hardwareID: generateHardwareID(),
|
||||
auth: auth,
|
||||
cacheKey: cacheKey,
|
||||
}
|
||||
|
||||
switch a := auth.(type) {
|
||||
case RefreshTokenAuth:
|
||||
config, err := parseAuthConfig(a.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
|
||||
@@ -164,160 +201,30 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest
|
||||
client.authConfig = config
|
||||
client.hardwareID = config.HID
|
||||
client.RefreshToken = a.RefreshToken
|
||||
case EmailAuth:
|
||||
if a.Email == "" || a.Password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid auth type")
|
||||
}
|
||||
|
||||
clientCache[cacheKey] = client
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Request makes an authenticated request to the Ring API
|
||||
func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) {
|
||||
// Ensure we have a valid auth token
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("hardware_id", c.hardwareID)
|
||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||
|
||||
// Make request with retries
|
||||
var resp *http.Response
|
||||
var responseBody []byte
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if attempt == maxRetries {
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err)
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Handle 401 by refreshing auth and retrying
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
c.authToken = nil // Force token refresh
|
||||
if attempt == maxRetries {
|
||||
return nil, fmt.Errorf("authentication failed after %d retries", maxRetries)
|
||||
}
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh authentication: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle other error status codes
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return responseBody, nil
|
||||
func ClientAPI(path string) string {
|
||||
return clientAPIBaseURL + path
|
||||
}
|
||||
|
||||
// ensureAuth ensures we have a valid auth token
|
||||
func (c *RingRestClient) ensureAuth() error {
|
||||
if c.authToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var grantData = map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": c.authConfig.RT,
|
||||
}
|
||||
|
||||
// Add common fields
|
||||
grantData["client_id"] = "ring_official_android"
|
||||
grantData["scope"] = "client"
|
||||
|
||||
// Make auth request
|
||||
body, err := json.Marshal(grantData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("hardware_id", c.hardwareID)
|
||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||
req.Header.Set("2fa-support", "true")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPreconditionFailed {
|
||||
return fmt.Errorf("2FA required. Please see documentation for handling 2FA")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var authResp AuthTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||
return fmt.Errorf("failed to decode auth response: %w", err)
|
||||
}
|
||||
|
||||
// Update auth config and refresh token
|
||||
c.authToken = &authResp
|
||||
c.authConfig = &AuthConfig{
|
||||
RT: authResp.RefreshToken,
|
||||
HID: c.hardwareID,
|
||||
}
|
||||
|
||||
// Encode and notify about new refresh token
|
||||
if c.onTokenRefresh != nil {
|
||||
newRefreshToken := encodeAuthConfig(c.authConfig)
|
||||
c.onTokenRefresh(newRefreshToken)
|
||||
}
|
||||
|
||||
return nil
|
||||
func DeviceAPI(path string) string {
|
||||
return deviceAPIBaseURL + path
|
||||
}
|
||||
|
||||
// getAuth makes an authentication request to the Ring API
|
||||
func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||
func CommandsAPI(path string) string {
|
||||
return commandsAPIBaseURL + path
|
||||
}
|
||||
|
||||
func AppAPI(path string) string {
|
||||
return appAPIBaseURL + path
|
||||
}
|
||||
|
||||
func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||
var grantData map[string]string
|
||||
|
||||
if c.authConfig != nil && twoFactorAuthCode == "" {
|
||||
@@ -404,60 +311,30 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse,
|
||||
return nil, fmt.Errorf("failed to decode auth response: %w", err)
|
||||
}
|
||||
|
||||
// Refresh token and expiry
|
||||
c.authToken = &authResp
|
||||
c.authConfig = &AuthConfig{
|
||||
RT: authResp.RefreshToken,
|
||||
HID: c.hardwareID,
|
||||
}
|
||||
// Set token expiry (1 minute before actual expiry)
|
||||
expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second
|
||||
c.tokenExpiry = time.Now().Add(expiresIn)
|
||||
|
||||
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
||||
if c.onTokenRefresh != nil {
|
||||
c.onTokenRefresh(c.RefreshToken)
|
||||
}
|
||||
|
||||
// Refresh the cached client
|
||||
cacheMutex.Lock()
|
||||
clientCache[c.cacheKey] = c
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return c.authToken, nil
|
||||
}
|
||||
|
||||
// Helper functions for auth config encoding/decoding
|
||||
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config AuthConfig
|
||||
if err := json.Unmarshal(decoded, &config); err != nil {
|
||||
// Handle legacy format where refresh token is the raw token
|
||||
return &AuthConfig{RT: refreshToken}, nil
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func encodeAuthConfig(config *AuthConfig) string {
|
||||
jsonBytes, _ := json.Marshal(config)
|
||||
return base64.StdEncoding.EncodeToString(jsonBytes)
|
||||
}
|
||||
|
||||
// API URL helpers
|
||||
func ClientAPI(path string) string {
|
||||
return clientAPIBaseURL + path
|
||||
}
|
||||
|
||||
func DeviceAPI(path string) string {
|
||||
return deviceAPIBaseURL + path
|
||||
}
|
||||
|
||||
func CommandsAPI(path string) string {
|
||||
return commandsAPIBaseURL + path
|
||||
}
|
||||
|
||||
func AppAPI(path string) string {
|
||||
return appAPIBaseURL + path
|
||||
}
|
||||
|
||||
// FetchRingDevices gets all Ring devices and categorizes them
|
||||
func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
|
||||
func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) {
|
||||
response, err := c.Request("GET", ClientAPI("ring_devices"), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch ring devices: %w", err)
|
||||
@@ -509,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
|
||||
return &devices, nil
|
||||
}
|
||||
|
||||
func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) {
|
||||
func (c *RingApi) GetSocketTicket() (*SocketTicketResponse, error) {
|
||||
response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch socket ticket: %w", err)
|
||||
@@ -523,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) {
|
||||
return &ticket, nil
|
||||
}
|
||||
|
||||
func (c *RingApi) Request(method, url string, body interface{}) ([]byte, error) {
|
||||
// Ensure we have a valid session
|
||||
if err := c.ensureSession(); err != nil {
|
||||
return nil, fmt.Errorf("session validation failed: %w", err)
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("hardware_id", c.hardwareID)
|
||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||
|
||||
// Make request with retries
|
||||
var resp *http.Response
|
||||
var responseBody []byte
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if attempt == maxRetries {
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err)
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Handle 401 by refreshing auth and retrying
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Reset token to force refresh
|
||||
c.authMutex.Lock()
|
||||
c.authToken = nil
|
||||
c.tokenExpiry = time.Time{} // Reset token expiry
|
||||
c.authMutex.Unlock()
|
||||
|
||||
if attempt == maxRetries {
|
||||
return nil, fmt.Errorf("authentication failed after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
// By 401 with Auth AND Session start over
|
||||
c.sessionMutex.Lock()
|
||||
c.session = nil
|
||||
c.sessionExpiry = time.Time{} // Reset session expiry
|
||||
c.sessionMutex.Unlock()
|
||||
|
||||
if err := c.ensureSession(); err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh session: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle 404 error with hardware_id reference - session issue
|
||||
if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) {
|
||||
var errorBody map[string]interface{}
|
||||
if err := json.Unmarshal(responseBody, &errorBody); err == nil {
|
||||
if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) {
|
||||
// Session with hardware_id not found, refresh session
|
||||
c.sessionMutex.Lock()
|
||||
c.session = nil
|
||||
c.sessionExpiry = time.Time{} // Reset session expiry
|
||||
c.sessionMutex.Unlock()
|
||||
|
||||
if attempt == maxRetries {
|
||||
return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if err := c.ensureSession(); err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh session: %w", err)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other error status codes
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return responseBody, nil
|
||||
}
|
||||
|
||||
func (c *RingApi) ensureSession() error {
|
||||
c.sessionMutex.Lock()
|
||||
defer c.sessionMutex.Unlock()
|
||||
|
||||
// If session is still valid, use it
|
||||
if c.session != nil && time.Now().Before(c.sessionExpiry) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Make sure we have a valid auth token
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return fmt.Errorf("authentication failed while creating session: %w", err)
|
||||
}
|
||||
|
||||
sessionPayload := map[string]interface{}{
|
||||
"device": map[string]interface{}{
|
||||
"hardware_id": c.hardwareID,
|
||||
"metadata": map[string]interface{}{
|
||||
"api_version": apiVersion,
|
||||
"device_model": "ring-client-go",
|
||||
},
|
||||
"os": "android",
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(sessionPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||
req.Header.Set("hardware_id", c.hardwareID)
|
||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var sessionResp SessionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
|
||||
return fmt.Errorf("failed to decode session response: %w", err)
|
||||
}
|
||||
|
||||
c.session = &sessionResp
|
||||
c.sessionExpiry = time.Now().Add(sessionValidTime)
|
||||
|
||||
// Aktualisiere den gecachten Client
|
||||
cacheMutex.Lock()
|
||||
clientCache[c.cacheKey] = c
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RingApi) ensureAuth() error {
|
||||
c.authMutex.Lock()
|
||||
defer c.authMutex.Unlock()
|
||||
|
||||
// If token exists and is not expired, use it
|
||||
if c.authToken != nil && time.Now().Before(c.tokenExpiry) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var grantData = map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": c.authConfig.RT,
|
||||
}
|
||||
|
||||
// Add common fields
|
||||
grantData["client_id"] = "ring_official_android"
|
||||
grantData["scope"] = "client"
|
||||
|
||||
// Make auth request
|
||||
body, err := json.Marshal(grantData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("hardware_id", c.hardwareID)
|
||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||
req.Header.Set("2fa-support", "true")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPreconditionFailed {
|
||||
return fmt.Errorf("2FA required. Please see documentation for handling 2FA")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var authResp AuthTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||
return fmt.Errorf("failed to decode auth response: %w", err)
|
||||
}
|
||||
|
||||
// Update auth config and refresh token
|
||||
c.authToken = &authResp
|
||||
c.authConfig = &AuthConfig{
|
||||
RT: authResp.RefreshToken,
|
||||
HID: c.hardwareID,
|
||||
}
|
||||
|
||||
// Set token expiry (1 minute before actual expiry)
|
||||
expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second
|
||||
c.tokenExpiry = time.Now().Add(expiresIn)
|
||||
|
||||
// Encode and notify about new refresh token
|
||||
if c.onTokenRefresh != nil {
|
||||
newRefreshToken := encodeAuthConfig(c.authConfig)
|
||||
c.onTokenRefresh(newRefreshToken)
|
||||
}
|
||||
|
||||
// Refreshn the token in the client
|
||||
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
||||
|
||||
// Refresh the cached client
|
||||
cacheMutex.Lock()
|
||||
clientCache[c.cacheKey] = c
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config AuthConfig
|
||||
if err := json.Unmarshal(decoded, &config); err != nil {
|
||||
// Handle legacy format where refresh token is the raw token
|
||||
return &AuthConfig{RT: refreshToken}, nil
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func encodeAuthConfig(config *AuthConfig) string {
|
||||
jsonBytes, _ := json.Marshal(config)
|
||||
return base64.StdEncoding.EncodeToString(jsonBytes)
|
||||
}
|
||||
|
||||
func generateHardwareID() string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("ring-client-go2rtc"))
|
||||
|
||||
+122
-308
@@ -5,103 +5,25 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
pion "github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
api *RingRestClient
|
||||
ws *websocket.Conn
|
||||
api *RingApi
|
||||
wsClient *WSClient
|
||||
prod core.Producer
|
||||
camera *CameraData
|
||||
cameraID int
|
||||
dialogID string
|
||||
sessionID string
|
||||
wsMutex sync.Mutex
|
||||
done chan struct{}
|
||||
connected core.Waiter
|
||||
closed bool
|
||||
}
|
||||
|
||||
type SessionBody struct {
|
||||
DoorbotID int `json:"doorbot_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
type AnswerMessage struct {
|
||||
Method string `json:"method"` // "sdp"
|
||||
Body struct {
|
||||
SessionBody
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"` // "answer"
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type IceCandidateMessage struct {
|
||||
Method string `json:"method"` // "ice"
|
||||
Body struct {
|
||||
SessionBody
|
||||
Ice string `json:"ice"`
|
||||
MLineIndex int `json:"mlineindex"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type SessionMessage struct {
|
||||
Method string `json:"method"` // "session_created" or "session_started"
|
||||
Body SessionBody `json:"body"`
|
||||
}
|
||||
|
||||
type PongMessage struct {
|
||||
Method string `json:"method"` // "pong"
|
||||
Body SessionBody `json:"body"`
|
||||
}
|
||||
|
||||
type NotificationMessage struct {
|
||||
Method string `json:"method"` // "notification"
|
||||
Body struct {
|
||||
SessionBody
|
||||
IsOK bool `json:"is_ok"`
|
||||
Text string `json:"text"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type StreamInfoMessage struct {
|
||||
Method string `json:"method"` // "stream_info"
|
||||
Body struct {
|
||||
SessionBody
|
||||
Transcoding bool `json:"transcoding"`
|
||||
TranscodingReason string `json:"transcoding_reason"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type CloseMessage struct {
|
||||
Method string `json:"method"` // "close"
|
||||
Body struct {
|
||||
SessionBody
|
||||
Reason struct {
|
||||
Code int `json:"code"`
|
||||
Text string `json:"text"`
|
||||
} `json:"reason"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type BaseMessage struct {
|
||||
Method string `json:"method"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
// Close reason codes
|
||||
const (
|
||||
CloseReasonNormalClose = 0
|
||||
CloseReasonAuthenticationFailed = 5
|
||||
CloseReasonTimeout = 6
|
||||
)
|
||||
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
// 1. Parse URL and validate basic params
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -109,70 +31,42 @@ func Dial(rawURL string) (*Client, error) {
|
||||
|
||||
query := u.Query()
|
||||
encodedToken := query.Get("refresh_token")
|
||||
cameraID := query.Get("camera_id")
|
||||
deviceID := query.Get("device_id")
|
||||
_, isSnapshot := query["snapshot"]
|
||||
|
||||
if encodedToken == "" || deviceID == "" {
|
||||
if encodedToken == "" || deviceID == "" || cameraID == "" {
|
||||
return nil, errors.New("ring: wrong query")
|
||||
}
|
||||
|
||||
// URL-decode the refresh token
|
||||
client := &Client{
|
||||
dialogID: uuid.NewString(),
|
||||
}
|
||||
|
||||
client.cameraID, err = strconv.Atoi(cameraID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ring: invalid camera_id: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := url.QueryUnescape(encodedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err)
|
||||
}
|
||||
|
||||
// Initialize Ring API client
|
||||
ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
||||
client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get camera details
|
||||
devices, err := ringAPI.FetchRingDevices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var camera *CameraData
|
||||
for _, cam := range devices.AllCameras {
|
||||
if fmt.Sprint(cam.DeviceID) == deviceID {
|
||||
camera = &cam
|
||||
break
|
||||
}
|
||||
}
|
||||
if camera == nil {
|
||||
return nil, errors.New("ring: camera not found")
|
||||
}
|
||||
|
||||
// Create base client
|
||||
client := &Client{
|
||||
api: ringAPI,
|
||||
camera: camera,
|
||||
dialogID: uuid.NewString(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Check if snapshot request
|
||||
// Snapshot Flow
|
||||
if isSnapshot {
|
||||
client.prod = NewSnapshotProducer(ringAPI, camera)
|
||||
client.prod = NewSnapshotProducer(client.api, client.cameraID)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// If not snapshot, continue with WebRTC setup
|
||||
ticket, err := ringAPI.GetSocketTicket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create WebSocket connection
|
||||
wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
|
||||
uuid.NewString(), url.QueryEscape(ticket.Ticket))
|
||||
|
||||
client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{
|
||||
"User-Agent": {"android:com.ringapp"},
|
||||
})
|
||||
client.wsClient, err = StartWebsocket(client.cameraID, client.api)
|
||||
if err != nil {
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -196,13 +90,13 @@ func Dial(rawURL string) (*Client, error) {
|
||||
|
||||
api, err := webrtc.NewAPI()
|
||||
if err != nil {
|
||||
client.ws.Close()
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc, err := api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
client.ws.Close()
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -212,16 +106,27 @@ func Dial(rawURL string) (*Client, error) {
|
||||
// protect from blocking on errors
|
||||
defer sendOffer.Done(nil)
|
||||
|
||||
// waiter will wait PC error or WS error or nil (connection OK)
|
||||
var connState core.Waiter
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.FormatName = "ring/webrtc"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "ws"
|
||||
prod.URL = rawURL
|
||||
|
||||
client.prod = prod
|
||||
client.wsClient.onMessage = func(msg WSMessage) {
|
||||
client.onWSMessage(msg)
|
||||
}
|
||||
|
||||
client.wsClient.onError = func(err error) {
|
||||
// fmt.Printf("ring: error: %s\n", err.Error())
|
||||
client.Stop()
|
||||
client.connected.Done(err)
|
||||
}
|
||||
|
||||
client.wsClient.onClose = func() {
|
||||
// fmt.Println("ring: disconnect")
|
||||
client.Stop()
|
||||
client.connected.Done(errors.New("ring: disconnect"))
|
||||
}
|
||||
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -240,22 +145,28 @@ func Dial(rawURL string) (*Client, error) {
|
||||
"mlineindex": iceCandidate.SDPMLineIndex,
|
||||
}
|
||||
|
||||
if err = client.sendSessionMessage("ice", icePayload); err != nil {
|
||||
connState.Done(err)
|
||||
if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil {
|
||||
client.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateNew:
|
||||
break
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
break
|
||||
case pion.PeerConnectionStateConnected:
|
||||
connState.Done(nil)
|
||||
client.connected.Done(nil)
|
||||
default:
|
||||
connState.Done(errors.New("ring: " + msg.String()))
|
||||
client.Stop()
|
||||
client.connected.Done(errors.New("ring: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client.prod = prod
|
||||
|
||||
// Setup media configuration
|
||||
medias := []*core.Media{
|
||||
{
|
||||
@@ -297,186 +208,103 @@ func Dial(rawURL string) (*Client, error) {
|
||||
"sdp": offer,
|
||||
}
|
||||
|
||||
if err = client.sendSessionMessage("live_view", offerPayload); err != nil {
|
||||
if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil {
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done(nil)
|
||||
|
||||
// Ring expects a ping message every 5 seconds
|
||||
go client.startPingLoop(pc)
|
||||
go client.startMessageLoop(&connState)
|
||||
|
||||
if err = connState.Wait(); err != nil {
|
||||
if err = client.connected.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) startPingLoop(pc *pion.PeerConnection) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
func (c *Client) onWSMessage(msg WSMessage) {
|
||||
rawMsg, _ := json.Marshal(msg)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if pc.ConnectionState() == pion.PeerConnectionStateConnected {
|
||||
if err := c.sendSessionMessage("ping", nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
// fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg))
|
||||
|
||||
// check if "doorbot_id" is present
|
||||
if _, ok := msg.Body["doorbot_id"]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// check if the message is from the correct doorbot
|
||||
doorbotID := msg.Body["doorbot_id"].(float64)
|
||||
if int(doorbotID) != c.cameraID {
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Method == "session_created" || msg.Method == "session_started" {
|
||||
if _, ok := msg.Body["session_id"]; ok && c.wsClient.sessionID == "" {
|
||||
c.wsClient.sessionID = msg.Body["session_id"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) startMessageLoop(connState *core.Waiter) {
|
||||
var err error
|
||||
|
||||
// will be closed when conn will be closed
|
||||
defer func() {
|
||||
connState.Done(err)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
// check if the message is from the correct session
|
||||
if _, ok := msg.Body["session_id"]; ok {
|
||||
if msg.Body["session_id"].(string) != c.wsClient.sessionID {
|
||||
return
|
||||
default:
|
||||
var res BaseMessage
|
||||
if err = c.ws.ReadJSON(&res); err != nil {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.Method {
|
||||
case "sdp":
|
||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||
// Get answer
|
||||
var msg AnswerMessage
|
||||
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||
c.Stop()
|
||||
c.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
// check if "doorbot_id" is present
|
||||
if _, ok := res.Body["doorbot_id"]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if the message is from the correct doorbot
|
||||
doorbotID := res.Body["doorbot_id"].(float64)
|
||||
if doorbotID != float64(c.camera.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if the message is from the correct session
|
||||
if res.Method == "session_created" || res.Method == "session_started" {
|
||||
if _, ok := res.Body["session_id"]; ok && c.sessionID == "" {
|
||||
c.sessionID = res.Body["session_id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := res.Body["session_id"]; ok {
|
||||
if res.Body["session_id"].(string) != c.sessionID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rawMsg, _ := json.Marshal(res)
|
||||
|
||||
switch res.Method {
|
||||
case "sdp":
|
||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||
// Get answer
|
||||
var msg AnswerMessage
|
||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
||||
c.Stop()
|
||||
return
|
||||
}
|
||||
if err = prod.SetAnswer(msg.Body.SDP); err != nil {
|
||||
c.Stop()
|
||||
return
|
||||
}
|
||||
if err = c.activateSession(); err != nil {
|
||||
c.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case "ice":
|
||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||
// Continue to receiving candidates
|
||||
var msg IceCandidateMessage
|
||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// check for empty ICE candidate
|
||||
if msg.Body.Ice == "" {
|
||||
break
|
||||
}
|
||||
|
||||
if err = prod.AddCandidate(msg.Body.Ice); err != nil {
|
||||
c.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case "close":
|
||||
if err := prod.SetAnswer(msg.Body.SDP); err != nil {
|
||||
c.Stop()
|
||||
c.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
case "pong":
|
||||
// Ignore
|
||||
continue
|
||||
if err := c.wsClient.activateSession(); err != nil {
|
||||
c.Stop()
|
||||
c.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
prod.SDP = msg.Body.SDP
|
||||
}
|
||||
|
||||
case "ice":
|
||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||
var msg IceCandidateMessage
|
||||
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Skip empty candidates
|
||||
if msg.Body.Ice == "" {
|
||||
break
|
||||
}
|
||||
|
||||
if err := prod.AddCandidate(msg.Body.Ice); err != nil {
|
||||
c.Stop()
|
||||
c.connected.Done(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case "close":
|
||||
c.Stop()
|
||||
c.connected.Done(errors.New("ring: close"))
|
||||
|
||||
case "pong":
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) activateSession() error {
|
||||
if err := c.sendSessionMessage("activate_session", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamPayload := map[string]interface{}{
|
||||
"audio_enabled": true,
|
||||
"video_enabled": true,
|
||||
}
|
||||
|
||||
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error {
|
||||
c.wsMutex.Lock()
|
||||
defer c.wsMutex.Unlock()
|
||||
|
||||
if body == nil {
|
||||
body = make(map[string]interface{})
|
||||
}
|
||||
|
||||
body["doorbot_id"] = c.camera.ID
|
||||
if c.sessionID != "" {
|
||||
body["session_id"] = c.sessionID
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"method": method,
|
||||
"dialog_id": c.dialogID,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
if err := c.ws.WriteJSON(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
return c.prod.GetMedias()
|
||||
}
|
||||
@@ -492,7 +320,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece
|
||||
speakerPayload := map[string]interface{}{
|
||||
"stealth_mode": false,
|
||||
}
|
||||
_ = c.sendSessionMessage("camera_options", speakerPayload)
|
||||
_ = c.wsClient.sendSessionMessage("camera_options", speakerPayload)
|
||||
}
|
||||
return webrtcProd.AddTrack(media, codec, track)
|
||||
}
|
||||
@@ -505,37 +333,23 @@ func (c *Client) Start() error {
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
if c.closed {
|
||||
return nil
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
c.closed = true
|
||||
|
||||
if c.prod != nil {
|
||||
_ = c.prod.Stop()
|
||||
}
|
||||
|
||||
if c.ws != nil {
|
||||
closePayload := map[string]interface{}{
|
||||
"reason": map[string]interface{}{
|
||||
"code": CloseReasonNormalClose,
|
||||
"text": "",
|
||||
},
|
||||
}
|
||||
|
||||
_ = c.sendSessionMessage("close", closePayload)
|
||||
_ = c.ws.Close()
|
||||
c.ws = nil
|
||||
if c.wsClient != nil {
|
||||
_ = c.wsClient.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
|
||||
return webrtcProd.MarshalJSON()
|
||||
}
|
||||
|
||||
return json.Marshal(c.prod)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
type SnapshotProducer struct {
|
||||
core.Connection
|
||||
|
||||
client *RingRestClient
|
||||
camera *CameraData
|
||||
client *RingApi
|
||||
cameraID int
|
||||
}
|
||||
|
||||
func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer {
|
||||
func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer {
|
||||
return &SnapshotProducer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
@@ -35,14 +35,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr
|
||||
},
|
||||
},
|
||||
},
|
||||
client: client,
|
||||
camera: camera,
|
||||
client: client,
|
||||
cameraID: cameraID,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SnapshotProducer) Start() error {
|
||||
// Fetch snapshot
|
||||
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil)
|
||||
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type SessionBody struct {
|
||||
DoorbotID int `json:"doorbot_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
type AnswerMessage struct {
|
||||
Method string `json:"method"` // "sdp"
|
||||
Body struct {
|
||||
SessionBody
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"` // "answer"
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type IceCandidateMessage struct {
|
||||
Method string `json:"method"` // "ice"
|
||||
Body struct {
|
||||
SessionBody
|
||||
Ice string `json:"ice"`
|
||||
MLineIndex int `json:"mlineindex"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type SessionMessage struct {
|
||||
Method string `json:"method"` // "session_created" or "session_started"
|
||||
Body SessionBody `json:"body"`
|
||||
}
|
||||
|
||||
type PongMessage struct {
|
||||
Method string `json:"method"` // "pong"
|
||||
Body SessionBody `json:"body"`
|
||||
}
|
||||
|
||||
type NotificationMessage struct {
|
||||
Method string `json:"method"` // "notification"
|
||||
Body struct {
|
||||
SessionBody
|
||||
IsOK bool `json:"is_ok"`
|
||||
Text string `json:"text"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type StreamInfoMessage struct {
|
||||
Method string `json:"method"` // "stream_info"
|
||||
Body struct {
|
||||
SessionBody
|
||||
Transcoding bool `json:"transcoding"`
|
||||
TranscodingReason string `json:"transcoding_reason"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type CloseRequest struct {
|
||||
Method string `json:"method"` // "close"
|
||||
Body struct {
|
||||
SessionBody
|
||||
Reason struct {
|
||||
Code int `json:"code"`
|
||||
Text string `json:"text"`
|
||||
} `json:"reason"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
Method string `json:"method"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
type WSClient struct {
|
||||
ws *websocket.Conn
|
||||
api *RingApi
|
||||
wsMutex sync.Mutex
|
||||
cameraID int
|
||||
dialogID string
|
||||
sessionID string
|
||||
|
||||
onMessage func(msg WSMessage)
|
||||
onError func(err error)
|
||||
onClose func()
|
||||
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
const (
|
||||
CloseReasonNormalClose = 0
|
||||
CloseReasonAuthenticationFailed = 5
|
||||
CloseReasonTimeout = 6
|
||||
)
|
||||
|
||||
func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) {
|
||||
client := &WSClient{
|
||||
api: api,
|
||||
cameraID: cameraID,
|
||||
dialogID: uuid.NewString(),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
ticket, err := client.api.GetSocketTicket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
|
||||
uuid.NewString(), url.QueryEscape(ticket.Ticket))
|
||||
|
||||
httpHeader := http.Header{}
|
||||
httpHeader.Set("User-Agent", "android:com.ringapp")
|
||||
|
||||
client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.ws.SetCloseHandler(func(code int, text string) error {
|
||||
client.onWsClose()
|
||||
return nil
|
||||
})
|
||||
|
||||
go client.startPingLoop()
|
||||
go client.startMessageLoop()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *WSClient) Close() error {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return nil
|
||||
default:
|
||||
close(c.closed)
|
||||
}
|
||||
|
||||
closePayload := map[string]interface{}{
|
||||
"reason": map[string]interface{}{
|
||||
"code": CloseReasonNormalClose,
|
||||
"text": "",
|
||||
},
|
||||
}
|
||||
|
||||
_ = c.sendSessionMessage("close", closePayload)
|
||||
|
||||
return c.ws.Close()
|
||||
}
|
||||
|
||||
func (c *WSClient) startPingLoop() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.sendSessionMessage("ping", nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) startMessageLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return
|
||||
default:
|
||||
var res WSMessage
|
||||
if err := c.ws.ReadJSON(&res); err != nil {
|
||||
select {
|
||||
case <-c.closed:
|
||||
// Ignore error if closed
|
||||
default:
|
||||
c.onWsError(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.onWsMessage(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) activateSession() error {
|
||||
if err := c.sendSessionMessage("activate_session", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamPayload := map[string]interface{}{
|
||||
"audio_enabled": true,
|
||||
"video_enabled": true,
|
||||
}
|
||||
|
||||
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return nil
|
||||
default:
|
||||
// continue
|
||||
}
|
||||
|
||||
c.wsMutex.Lock()
|
||||
defer c.wsMutex.Unlock()
|
||||
|
||||
if payload == nil {
|
||||
payload = make(map[string]interface{})
|
||||
}
|
||||
|
||||
payload["doorbot_id"] = c.cameraID
|
||||
if c.sessionID != "" {
|
||||
payload["session_id"] = c.sessionID
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"method": method,
|
||||
"dialog_id": c.dialogID,
|
||||
"body": payload,
|
||||
}
|
||||
|
||||
// rawMsg, _ := json.Marshal(msg)
|
||||
// fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg))
|
||||
|
||||
if err := c.ws.WriteJSON(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) onWsMessage(msg WSMessage) {
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) onWsError(err error) {
|
||||
if c.onError != nil {
|
||||
c.onError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) onWsClose() {
|
||||
if c.onClose != nil {
|
||||
c.onClose()
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -116,20 +116,39 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin
|
||||
// urlParse fix bugs:
|
||||
// 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/
|
||||
// 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/
|
||||
// 3. Content-Base: 192.168.253.220:1935/
|
||||
func urlParse(rawURL string) (*url.URL, error) {
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/830
|
||||
if strings.HasPrefix(rawURL, "rtsp://rtsp://") {
|
||||
rawURL = rawURL[7:]
|
||||
}
|
||||
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/1852
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
rawURL = "rtsp://" + rawURL
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil && strings.HasSuffix(err.Error(), "after host") {
|
||||
if i1 := strings.Index(rawURL, "://"); i1 > 0 {
|
||||
if i2 := strings.IndexByte(rawURL[i1+3:], '/'); i2 > 0 {
|
||||
return urlParse(rawURL[:i1+3+i2] + ":" + rawURL[i1+3+i2:])
|
||||
}
|
||||
if i := indexN(rawURL, '/', 3); i > 0 {
|
||||
return urlParse(rawURL[:i] + ":" + rawURL[i:])
|
||||
}
|
||||
}
|
||||
|
||||
return u, err
|
||||
}
|
||||
|
||||
func indexN(s string, c byte, n int) int {
|
||||
var offset int
|
||||
for {
|
||||
i := strings.IndexByte(s[offset:], c)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
if n--; n == 0 {
|
||||
return offset + i
|
||||
}
|
||||
offset += i + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -11,14 +11,20 @@ func TestURLParse(t *testing.T) {
|
||||
// https://github.com/AlexxIT/WebRTC/issues/395
|
||||
base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/"
|
||||
u, err := urlParse(base)
|
||||
assert.Empty(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "::ffff:192.168.1.123:", u.Host)
|
||||
|
||||
// https://github.com/AlexxIT/go2rtc/issues/208
|
||||
base = "rtsp://rtsp://turret2-cam.lan:554/stream1/"
|
||||
u, err = urlParse(base)
|
||||
assert.Empty(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "turret2-cam.lan:554", u.Host)
|
||||
|
||||
// https://github.com/AlexxIT/go2rtc/issues/1852
|
||||
base = "192.168.253.220:1935/"
|
||||
u, err = urlParse(base)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "192.168.253.220:1935", u.Host)
|
||||
}
|
||||
|
||||
func TestBugSDP1(t *testing.T) {
|
||||
|
||||
+10
-3
@@ -125,13 +125,20 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error
|
||||
networks = append(networks, ice.NetworkType(ntype))
|
||||
}
|
||||
|
||||
udpMux, _ = ice.NewMultiUDPMuxFromPort(
|
||||
var err error
|
||||
if udpMux, err = ice.NewMultiUDPMuxFromPort(
|
||||
port,
|
||||
ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter),
|
||||
ice.UDPMuxFromPortWithIPFilter(ipFilter),
|
||||
ice.UDPMuxFromPortWithNetworks(networks...),
|
||||
)
|
||||
} else if ln, err := net.ListenPacket("udp", address); err == nil {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
ln, err := net.ListenPacket("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln})
|
||||
}
|
||||
s.SetICEUDPMux(udpMux)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
## Versions
|
||||
|
||||
**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. Everything described below is not relevant.
|
||||
|
||||
[Go 1.20](https://go.dev/doc/go1.20) is last version with support Windows 7 and macOS 10.13.
|
||||
Go 1.21 support only Windows 10 and macOS 10.15.
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
@ECHO OFF
|
||||
|
||||
@SET GOTOOLCHAIN=
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=amd64
|
||||
@SET FILENAME=go2rtc_win64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOTOOLCHAIN=go1.20.14
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=386
|
||||
@SET FILENAME=go2rtc_win32.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOTOOLCHAIN=
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=arm64
|
||||
@SET FILENAME=go2rtc_win_arm64.zip
|
||||
@@ -50,13 +47,11 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME
|
||||
@SET FILENAME=go2rtc_linux_mipsel
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME%
|
||||
|
||||
@SET GOTOOLCHAIN=go1.20.14
|
||||
@SET GOOS=darwin
|
||||
@SET GOARCH=amd64
|
||||
@SET FILENAME=go2rtc_mac_amd64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc
|
||||
|
||||
@SET GOTOOLCHAIN=
|
||||
@SET GOOS=darwin
|
||||
@SET GOARCH=arm64
|
||||
@SET FILENAME=go2rtc_mac_arm64.zip
|
||||
|
||||
+12
-7
@@ -254,25 +254,30 @@
|
||||
|
||||
async function handleRingAuth(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const table = document.getElementById('ring-table');
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/ring?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
|
||||
if (!r.ok) {
|
||||
table.innerText = (await r.text()) || 'Unknown error';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
|
||||
table.innerText = '';
|
||||
|
||||
if (data.needs_2fa) {
|
||||
document.getElementById('tfa-field').style.display = 'block';
|
||||
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
const table = document.getElementById('ring-table');
|
||||
table.innerText = data.error || 'Unknown error';
|
||||
return;
|
||||
}
|
||||
|
||||
const table = document.getElementById('ring-table');
|
||||
drawTable(table, data);
|
||||
}
|
||||
|
||||
|
||||
+19
-15
@@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement {
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS
|
||||
.filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
|
||||
.filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio'))
|
||||
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
@@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
} else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
modes.push('hls');
|
||||
this.onhls();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
} else if (this.mode.includes('mp4')) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||
if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (this.mode.includes('mjpeg')) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = msg => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
@@ -490,7 +490,7 @@ export class VideoRTC extends HTMLElement {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
|
||||
if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return;
|
||||
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({type: 'webrtc/candidate', value: candidate});
|
||||
@@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement {
|
||||
this.onmessage['webrtc'] = msg => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
|
||||
if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return;
|
||||
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
||||
console.warn(er);
|
||||
@@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement {
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
if (!msg.value.includes('webrtc/offer')) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
@@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement {
|
||||
*/
|
||||
async createOffer(pc) {
|
||||
try {
|
||||
if (this.media.indexOf('microphone') >= 0) {
|
||||
if (this.media.includes('microphone')) {
|
||||
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
media.getTracks().forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
@@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement {
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
if (this.media.indexOf(kind) >= 0) {
|
||||
if (this.media.includes(kind)) {
|
||||
pc.addTransceiver(kind, {direction: 'recvonly'});
|
||||
}
|
||||
}
|
||||
@@ -580,12 +580,16 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const stream = video2.srcObject;
|
||||
if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
// not the best, but a pretty simple way to check a codec
|
||||
const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000');
|
||||
rtcPriority += isH265Supported ? 0x240 : 0x220;
|
||||
}
|
||||
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
|
||||
if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
|
||||
if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = stream;
|
||||
|
||||
Reference in New Issue
Block a user