Correcting code formatting after #1567

This commit is contained in:
Alex X
2025-01-25 11:18:36 +03:00
parent fc02e6f4a5
commit f072dab07b
6 changed files with 641 additions and 641 deletions

View File

@@ -1,102 +0,0 @@
package ring
import (
"encoding/json"
"net/http"
"net/url"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ring"
)
func Init() {
streams.HandleFunc("ring", func(source string) (core.Producer, error) {
return ring.Dial(source)
})
api.HandleFunc("api/ring", apiRing)
}
func apiRing(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
var ringAPI *ring.RingRestClient
var err error
// Check auth method
if email := query.Get("email"); email != "" {
// Email/Password Flow
password := query.Get("password")
code := query.Get("code")
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
Email: email,
Password: password,
}, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Try authentication (this will trigger 2FA if needed)
if _, err = ringAPI.GetAuth(code); err != nil {
if ringAPI.Using2FA {
// Return 2FA prompt
json.NewEncoder(w).Encode(map[string]interface{}{
"needs_2fa": true,
"prompt": ringAPI.PromptFor2FA,
})
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// Refresh Token Flow
refreshToken := query.Get("refresh_token")
if refreshToken == "" {
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
return
}
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{
RefreshToken: refreshToken,
}, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// Fetch devices
devices, err := ringAPI.FetchRingDevices()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Create clean query with only required parameters
cleanQuery := url.Values{}
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
var items []*api.Source
for _, camera := range devices.AllCameras {
cleanQuery.Set("device_id", camera.DeviceID)
// Stream source
items = append(items, &api.Source{
Name: camera.Description,
URL: "ring:?" + cleanQuery.Encode(),
})
// Snapshot source
items = append(items, &api.Source{
Name: camera.Description + " Snapshot",
URL: "ring:?" + cleanQuery.Encode() + "&snapshot",
})
}
api.ResponseSources(w, items)
}

102
internal/ring/ring.go Normal file
View File

@@ -0,0 +1,102 @@
package ring
import (
"encoding/json"
"net/http"
"net/url"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ring"
)
func Init() {
streams.HandleFunc("ring", func(source string) (core.Producer, error) {
return ring.Dial(source)
})
api.HandleFunc("api/ring", apiRing)
}
func apiRing(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
var ringAPI *ring.RingRestClient
var err error
// Check auth method
if email := query.Get("email"); email != "" {
// Email/Password Flow
password := query.Get("password")
code := query.Get("code")
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
Email: email,
Password: password,
}, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Try authentication (this will trigger 2FA if needed)
if _, err = ringAPI.GetAuth(code); err != nil {
if ringAPI.Using2FA {
// Return 2FA prompt
json.NewEncoder(w).Encode(map[string]interface{}{
"needs_2fa": true,
"prompt": ringAPI.PromptFor2FA,
})
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// Refresh Token Flow
refreshToken := query.Get("refresh_token")
if refreshToken == "" {
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
return
}
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{
RefreshToken: refreshToken,
}, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// Fetch devices
devices, err := ringAPI.FetchRingDevices()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Create clean query with only required parameters
cleanQuery := url.Values{}
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
var items []*api.Source
for _, camera := range devices.AllCameras {
cleanQuery.Set("device_id", camera.DeviceID)
// Stream source
items = append(items, &api.Source{
Name: camera.Description,
URL: "ring:?" + cleanQuery.Encode(),
})
// Snapshot source
items = append(items, &api.Source{
Name: camera.Description + " Snapshot",
URL: "ring:?" + cleanQuery.Encode() + "&snapshot",
})
}
api.ResponseSources(w, items)
}

View File

@@ -19,8 +19,8 @@ type RefreshTokenAuth struct {
} }
type EmailAuth struct { type EmailAuth struct {
Email string Email string
Password string Password string
} }
// AuthConfig represents the decoded refresh token data // AuthConfig represents the decoded refresh token data
@@ -31,38 +31,38 @@ type AuthConfig struct {
// AuthTokenResponse represents the response from the authentication endpoint // 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"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"` // Always "client" Scope string `json:"scope"` // Always "client"
TokenType string `json:"token_type"` // Always "Bearer" TokenType string `json:"token_type"` // Always "Bearer"
} }
type Auth2faResponse struct { type Auth2faResponse struct {
Error string `json:"error"` Error string `json:"error"`
ErrorDescription string `json:"error_description"` ErrorDescription string `json:"error_description"`
TSVState string `json:"tsv_state"` TSVState string `json:"tsv_state"`
Phone string `json:"phone"` Phone string `json:"phone"`
NextTimeInSecs int `json:"next_time_in_secs"` NextTimeInSecs int `json:"next_time_in_secs"`
} }
// SocketTicketRequest represents the request to get a socket ticket // 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 // RingRestClient handles authentication and requests to Ring API
type RingRestClient struct { type RingRestClient struct {
httpClient *http.Client httpClient *http.Client
authConfig *AuthConfig authConfig *AuthConfig
hardwareID string hardwareID string
authToken *AuthTokenResponse authToken *AuthTokenResponse
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)
} }
// CameraKind represents the different types of Ring cameras // CameraKind represents the different types of Ring cameras
@@ -70,11 +70,11 @@ type CameraKind string
// CameraData contains common fields for all camera types // CameraData contains common fields for all camera types
type CameraData struct { type CameraData struct {
ID float64 `json:"id"` ID float64 `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 // RingDeviceType represents different types of Ring devices
@@ -82,12 +82,12 @@ type RingDeviceType string
// RingDevicesResponse represents the response from the Ring API // 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"`
StickupCams []CameraData `json:"stickup_cams"` StickupCams []CameraData `json:"stickup_cams"`
AllCameras []CameraData `json:"all_cameras"` AllCameras []CameraData `json:"all_cameras"`
Chimes []CameraData `json:"chimes"` Chimes []CameraData `json:"chimes"`
Other []map[string]interface{} `json:"other"` Other []map[string]interface{} `json:"other"`
} }
const ( const (
@@ -131,48 +131,48 @@ const (
) )
const ( const (
clientAPIBaseURL = "https://api.ring.com/clients_api/" clientAPIBaseURL = "https://api.ring.com/clients_api/"
deviceAPIBaseURL = "https://api.ring.com/devices/v1/" deviceAPIBaseURL = "https://api.ring.com/devices/v1/"
commandsAPIBaseURL = "https://api.ring.com/commands/v1/" commandsAPIBaseURL = "https://api.ring.com/commands/v1/"
appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/"
oauthURL = "https://oauth.ring.com/oauth/token" oauthURL = "https://oauth.ring.com/oauth/token"
apiVersion = 11 apiVersion = 11
defaultTimeout = 20 * time.Second defaultTimeout = 20 * time.Second
maxRetries = 3 maxRetries = 3
) )
// NewRingRestClient creates a new Ring client instance // NewRingRestClient creates a new Ring client instance
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
client := &RingRestClient{ client := &RingRestClient{
httpClient: &http.Client{Timeout: defaultTimeout}, httpClient: &http.Client{Timeout: defaultTimeout},
onTokenRefresh: onTokenRefresh, onTokenRefresh: onTokenRefresh,
hardwareID: generateHardwareID(), hardwareID: generateHardwareID(),
auth: auth, auth: auth,
} }
switch a := auth.(type) {
case RefreshTokenAuth:
if a.RefreshToken == "" {
return nil, fmt.Errorf("refresh token is required")
}
switch a := auth.(type) {
case RefreshTokenAuth:
if a.RefreshToken == "" {
return nil, fmt.Errorf("refresh token is required")
}
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)
} }
client.authConfig = config client.authConfig = config
client.hardwareID = config.HID client.hardwareID = config.HID
client.RefreshToken = a.RefreshToken client.RefreshToken = a.RefreshToken
case EmailAuth: case EmailAuth:
if a.Email == "" || a.Password == "" { if a.Email == "" || a.Password == "" {
return nil, fmt.Errorf("email and password are required") return nil, fmt.Errorf("email and password are required")
} }
default: default:
return nil, fmt.Errorf("invalid auth type") return nil, fmt.Errorf("invalid auth type")
} }
return client, nil return client, nil
} }
// Request makes an authenticated request to the Ring API // Request makes an authenticated request to the Ring API
@@ -207,7 +207,7 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte,
// Make request with retries // Make request with retries
var resp *http.Response var resp *http.Response
var responseBody []byte var responseBody []byte
for attempt := 0; attempt <= maxRetries; attempt++ { for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err = c.httpClient.Do(req) resp, err = c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -318,104 +318,104 @@ func (c *RingRestClient) ensureAuth() error {
// getAuth makes an authentication request to the Ring API // getAuth makes an authentication request to the Ring API
func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { func (c *RingRestClient) 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 == "" {
grantData = map[string]string{ grantData = map[string]string{
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": c.authConfig.RT, "refresh_token": c.authConfig.RT,
} }
} else { } else {
authEmail, ok := c.auth.(EmailAuth) authEmail, ok := c.auth.(EmailAuth)
if !ok { if !ok {
return nil, fmt.Errorf("invalid auth type for email authentication") return nil, fmt.Errorf("invalid auth type for email authentication")
} }
grantData = map[string]string{ grantData = map[string]string{
"grant_type": "password", "grant_type": "password",
"username": authEmail.Email, "username": authEmail.Email,
"password": authEmail.Password, "password": authEmail.Password,
} }
} }
grantData["client_id"] = "ring_official_android" grantData["client_id"] = "ring_official_android"
grantData["scope"] = "client" grantData["scope"] = "client"
body, err := json.Marshal(grantData) body, err := json.Marshal(grantData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal auth request: %w", err) return nil, fmt.Errorf("failed to marshal auth request: %w", err)
} }
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("hardware_id", c.hardwareID) req.Header.Set("hardware_id", c.hardwareID)
req.Header.Set("User-Agent", "android:com.ringapp") req.Header.Set("User-Agent", "android:com.ringapp")
req.Header.Set("2fa-support", "true") req.Header.Set("2fa-support", "true")
if twoFactorAuthCode != "" { if twoFactorAuthCode != "" {
req.Header.Set("2fa-code", twoFactorAuthCode) req.Header.Set("2fa-code", twoFactorAuthCode)
} }
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle 2FA Responses // Handle 2FA Responses
if resp.StatusCode == http.StatusPreconditionFailed || if resp.StatusCode == http.StatusPreconditionFailed ||
(resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) {
var tfaResp Auth2faResponse
if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil {
return nil, err
}
c.Using2FA = true var tfaResp Auth2faResponse
if resp.StatusCode == http.StatusBadRequest { if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil {
c.PromptFor2FA = "Invalid 2fa code entered. Please try again." return nil, err
return nil, fmt.Errorf("invalid 2FA code") }
}
if tfaResp.TSVState != "" { c.Using2FA = true
prompt := "from your authenticator app" if resp.StatusCode == http.StatusBadRequest {
if tfaResp.TSVState != "totp" { c.PromptFor2FA = "Invalid 2fa code entered. Please try again."
prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) return nil, fmt.Errorf("invalid 2FA code")
} }
c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt)
} else {
c.PromptFor2FA = "Please enter the code sent to your text/email"
}
return nil, fmt.Errorf("2FA required") if tfaResp.TSVState != "" {
} prompt := "from your authenticator app"
if tfaResp.TSVState != "totp" {
prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState)
}
c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt)
} else {
c.PromptFor2FA = "Please enter the code sent to your text/email"
}
// Handle errors return nil, fmt.Errorf("2FA required")
if resp.StatusCode != http.StatusOK { }
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
}
var authResp AuthTokenResponse // Handle errors
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to decode auth response: %w", err) body, _ := io.ReadAll(resp.Body)
} return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
}
c.authToken = &authResp var authResp AuthTokenResponse
c.authConfig = &AuthConfig{ if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
RT: authResp.RefreshToken, return nil, fmt.Errorf("failed to decode auth response: %w", err)
HID: c.hardwareID, }
}
c.RefreshToken = encodeAuthConfig(c.authConfig) c.authToken = &authResp
if c.onTokenRefresh != nil { c.authConfig = &AuthConfig{
c.onTokenRefresh(c.RefreshToken) RT: authResp.RefreshToken,
} HID: c.hardwareID,
}
return c.authToken, nil c.RefreshToken = encodeAuthConfig(c.authConfig)
if c.onTokenRefresh != nil {
c.onTokenRefresh(c.RefreshToken)
}
return c.authToken, nil
} }
// Helper functions for auth config encoding/decoding // Helper functions for auth config encoding/decoding
@@ -542,4 +542,4 @@ func interfaceSlice(slice interface{}) []CameraData {
} }
} }
return ret return ret
} }

View File

@@ -16,422 +16,422 @@ import (
) )
type Client struct { type Client struct {
api *RingRestClient api *RingRestClient
ws *websocket.Conn ws *websocket.Conn
prod core.Producer prod core.Producer
camera *CameraData camera *CameraData
dialogID string dialogID string
sessionID string sessionID string
wsMutex sync.Mutex wsMutex sync.Mutex
done chan struct{} done chan struct{}
} }
type SessionBody struct { type SessionBody struct {
DoorbotID int `json:"doorbot_id"` DoorbotID int `json:"doorbot_id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
} }
type AnswerMessage struct { type AnswerMessage struct {
Method string `json:"method"` // "sdp" Method string `json:"method"` // "sdp"
Body struct { Body struct {
SessionBody SessionBody
SDP string `json:"sdp"` SDP string `json:"sdp"`
Type string `json:"type"` // "answer" Type string `json:"type"` // "answer"
} `json:"body"` } `json:"body"`
} }
type IceCandidateMessage struct { type IceCandidateMessage struct {
Method string `json:"method"` // "ice" Method string `json:"method"` // "ice"
Body struct { Body struct {
SessionBody SessionBody
Ice string `json:"ice"` Ice string `json:"ice"`
MLineIndex int `json:"mlineindex"` MLineIndex int `json:"mlineindex"`
} `json:"body"` } `json:"body"`
} }
type SessionMessage struct { type SessionMessage struct {
Method string `json:"method"` // "session_created" or "session_started" Method string `json:"method"` // "session_created" or "session_started"
Body SessionBody `json:"body"` Body SessionBody `json:"body"`
} }
type PongMessage struct { type PongMessage struct {
Method string `json:"method"` // "pong" Method string `json:"method"` // "pong"
Body SessionBody `json:"body"` Body SessionBody `json:"body"`
} }
type NotificationMessage struct { type NotificationMessage struct {
Method string `json:"method"` // "notification" Method string `json:"method"` // "notification"
Body struct { Body struct {
SessionBody SessionBody
IsOK bool `json:"is_ok"` IsOK bool `json:"is_ok"`
Text string `json:"text"` Text string `json:"text"`
} `json:"body"` } `json:"body"`
} }
type StreamInfoMessage struct { type StreamInfoMessage struct {
Method string `json:"method"` // "stream_info" Method string `json:"method"` // "stream_info"
Body struct { Body struct {
SessionBody SessionBody
Transcoding bool `json:"transcoding"` Transcoding bool `json:"transcoding"`
TranscodingReason string `json:"transcoding_reason"` TranscodingReason string `json:"transcoding_reason"`
} `json:"body"` } `json:"body"`
} }
type CloseMessage struct { type CloseMessage struct {
Method string `json:"method"` // "close" Method string `json:"method"` // "close"
Body struct { Body struct {
SessionBody SessionBody
Reason struct { Reason struct {
Code int `json:"code"` Code int `json:"code"`
Text string `json:"text"` Text string `json:"text"`
} `json:"reason"` } `json:"reason"`
} `json:"body"` } `json:"body"`
} }
type BaseMessage struct { type BaseMessage struct {
Method string `json:"method"` Method string `json:"method"`
Body map[string]any `json:"body"` Body map[string]any `json:"body"`
} }
// Close reason codes // Close reason codes
const ( const (
CloseReasonNormalClose = 0 CloseReasonNormalClose = 0
CloseReasonAuthenticationFailed = 5 CloseReasonAuthenticationFailed = 5
CloseReasonTimeout = 6 CloseReasonTimeout = 6
) )
func Dial(rawURL string) (*Client, error) { func Dial(rawURL string) (*Client, error) {
// 1. Parse URL and validate basic params // 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
} }
query := u.Query() query := u.Query()
encodedToken := query.Get("refresh_token") encodedToken := query.Get("refresh_token")
deviceID := query.Get("device_id") deviceID := query.Get("device_id")
_, isSnapshot := query["snapshot"] _, isSnapshot := query["snapshot"]
if encodedToken == "" || deviceID == "" { if encodedToken == "" || deviceID == "" {
return nil, errors.New("ring: wrong query") return nil, errors.New("ring: wrong query")
} }
// URL-decode the refresh token // URL-decode the refresh token
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 // Initialize Ring API client
ringAPI, err := NewRingRestClient(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 // Get camera details
devices, err := ringAPI.FetchRingDevices() devices, err := ringAPI.FetchRingDevices()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var camera *CameraData var camera *CameraData
for _, cam := range devices.AllCameras { for _, cam := range devices.AllCameras {
if fmt.Sprint(cam.DeviceID) == deviceID { if fmt.Sprint(cam.DeviceID) == deviceID {
camera = &cam camera = &cam
break break
} }
} }
if camera == nil { if camera == nil {
return nil, errors.New("ring: camera not found") return nil, errors.New("ring: camera not found")
} }
// Create base client // Create base client
client := &Client{ client := &Client{
api: ringAPI, api: ringAPI,
camera: camera, camera: camera,
dialogID: uuid.NewString(), dialogID: uuid.NewString(),
done: make(chan struct{}), done: make(chan struct{}),
} }
// Check if snapshot request // Check if snapshot request
if isSnapshot { if isSnapshot {
client.prod = NewSnapshotProducer(ringAPI, camera) client.prod = NewSnapshotProducer(ringAPI, camera)
return client, nil return client, nil
} }
// If not snapshot, continue with WebRTC setup // If not snapshot, continue with WebRTC setup
ticket, err := ringAPI.GetSocketTicket() ticket, err := ringAPI.GetSocketTicket()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Create WebSocket connection // 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", 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)) uuid.NewString(), url.QueryEscape(ticket.Ticket))
client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{ client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{
"User-Agent": {"android:com.ringapp"}, "User-Agent": {"android:com.ringapp"},
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Create Peer Connection // Create Peer Connection
conf := pion.Configuration{ conf := pion.Configuration{
ICEServers: []pion.ICEServer{ ICEServers: []pion.ICEServer{
{URLs: []string{ {URLs: []string{
"stun:stun.kinesisvideo.us-east-1.amazonaws.com:443", "stun:stun.kinesisvideo.us-east-1.amazonaws.com:443",
"stun:stun.kinesisvideo.us-east-2.amazonaws.com:443", "stun:stun.kinesisvideo.us-east-2.amazonaws.com:443",
"stun:stun.kinesisvideo.us-west-2.amazonaws.com:443", "stun:stun.kinesisvideo.us-west-2.amazonaws.com:443",
"stun:stun.l.google.com:19302", "stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302", "stun:stun1.l.google.com:19302",
"stun:stun2.l.google.com:19302", "stun:stun2.l.google.com:19302",
"stun:stun3.l.google.com:19302", "stun:stun3.l.google.com:19302",
"stun:stun4.l.google.com:19302", "stun:stun4.l.google.com:19302",
}}, }},
}, },
ICETransportPolicy: pion.ICETransportPolicyAll, ICETransportPolicy: pion.ICETransportPolicyAll,
BundlePolicy: pion.BundlePolicyBalanced, BundlePolicy: pion.BundlePolicyBalanced,
} }
api, err := webrtc.NewAPI() api, err := webrtc.NewAPI()
if err != nil { if err != nil {
client.ws.Close() client.ws.Close()
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.ws.Close()
return nil, err return nil, err
} }
// protect from sending ICE candidate before Offer // protect from sending ICE candidate before Offer
var sendOffer core.Waiter var sendOffer core.Waiter
// 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) // waiter will wait PC error or WS error or nil (connection OK)
var connState core.Waiter 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.prod = prod
prod.Listen(func(msg any) { prod.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
case *pion.ICECandidate: case *pion.ICECandidate:
_ = sendOffer.Wait() _ = sendOffer.Wait()
iceCandidate := msg.ToJSON() iceCandidate := msg.ToJSON()
// skip empty ICE candidates // skip empty ICE candidates
if iceCandidate.Candidate == "" { if iceCandidate.Candidate == "" {
return return
} }
icePayload := map[string]interface{}{ icePayload := map[string]interface{}{
"ice": iceCandidate.Candidate, "ice": iceCandidate.Candidate,
"mlineindex": iceCandidate.SDPMLineIndex, "mlineindex": iceCandidate.SDPMLineIndex,
} }
if err = client.sendSessionMessage("ice", icePayload); err != nil {
connState.Done(err)
return
}
case pion.PeerConnectionState: if err = client.sendSessionMessage("ice", icePayload); err != nil {
switch msg { connState.Done(err)
case pion.PeerConnectionStateConnecting: return
case pion.PeerConnectionStateConnected: }
connState.Done(nil)
default:
connState.Done(errors.New("ring: " + msg.String()))
}
}
})
// Setup media configuration case pion.PeerConnectionState:
medias := []*core.Media{ switch msg {
{ case pion.PeerConnectionStateConnecting:
Kind: core.KindAudio, case pion.PeerConnectionStateConnected:
Direction: core.DirectionSendRecv, connState.Done(nil)
Codecs: []*core.Codec{ default:
{ connState.Done(errors.New("ring: " + msg.String()))
Name: "opus", }
ClockRate: 48000, }
Channels: 2, })
},
},
},
{
Kind: core.KindVideo,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{
{
Name: "H264",
ClockRate: 90000,
},
},
},
}
// Create offer // Setup media configuration
offer, err := prod.CreateOffer(medias) medias := []*core.Media{
if err != nil { {
client.Stop() Kind: core.KindAudio,
return nil, err Direction: core.DirectionSendRecv,
} Codecs: []*core.Codec{
{
Name: "opus",
ClockRate: 48000,
Channels: 2,
},
},
},
{
Kind: core.KindVideo,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{
{
Name: "H264",
ClockRate: 90000,
},
},
},
}
// Send offer // Create offer
offerPayload := map[string]interface{}{ offer, err := prod.CreateOffer(medias)
"stream_options": map[string]bool{ if err != nil {
"audio_enabled": true, client.Stop()
"video_enabled": true, return nil, err
}, }
"sdp": offer,
}
if err = client.sendSessionMessage("live_view", offerPayload); err != nil { // Send offer
client.Stop() offerPayload := map[string]interface{}{
return nil, err "stream_options": map[string]bool{
} "audio_enabled": true,
"video_enabled": true,
},
"sdp": offer,
}
sendOffer.Done(nil) if err = client.sendSessionMessage("live_view", offerPayload); err != nil {
client.Stop()
return nil, err
}
// Ring expects a ping message every 5 seconds sendOffer.Done(nil)
go client.startPingLoop(pc)
go client.startMessageLoop(&connState)
if err = connState.Wait(); err != nil { // Ring expects a ping message every 5 seconds
return nil, err go client.startPingLoop(pc)
} go client.startMessageLoop(&connState)
return client, nil if err = connState.Wait(); err != nil {
return nil, err
}
return client, nil
} }
func (c *Client) startPingLoop(pc *pion.PeerConnection) { func (c *Client) startPingLoop(pc *pion.PeerConnection) {
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-c.done: case <-c.done:
return return
case <-ticker.C: case <-ticker.C:
if pc.ConnectionState() == pion.PeerConnectionStateConnected { if pc.ConnectionState() == pion.PeerConnectionStateConnected {
if err := c.sendSessionMessage("ping", nil); err != nil { if err := c.sendSessionMessage("ping", nil); err != nil {
return return
} }
} }
} }
} }
} }
func (c *Client) startMessageLoop(connState *core.Waiter) { func (c *Client) startMessageLoop(connState *core.Waiter) {
var err error var err error
// will be closed when conn will be closed // will be closed when conn will be closed
defer func() { defer func() {
connState.Done(err) connState.Done(err)
}() }()
for { for {
select { select {
case <-c.done: case <-c.done:
return return
default: default:
var res BaseMessage var res BaseMessage
if err = c.ws.ReadJSON(&res); err != nil { if err = c.ws.ReadJSON(&res); err != nil {
select { select {
case <-c.done: case <-c.done:
return return
default: default:
} }
c.Stop() c.Stop()
return return
} }
// check if "doorbot_id" is present // check if "doorbot_id" is present
if _, ok := res.Body["doorbot_id"]; !ok { if _, ok := res.Body["doorbot_id"]; !ok {
continue 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 // check if the message is from the correct doorbot
if res.Method == "session_created" || res.Method == "session_started" { doorbotID := res.Body["doorbot_id"].(float64)
if _, ok := res.Body["session_id"]; ok && c.sessionID == "" { if doorbotID != float64(c.camera.ID) {
c.sessionID = res.Body["session_id"].(string) continue
} }
}
if _, ok := res.Body["session_id"]; ok { // check if the message is from the correct session
if res.Body["session_id"].(string) != c.sessionID { if res.Method == "session_created" || res.Method == "session_started" {
continue if _, ok := res.Body["session_id"]; ok && c.sessionID == "" {
} c.sessionID = res.Body["session_id"].(string)
} }
}
rawMsg, _ := json.Marshal(res) if _, ok := res.Body["session_id"]; ok {
if res.Body["session_id"].(string) != c.sessionID {
continue
}
}
switch res.Method { rawMsg, _ := json.Marshal(res)
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 switch res.Method {
if msg.Body.Ice == "" { case "sdp":
break 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
}
}
if err = prod.AddCandidate(msg.Body.Ice); err != nil { case "ice":
c.Stop() if prod, ok := c.prod.(*webrtc.Conn); ok {
return // Continue to receiving candidates
} var msg IceCandidateMessage
} if err = json.Unmarshal(rawMsg, &msg); err != nil {
break
}
case "close": // check for empty ICE candidate
c.Stop() if msg.Body.Ice == "" {
return break
}
case "pong": if err = prod.AddCandidate(msg.Body.Ice); err != nil {
// Ignore c.Stop()
continue return
} }
} }
}
case "close":
c.Stop()
return
case "pong":
// Ignore
continue
}
}
}
} }
func (c *Client) activateSession() error { func (c *Client) activateSession() error {
@@ -453,7 +453,7 @@ func (c *Client) activateSession() error {
func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error {
c.wsMutex.Lock() c.wsMutex.Lock()
defer c.wsMutex.Unlock() defer c.wsMutex.Unlock()
if body == nil { if body == nil {
body = make(map[string]interface{}) body = make(map[string]interface{})
@@ -486,18 +486,18 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver,
} }
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
if media.Kind == core.KindAudio { if media.Kind == core.KindAudio {
// Enable speaker // Enable speaker
speakerPayload := map[string]interface{}{ speakerPayload := map[string]interface{}{
"stealth_mode": false, "stealth_mode": false,
} }
_ = c.sendSessionMessage("camera_options", speakerPayload) _ = c.sendSessionMessage("camera_options", speakerPayload)
} }
return webrtcProd.AddTrack(media, codec, track) return webrtcProd.AddTrack(media, codec, track)
} }
return fmt.Errorf("add track not supported for snapshot") return fmt.Errorf("add track not supported for snapshot")
} }
func (c *Client) Start() error { func (c *Client) Start() error {
@@ -534,9 +534,9 @@ func (c *Client) Stop() error {
} }
func (c *Client) MarshalJSON() ([]byte, error) { func (c *Client) MarshalJSON() ([]byte, error) {
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
return webrtcProd.MarshalJSON() return webrtcProd.MarshalJSON()
} }
return nil, errors.New("ring: can't marshal") return nil, errors.New("ring: can't marshal")
} }

View File

@@ -8,57 +8,57 @@ import (
) )
type SnapshotProducer struct { type SnapshotProducer struct {
core.Connection core.Connection
client *RingRestClient client *RingRestClient
camera *CameraData camera *CameraData
} }
func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer {
return &SnapshotProducer{ return &SnapshotProducer{
Connection: core.Connection{ Connection: core.Connection{
ID: core.NewID(), ID: core.NewID(),
FormatName: "ring/snapshot", FormatName: "ring/snapshot",
Protocol: "https", Protocol: "https",
Medias: []*core.Media{ Medias: []*core.Media{
{ {
Kind: core.KindVideo, Kind: core.KindVideo,
Direction: core.DirectionRecvonly, Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{ Codecs: []*core.Codec{
{ {
Name: core.CodecJPEG, Name: core.CodecJPEG,
ClockRate: 90000, ClockRate: 90000,
PayloadType: core.PayloadTypeRAW, PayloadType: core.PayloadTypeRAW,
}, },
}, },
}, },
}, },
}, },
client: client, client: client,
camera: camera, camera: camera,
} }
} }
func (p *SnapshotProducer) Start() error { func (p *SnapshotProducer) Start() error {
// Fetch snapshot // 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", int(p.camera.ID)), nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshot: %w", err) return fmt.Errorf("failed to get snapshot: %w", err)
} }
pkt := &rtp.Packet{ pkt := &rtp.Packet{
Header: rtp.Header{Timestamp: core.Now90000()}, Header: rtp.Header{Timestamp: core.Now90000()},
Payload: response, Payload: response,
} }
// Send to all receivers // Send to all receivers
for _, receiver := range p.Receivers { for _, receiver := range p.Receivers {
receiver.WriteRTP(pkt) receiver.WriteRTP(pkt)
} }
return nil return nil
} }
func (p *SnapshotProducer) Stop() error { func (p *SnapshotProducer) Stop() error {
return p.Connection.Stop() return p.Connection.Stop()
} }

View File

@@ -247,7 +247,7 @@
const r = await fetch(url, {cache: 'no-cache'}); const r = await fetch(url, {cache: 'no-cache'});
const data = await r.json(); const data = await r.json();
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';