Merge branch 'master' of https://github.com/AlexxIT/go2rtc into tuya-new

This commit is contained in:
seydx
2025-09-30 19:14:43 +02:00
10 changed files with 822 additions and 561 deletions
+13
View File
@@ -70,6 +70,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
* [Source: Hass](#source-hass) * [Source: Hass](#source-hass)
* [Source: ISAPI](#source-isapi) * [Source: ISAPI](#source-isapi)
* [Source: Nest](#source-nest) * [Source: Nest](#source-nest)
* [Source: Ring](#source-ring)
* [Source: Roborock](#source-roborock) * [Source: Roborock](#source-roborock)
* [Source: WebRTC](#source-webrtc) * [Source: WebRTC](#source-webrtc)
* [Source: WebTorrent](#source-webtorrent) * [Source: WebTorrent](#source-webtorrent)
@@ -200,6 +201,7 @@ Available source types:
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR
- [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support - [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support
- [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support
- [tuya](#source-tuya) - Tuya cameras with [two way audio](#two-way-audio) support - [tuya](#source-tuya) - Tuya cameras with [two way audio](#two-way-audio) support
- [kasa](#source-tapo) - TP-Link Kasa cameras - [kasa](#source-tapo) - TP-Link Kasa cameras
- [gopro](#source-gopro) - GoPro cameras - [gopro](#source-gopro) - GoPro cameras
@@ -222,6 +224,7 @@ Supported sources:
- [Hikvision ISAPI](#source-isapi) cameras - [Hikvision ISAPI](#source-isapi) cameras
- [Roborock vacuums](#source-roborock) models with cameras - [Roborock vacuums](#source-roborock) models with cameras
- [Exec](#source-exec) audio on server - [Exec](#source-exec) audio on server
- [Ring](#source-ring) cameras
- [Tuya](#source-tuya) cameras - [Tuya](#source-tuya) cameras
- [Any Browser](#incoming-browser) as IP-camera - [Any Browser](#incoming-browser) as IP-camera
@@ -686,6 +689,16 @@ streams:
nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** 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 #### Source: Roborock
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
+1 -1
View File
@@ -80,7 +80,7 @@ var defaults = map[string]string{
// `-profile high -level 4.1` - most used streaming profile // `-profile high -level 4.1` - most used streaming profile
// `-pix_fmt:v yuv420p` - important for Telegram // `-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", "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",
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
+14 -10
View File
@@ -1,10 +1,11 @@
package ring package ring
import ( import (
"encoding/json"
"net/http" "net/http"
"net/url" "net/url"
"fmt"
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
@@ -21,8 +22,7 @@ func Init() {
func apiRing(w http.ResponseWriter, r *http.Request) { func apiRing(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
var ringAPI *ring.RingRestClient var ringAPI *ring.RingApi
var err error
// Check auth method // Check auth method
if email := query.Get("email"); email != "" { if email := query.Get("email"); email != "" {
@@ -30,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
password := query.Get("password") password := query.Get("password")
code := query.Get("code") code := query.Get("code")
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ var err error
ringAPI, err = ring.NewRestClient(ring.EmailAuth{
Email: email, Email: email,
Password: password, Password: password,
}, nil) }, nil)
@@ -44,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
if _, err = ringAPI.GetAuth(code); err != nil { if _, err = ringAPI.GetAuth(code); err != nil {
if ringAPI.Using2FA { if ringAPI.Using2FA {
// Return 2FA prompt // Return 2FA prompt
json.NewEncoder(w).Encode(map[string]interface{}{ api.ResponseJSON(w, map[string]interface{}{
"needs_2fa": true, "needs_2fa": true,
"prompt": ringAPI.PromptFor2FA, "prompt": ringAPI.PromptFor2FA,
}) })
@@ -53,36 +54,39 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
} else { } else if refreshToken := query.Get("refresh_token"); refreshToken != "" {
// Refresh Token Flow // Refresh Token Flow
refreshToken := query.Get("refresh_token")
if refreshToken == "" { if refreshToken == "" {
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
return return
} }
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ var err error
ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{
RefreshToken: refreshToken, RefreshToken: refreshToken,
}, nil) }, nil)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
} else {
http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest)
return
} }
// Fetch devices
devices, err := ringAPI.FetchRingDevices() devices, err := ringAPI.FetchRingDevices()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// Create clean query with only required parameters
cleanQuery := url.Values{} cleanQuery := url.Values{}
cleanQuery.Set("refresh_token", ringAPI.RefreshToken) cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
var items []*api.Source var items []*api.Source
for _, camera := range devices.AllCameras { for _, camera := range devices.AllCameras {
cleanQuery.Set("camera_id", fmt.Sprint(camera.ID))
cleanQuery.Set("device_id", camera.DeviceID) cleanQuery.Set("device_id", camera.DeviceID)
// Stream source // Stream source
+5 -5
View File
@@ -9,8 +9,8 @@ import (
) )
func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
//vps, sps, pps := GetParameterSet(codec.FmtpLine) vps, sps, pps := GetParameterSet(codec.FmtpLine)
//ps := h264.EncodeAVC(vps, sps, pps) ps := h264.JoinNALU(vps, sps, pps)
buf := make([]byte, 0, 512*1024) // 512K buf := make([]byte, 0, 512*1024) // 512K
var nuStart int var nuStart int
@@ -40,9 +40,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
nuType = data[2] & 0x3F nuType = data[2] & 0x3F
// push PS data before keyframe // push PS data before keyframe
//if len(buf) == 0 && nuType >= 19 && nuType <= 21 { if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
// buf = append(buf, ps...) buf = append(buf, ps...)
//} }
nuStart = len(buf) nuStart = len(buf)
buf = append(buf, 0, 0, 0, 0) // NAL unit size buf = append(buf, 0, 0, 0, 0) // NAL unit size
+365 -208
View File
@@ -11,9 +11,13 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
"sync"
"time" "time"
) )
var clientCache = map[string]*RingApi{}
var cacheMutex sync.Mutex
type RefreshTokenAuth struct { type RefreshTokenAuth struct {
RefreshToken string RefreshToken string
} }
@@ -23,13 +27,11 @@ type EmailAuth struct {
Password string Password string
} }
// AuthConfig represents the decoded refresh token data
type AuthConfig struct { type AuthConfig struct {
RT string `json:"rt"` // Refresh Token RT string `json:"rt"` // Refresh Token
HID string `json:"hid"` // Hardware ID HID string `json:"hid"` // Hardware ID
} }
// AuthTokenResponse represents the response from the authentication endpoint
type AuthTokenResponse struct { type AuthTokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
@@ -46,41 +48,50 @@ type Auth2faResponse struct {
NextTimeInSecs int `json:"next_time_in_secs"` NextTimeInSecs int `json:"next_time_in_secs"`
} }
// SocketTicketRequest represents the request to get a socket ticket
type SocketTicketResponse struct { type SocketTicketResponse struct {
Ticket string `json:"ticket"` Ticket string `json:"ticket"`
ResponseTimestamp int64 `json:"response_timestamp"` ResponseTimestamp int64 `json:"response_timestamp"`
} }
// RingRestClient handles authentication and requests to Ring API type SessionResponse struct {
type RingRestClient 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 httpClient *http.Client
authConfig *AuthConfig authConfig *AuthConfig
hardwareID string hardwareID string
authToken *AuthTokenResponse authToken *AuthTokenResponse
tokenExpiry time.Time
Using2FA bool Using2FA bool
PromptFor2FA string PromptFor2FA string
RefreshToken string RefreshToken string
auth interface{} // EmailAuth or RefreshTokenAuth auth interface{} // EmailAuth or RefreshTokenAuth
onTokenRefresh func(string) 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 type CameraKind string
// CameraData contains common fields for all camera types
type CameraData struct { type CameraData struct {
ID float64 `json:"id"` ID int `json:"id"`
Description string `json:"description"` Description string `json:"description"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
Kind string `json:"kind"` Kind string `json:"kind"`
LocationID string `json:"location_id"` LocationID string `json:"location_id"`
} }
// RingDeviceType represents different types of Ring devices
type RingDeviceType string type RingDeviceType string
// RingDevicesResponse represents the response from the Ring API
type RingDevicesResponse struct { type RingDevicesResponse struct {
Doorbots []CameraData `json:"doorbots"` Doorbots []CameraData `json:"doorbots"`
AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` AuthorizedDoorbots []CameraData `json:"authorized_doorbots"`
@@ -139,23 +150,49 @@ const (
apiVersion = 11 apiVersion = 11
defaultTimeout = 20 * time.Second defaultTimeout = 20 * time.Second
maxRetries = 3 maxRetries = 3
sessionValidTime = 12 * time.Hour
) )
// NewRingRestClient creates a new Ring client instance func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) {
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { var cacheKey string
client := &RingRestClient{
httpClient: &http.Client{Timeout: defaultTimeout},
onTokenRefresh: onTokenRefresh,
hardwareID: generateHardwareID(),
auth: auth,
}
// Create cache key based on auth data
switch a := auth.(type) { switch a := auth.(type) {
case RefreshTokenAuth: case RefreshTokenAuth:
if a.RefreshToken == "" { if a.RefreshToken == "" {
return nil, fmt.Errorf("refresh token is required") 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) config, err := parseAuthConfig(a.RefreshToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse refresh token: %w", err) 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.authConfig = config
client.hardwareID = config.HID client.hardwareID = config.HID
client.RefreshToken = a.RefreshToken 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 return client, nil
} }
// Request makes an authenticated request to the Ring API func ClientAPI(path string) string {
func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { return clientAPIBaseURL + path
// Ensure we have a valid auth token
if err := c.ensureAuth(); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
var bodyReader io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = bytes.NewReader(jsonBody)
}
// Create request
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("hardware_id", c.hardwareID)
req.Header.Set("User-Agent", "android:com.ringapp")
// Make request with retries
var resp *http.Response
var responseBody []byte
for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err = c.httpClient.Do(req)
if err != nil {
if attempt == maxRetries {
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err)
}
time.Sleep(5 * time.Second)
continue
}
defer resp.Body.Close()
responseBody, err = io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Handle 401 by refreshing auth and retrying
if resp.StatusCode == http.StatusUnauthorized {
c.authToken = nil // Force token refresh
if attempt == maxRetries {
return nil, fmt.Errorf("authentication failed after %d retries", maxRetries)
}
if err := c.ensureAuth(); err != nil {
return nil, fmt.Errorf("failed to refresh authentication: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
continue
}
// Handle other error status codes
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
break
}
return responseBody, nil
} }
// ensureAuth ensures we have a valid auth token func DeviceAPI(path string) string {
func (c *RingRestClient) ensureAuth() error { return deviceAPIBaseURL + path
if c.authToken != nil {
return nil
}
var grantData = map[string]string{
"grant_type": "refresh_token",
"refresh_token": c.authConfig.RT,
}
// Add common fields
grantData["client_id"] = "ring_official_android"
grantData["scope"] = "client"
// Make auth request
body, err := json.Marshal(grantData)
if err != nil {
return fmt.Errorf("failed to marshal auth request: %w", err)
}
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("hardware_id", c.hardwareID)
req.Header.Set("User-Agent", "android:com.ringapp")
req.Header.Set("2fa-support", "true")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("auth request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
return fmt.Errorf("2FA required. Please see documentation for handling 2FA")
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
}
var authResp AuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("failed to decode auth response: %w", err)
}
// Update auth config and refresh token
c.authToken = &authResp
c.authConfig = &AuthConfig{
RT: authResp.RefreshToken,
HID: c.hardwareID,
}
// Encode and notify about new refresh token
if c.onTokenRefresh != nil {
newRefreshToken := encodeAuthConfig(c.authConfig)
c.onTokenRefresh(newRefreshToken)
}
return nil
} }
// getAuth makes an authentication request to the Ring API func CommandsAPI(path string) string {
func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { return commandsAPIBaseURL + path
}
func AppAPI(path string) string {
return appAPIBaseURL + path
}
func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
var grantData map[string]string var grantData map[string]string
if c.authConfig != nil && twoFactorAuthCode == "" { 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) return nil, fmt.Errorf("failed to decode auth response: %w", err)
} }
// Refresh token and expiry
c.authToken = &authResp c.authToken = &authResp
c.authConfig = &AuthConfig{ c.authConfig = &AuthConfig{
RT: authResp.RefreshToken, RT: authResp.RefreshToken,
HID: c.hardwareID, 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) c.RefreshToken = encodeAuthConfig(c.authConfig)
if c.onTokenRefresh != nil { if c.onTokenRefresh != nil {
c.onTokenRefresh(c.RefreshToken) c.onTokenRefresh(c.RefreshToken)
} }
// Refresh the cached client
cacheMutex.Lock()
clientCache[c.cacheKey] = c
cacheMutex.Unlock()
return c.authToken, nil return c.authToken, nil
} }
// Helper functions for auth config encoding/decoding func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) {
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
if err != nil {
return nil, err
}
var config AuthConfig
if err := json.Unmarshal(decoded, &config); err != nil {
// Handle legacy format where refresh token is the raw token
return &AuthConfig{RT: refreshToken}, nil
}
return &config, nil
}
func encodeAuthConfig(config *AuthConfig) string {
jsonBytes, _ := json.Marshal(config)
return base64.StdEncoding.EncodeToString(jsonBytes)
}
// API URL helpers
func ClientAPI(path string) string {
return clientAPIBaseURL + path
}
func DeviceAPI(path string) string {
return deviceAPIBaseURL + path
}
func CommandsAPI(path string) string {
return commandsAPIBaseURL + path
}
func AppAPI(path string) string {
return appAPIBaseURL + path
}
// FetchRingDevices gets all Ring devices and categorizes them
func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
response, err := c.Request("GET", ClientAPI("ring_devices"), nil) response, err := c.Request("GET", ClientAPI("ring_devices"), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch ring devices: %w", err) return nil, fmt.Errorf("failed to fetch ring devices: %w", err)
@@ -509,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
return &devices, nil 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) response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) return nil, fmt.Errorf("failed to fetch socket ticket: %w", err)
@@ -523,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) {
return &ticket, nil 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 { func generateHardwareID() string {
h := sha256.New() h := sha256.New()
h.Write([]byte("ring-client-go2rtc")) h.Write([]byte("ring-client-go2rtc"))
+122 -308
View File
@@ -5,103 +5,25 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"sync" "strconv"
"time"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket"
pion "github.com/pion/webrtc/v4" pion "github.com/pion/webrtc/v4"
) )
type Client struct { type Client struct {
api *RingRestClient api *RingApi
ws *websocket.Conn wsClient *WSClient
prod core.Producer prod core.Producer
camera *CameraData cameraID int
dialogID string dialogID string
sessionID string connected core.Waiter
wsMutex sync.Mutex closed bool
done chan struct{}
} }
type SessionBody struct {
DoorbotID int `json:"doorbot_id"`
SessionID string `json:"session_id"`
}
type AnswerMessage struct {
Method string `json:"method"` // "sdp"
Body struct {
SessionBody
SDP string `json:"sdp"`
Type string `json:"type"` // "answer"
} `json:"body"`
}
type IceCandidateMessage struct {
Method string `json:"method"` // "ice"
Body struct {
SessionBody
Ice string `json:"ice"`
MLineIndex int `json:"mlineindex"`
} `json:"body"`
}
type SessionMessage struct {
Method string `json:"method"` // "session_created" or "session_started"
Body SessionBody `json:"body"`
}
type PongMessage struct {
Method string `json:"method"` // "pong"
Body SessionBody `json:"body"`
}
type NotificationMessage struct {
Method string `json:"method"` // "notification"
Body struct {
SessionBody
IsOK bool `json:"is_ok"`
Text string `json:"text"`
} `json:"body"`
}
type StreamInfoMessage struct {
Method string `json:"method"` // "stream_info"
Body struct {
SessionBody
Transcoding bool `json:"transcoding"`
TranscodingReason string `json:"transcoding_reason"`
} `json:"body"`
}
type CloseMessage struct {
Method string `json:"method"` // "close"
Body struct {
SessionBody
Reason struct {
Code int `json:"code"`
Text string `json:"text"`
} `json:"reason"`
} `json:"body"`
}
type BaseMessage struct {
Method string `json:"method"`
Body map[string]any `json:"body"`
}
// Close reason codes
const (
CloseReasonNormalClose = 0
CloseReasonAuthenticationFailed = 5
CloseReasonTimeout = 6
)
func Dial(rawURL string) (*Client, error) { func Dial(rawURL string) (*Client, error) {
// 1. Parse URL and validate basic params
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -109,70 +31,42 @@ func Dial(rawURL string) (*Client, error) {
query := u.Query() query := u.Query()
encodedToken := query.Get("refresh_token") encodedToken := query.Get("refresh_token")
cameraID := query.Get("camera_id")
deviceID := query.Get("device_id") deviceID := query.Get("device_id")
_, isSnapshot := query["snapshot"] _, isSnapshot := query["snapshot"]
if encodedToken == "" || deviceID == "" { if encodedToken == "" || deviceID == "" || cameraID == "" {
return nil, errors.New("ring: wrong query") 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) refreshToken, err := url.QueryUnescape(encodedToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err)
} }
// Initialize Ring API client client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Get camera details // Snapshot Flow
devices, err := ringAPI.FetchRingDevices()
if err != nil {
return nil, err
}
var camera *CameraData
for _, cam := range devices.AllCameras {
if fmt.Sprint(cam.DeviceID) == deviceID {
camera = &cam
break
}
}
if camera == nil {
return nil, errors.New("ring: camera not found")
}
// Create base client
client := &Client{
api: ringAPI,
camera: camera,
dialogID: uuid.NewString(),
done: make(chan struct{}),
}
// Check if snapshot request
if isSnapshot { if isSnapshot {
client.prod = NewSnapshotProducer(ringAPI, camera) client.prod = NewSnapshotProducer(client.api, client.cameraID)
return client, nil return client, nil
} }
// If not snapshot, continue with WebRTC setup client.wsClient, err = StartWebsocket(client.cameraID, client.api)
ticket, err := ringAPI.GetSocketTicket()
if err != nil {
return nil, err
}
// Create WebSocket connection
wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
uuid.NewString(), url.QueryEscape(ticket.Ticket))
client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{
"User-Agent": {"android:com.ringapp"},
})
if err != nil { if err != nil {
client.Stop()
return nil, err return nil, err
} }
@@ -196,13 +90,13 @@ func Dial(rawURL string) (*Client, error) {
api, err := webrtc.NewAPI() api, err := webrtc.NewAPI()
if err != nil { if err != nil {
client.ws.Close() client.Stop()
return nil, err return nil, err
} }
pc, err := api.NewPeerConnection(conf) pc, err := api.NewPeerConnection(conf)
if err != nil { if err != nil {
client.ws.Close() client.Stop()
return nil, err return nil, err
} }
@@ -212,16 +106,27 @@ func Dial(rawURL string) (*Client, error) {
// protect from blocking on errors // protect from blocking on errors
defer sendOffer.Done(nil) 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 := webrtc.NewConn(pc)
prod.FormatName = "ring/webrtc" prod.FormatName = "ring/webrtc"
prod.Mode = core.ModeActiveProducer prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws" prod.Protocol = "ws"
prod.URL = rawURL 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) { prod.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
@@ -240,22 +145,28 @@ func Dial(rawURL string) (*Client, error) {
"mlineindex": iceCandidate.SDPMLineIndex, "mlineindex": iceCandidate.SDPMLineIndex,
} }
if err = client.sendSessionMessage("ice", icePayload); err != nil { if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil {
connState.Done(err) client.connected.Done(err)
return return
} }
case pion.PeerConnectionState: case pion.PeerConnectionState:
switch msg { switch msg {
case pion.PeerConnectionStateNew:
break
case pion.PeerConnectionStateConnecting: case pion.PeerConnectionStateConnecting:
break
case pion.PeerConnectionStateConnected: case pion.PeerConnectionStateConnected:
connState.Done(nil) client.connected.Done(nil)
default: default:
connState.Done(errors.New("ring: " + msg.String())) client.Stop()
client.connected.Done(errors.New("ring: " + msg.String()))
} }
} }
}) })
client.prod = prod
// Setup media configuration // Setup media configuration
medias := []*core.Media{ medias := []*core.Media{
{ {
@@ -297,186 +208,103 @@ func Dial(rawURL string) (*Client, error) {
"sdp": offer, "sdp": offer,
} }
if err = client.sendSessionMessage("live_view", offerPayload); err != nil { if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil {
client.Stop() client.Stop()
return nil, err return nil, err
} }
sendOffer.Done(nil) sendOffer.Done(nil)
// Ring expects a ping message every 5 seconds if err = client.connected.Wait(); err != nil {
go client.startPingLoop(pc)
go client.startMessageLoop(&connState)
if err = connState.Wait(); err != nil {
return nil, err return nil, err
} }
return client, nil return client, nil
} }
func (c *Client) startPingLoop(pc *pion.PeerConnection) { func (c *Client) onWSMessage(msg WSMessage) {
ticker := time.NewTicker(5 * time.Second) rawMsg, _ := json.Marshal(msg)
defer ticker.Stop()
for { // fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg))
select {
case <-c.done: // check if "doorbot_id" is present
return if _, ok := msg.Body["doorbot_id"]; !ok {
case <-ticker.C: return
if pc.ConnectionState() == pion.PeerConnectionStateConnected { }
if err := c.sendSessionMessage("ping", nil); err != nil {
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) { // check if the message is from the correct session
var err error if _, ok := msg.Body["session_id"]; ok {
if msg.Body["session_id"].(string) != c.wsClient.sessionID {
// will be closed when conn will be closed
defer func() {
connState.Done(err)
}()
for {
select {
case <-c.done:
return 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.Stop()
c.connected.Done(err)
return return
} }
// check if "doorbot_id" is present if err := prod.SetAnswer(msg.Body.SDP); err != nil {
if _, ok := res.Body["doorbot_id"]; !ok {
continue
}
// check if the message is from the correct doorbot
doorbotID := res.Body["doorbot_id"].(float64)
if doorbotID != float64(c.camera.ID) {
continue
}
// check if the message is from the correct session
if res.Method == "session_created" || res.Method == "session_started" {
if _, ok := res.Body["session_id"]; ok && c.sessionID == "" {
c.sessionID = res.Body["session_id"].(string)
}
}
if _, ok := res.Body["session_id"]; ok {
if res.Body["session_id"].(string) != c.sessionID {
continue
}
}
rawMsg, _ := json.Marshal(res)
switch res.Method {
case "sdp":
if prod, ok := c.prod.(*webrtc.Conn); ok {
// Get answer
var msg AnswerMessage
if err = json.Unmarshal(rawMsg, &msg); err != nil {
c.Stop()
return
}
if err = prod.SetAnswer(msg.Body.SDP); err != nil {
c.Stop()
return
}
if err = c.activateSession(); err != nil {
c.Stop()
return
}
}
case "ice":
if prod, ok := c.prod.(*webrtc.Conn); ok {
// Continue to receiving candidates
var msg IceCandidateMessage
if err = json.Unmarshal(rawMsg, &msg); err != nil {
break
}
// check for empty ICE candidate
if msg.Body.Ice == "" {
break
}
if err = prod.AddCandidate(msg.Body.Ice); err != nil {
c.Stop()
return
}
}
case "close":
c.Stop() c.Stop()
c.connected.Done(err)
return return
}
case "pong": if err := c.wsClient.activateSession(); err != nil {
// Ignore c.Stop()
continue 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 { func (c *Client) GetMedias() []*core.Media {
return c.prod.GetMedias() 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{}{ speakerPayload := map[string]interface{}{
"stealth_mode": false, "stealth_mode": false,
} }
_ = c.sendSessionMessage("camera_options", speakerPayload) _ = c.wsClient.sendSessionMessage("camera_options", speakerPayload)
} }
return webrtcProd.AddTrack(media, codec, track) return webrtcProd.AddTrack(media, codec, track)
} }
@@ -505,37 +333,23 @@ func (c *Client) Start() error {
} }
func (c *Client) Stop() error { func (c *Client) Stop() error {
select { if c.closed {
case <-c.done:
return nil return nil
default:
close(c.done)
} }
c.closed = true
if c.prod != nil { if c.prod != nil {
_ = c.prod.Stop() _ = c.prod.Stop()
} }
if c.ws != nil { if c.wsClient != nil {
closePayload := map[string]interface{}{ _ = c.wsClient.Close()
"reason": map[string]interface{}{
"code": CloseReasonNormalClose,
"text": "",
},
}
_ = c.sendSessionMessage("close", closePayload)
_ = c.ws.Close()
c.ws = nil
} }
return nil return nil
} }
func (c *Client) MarshalJSON() ([]byte, error) { func (c *Client) MarshalJSON() ([]byte, error) {
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
return webrtcProd.MarshalJSON()
}
return json.Marshal(c.prod) return json.Marshal(c.prod)
} }
+6 -7
View File
@@ -10,11 +10,11 @@ import (
type SnapshotProducer struct { type SnapshotProducer struct {
core.Connection core.Connection
client *RingRestClient client *RingApi
camera *CameraData cameraID int
} }
func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer {
return &SnapshotProducer{ return &SnapshotProducer{
Connection: core.Connection{ Connection: core.Connection{
ID: core.NewID(), ID: core.NewID(),
@@ -35,14 +35,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr
}, },
}, },
}, },
client: client, client: client,
camera: camera, cameraID: cameraID,
} }
} }
func (p *SnapshotProducer) Start() error { func (p *SnapshotProducer) Start() error {
// Fetch snapshot response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil)
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil)
if err != nil { if err != nil {
return err return err
} }
+265
View File
@@ -0,0 +1,265 @@
package ring
import (
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
type SessionBody struct {
DoorbotID int `json:"doorbot_id"`
SessionID string `json:"session_id"`
}
type AnswerMessage struct {
Method string `json:"method"` // "sdp"
Body struct {
SessionBody
SDP string `json:"sdp"`
Type string `json:"type"` // "answer"
} `json:"body"`
}
type IceCandidateMessage struct {
Method string `json:"method"` // "ice"
Body struct {
SessionBody
Ice string `json:"ice"`
MLineIndex int `json:"mlineindex"`
} `json:"body"`
}
type SessionMessage struct {
Method string `json:"method"` // "session_created" or "session_started"
Body SessionBody `json:"body"`
}
type PongMessage struct {
Method string `json:"method"` // "pong"
Body SessionBody `json:"body"`
}
type NotificationMessage struct {
Method string `json:"method"` // "notification"
Body struct {
SessionBody
IsOK bool `json:"is_ok"`
Text string `json:"text"`
} `json:"body"`
}
type StreamInfoMessage struct {
Method string `json:"method"` // "stream_info"
Body struct {
SessionBody
Transcoding bool `json:"transcoding"`
TranscodingReason string `json:"transcoding_reason"`
} `json:"body"`
}
type CloseRequest struct {
Method string `json:"method"` // "close"
Body struct {
SessionBody
Reason struct {
Code int `json:"code"`
Text string `json:"text"`
} `json:"reason"`
} `json:"body"`
}
type WSMessage struct {
Method string `json:"method"`
Body map[string]any `json:"body"`
}
type WSClient struct {
ws *websocket.Conn
api *RingApi
wsMutex sync.Mutex
cameraID int
dialogID string
sessionID string
onMessage func(msg WSMessage)
onError func(err error)
onClose func()
closed chan struct{}
}
const (
CloseReasonNormalClose = 0
CloseReasonAuthenticationFailed = 5
CloseReasonTimeout = 6
)
func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) {
client := &WSClient{
api: api,
cameraID: cameraID,
dialogID: uuid.NewString(),
closed: make(chan struct{}),
}
ticket, err := client.api.GetSocketTicket()
if err != nil {
return nil, err
}
url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
uuid.NewString(), url.QueryEscape(ticket.Ticket))
httpHeader := http.Header{}
httpHeader.Set("User-Agent", "android:com.ringapp")
client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader)
if err != nil {
return nil, err
}
client.ws.SetCloseHandler(func(code int, text string) error {
client.onWsClose()
return nil
})
go client.startPingLoop()
go client.startMessageLoop()
return client, nil
}
func (c *WSClient) Close() error {
select {
case <-c.closed:
return nil
default:
close(c.closed)
}
closePayload := map[string]interface{}{
"reason": map[string]interface{}{
"code": CloseReasonNormalClose,
"text": "",
},
}
_ = c.sendSessionMessage("close", closePayload)
return c.ws.Close()
}
func (c *WSClient) startPingLoop() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.closed:
return
case <-ticker.C:
if err := c.sendSessionMessage("ping", nil); err != nil {
return
}
}
}
}
func (c *WSClient) startMessageLoop() {
for {
select {
case <-c.closed:
return
default:
var res WSMessage
if err := c.ws.ReadJSON(&res); err != nil {
select {
case <-c.closed:
// Ignore error if closed
default:
c.onWsError(err)
}
return
}
c.onWsMessage(res)
}
}
}
func (c *WSClient) activateSession() error {
if err := c.sendSessionMessage("activate_session", nil); err != nil {
return err
}
streamPayload := map[string]interface{}{
"audio_enabled": true,
"video_enabled": true,
}
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
return err
}
return nil
}
func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error {
select {
case <-c.closed:
return nil
default:
// continue
}
c.wsMutex.Lock()
defer c.wsMutex.Unlock()
if payload == nil {
payload = make(map[string]interface{})
}
payload["doorbot_id"] = c.cameraID
if c.sessionID != "" {
payload["session_id"] = c.sessionID
}
msg := map[string]interface{}{
"method": method,
"dialog_id": c.dialogID,
"body": payload,
}
// rawMsg, _ := json.Marshal(msg)
// fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg))
if err := c.ws.WriteJSON(msg); err != nil {
return err
}
return nil
}
func (c *WSClient) onWsMessage(msg WSMessage) {
if c.onMessage != nil {
c.onMessage(msg)
}
}
func (c *WSClient) onWsError(err error) {
if c.onError != nil {
c.onError(err)
}
}
func (c *WSClient) onWsClose() {
if c.onClose != nil {
c.onClose()
}
}
+12 -7
View File
@@ -254,25 +254,30 @@
async function handleRingAuth(ev) { async function handleRingAuth(ev) {
ev.preventDefault(); ev.preventDefault();
const table = document.getElementById('ring-table');
table.innerText = 'loading...';
const query = new URLSearchParams(new FormData(ev.target)); const query = new URLSearchParams(new FormData(ev.target));
const url = new URL('api/ring?' + query.toString(), location.href); const url = new URL('api/ring?' + query.toString(), location.href);
const r = await fetch(url, {cache: 'no-cache'}); const r = await fetch(url, {cache: 'no-cache'});
if (!r.ok) {
table.innerText = (await r.text()) || 'Unknown error';
return;
}
const data = await r.json(); const data = await r.json();
table.innerText = '';
if (data.needs_2fa) { if (data.needs_2fa) {
document.getElementById('tfa-field').style.display = 'block'; document.getElementById('tfa-field').style.display = 'block';
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code'; document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
return; 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); drawTable(table, data);
} }
+19 -15
View File
@@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement {
/** @param {Function} isSupported */ /** @param {Function} isSupported */
codecs(isSupported) { codecs(isSupported) {
return this.CODECS 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(); .filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
} }
@@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement {
const modes = []; 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'); modes.push('mse');
this.onmse(); 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'); modes.push('hls');
this.onhls(); this.onhls();
} else if (this.mode.indexOf('mp4') >= 0) { } else if (this.mode.includes('mp4')) {
modes.push('mp4'); modes.push('mp4');
this.onmp4(); this.onmp4();
} }
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) { if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
modes.push('webrtc'); modes.push('webrtc');
this.onwebrtc(); this.onwebrtc();
} }
if (this.mode.indexOf('mjpeg') >= 0) { if (this.mode.includes('mjpeg')) {
if (modes.length) { if (modes.length) {
this.onmessage['mjpeg'] = msg => { this.onmessage['mjpeg'] = msg => {
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return; 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); const pc = new RTCPeerConnection(this.pcConfig);
pc.addEventListener('icecandidate', ev => { 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 : ''; const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
this.send({type: 'webrtc/candidate', value: candidate}); this.send({type: 'webrtc/candidate', value: candidate});
@@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement {
this.onmessage['webrtc'] = msg => { this.onmessage['webrtc'] = msg => {
switch (msg.type) { switch (msg.type) {
case 'webrtc/candidate': 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 => { pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
console.warn(er); console.warn(er);
@@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement {
}); });
break; break;
case 'error': case 'error':
if (msg.value.indexOf('webrtc/offer') < 0) return; if (!msg.value.includes('webrtc/offer')) return;
pc.close(); pc.close();
} }
}; };
@@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement {
*/ */
async createOffer(pc) { async createOffer(pc) {
try { try {
if (this.media.indexOf('microphone') >= 0) { if (this.media.includes('microphone')) {
const media = await navigator.mediaDevices.getUserMedia({audio: true}); const media = await navigator.mediaDevices.getUserMedia({audio: true});
media.getTracks().forEach(track => { media.getTracks().forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'}); pc.addTransceiver(track, {direction: 'sendonly'});
@@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement {
} }
for (const kind of ['video', 'audio']) { for (const kind of ['video', 'audio']) {
if (this.media.indexOf(kind) >= 0) { if (this.media.includes(kind)) {
pc.addTransceiver(kind, {direction: 'recvonly'}); pc.addTransceiver(kind, {direction: 'recvonly'});
} }
} }
@@ -580,12 +580,16 @@ export class VideoRTC extends HTMLElement {
/** @type {MediaStream} */ /** @type {MediaStream} */
const stream = video2.srcObject; 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 (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
if (rtcPriority >= msePriority) { if (rtcPriority >= msePriority) {
this.video.srcObject = stream; this.video.srcObject = stream;