refactor
This commit is contained in:
+11
-10
@@ -1,7 +1,6 @@
|
|||||||
package ring
|
package ring
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
@@ -23,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 != "" {
|
||||||
@@ -32,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)
|
||||||
@@ -46,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,
|
||||||
})
|
})
|
||||||
@@ -55,31 +54,33 @@ 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)
|
||||||
|
|
||||||
|
|||||||
+295
-314
@@ -15,7 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var clientCache = map[string]*RingRestClient{}
|
var clientCache = map[string]*RingApi{}
|
||||||
var cacheMutex sync.Mutex
|
var cacheMutex sync.Mutex
|
||||||
|
|
||||||
type RefreshTokenAuth struct {
|
type RefreshTokenAuth struct {
|
||||||
@@ -27,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"`
|
||||||
@@ -50,13 +48,11 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionResponse repesents the response from the session endpoint
|
|
||||||
type SessionResponse struct {
|
type SessionResponse struct {
|
||||||
Profile struct {
|
Profile struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -66,8 +62,7 @@ type SessionResponse struct {
|
|||||||
} `json:"profile"`
|
} `json:"profile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RingRestClient handles authentication and requests to Ring API
|
type RingApi struct {
|
||||||
type RingRestClient struct {
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
authConfig *AuthConfig
|
authConfig *AuthConfig
|
||||||
hardwareID string
|
hardwareID string
|
||||||
@@ -82,15 +77,11 @@ type RingRestClient struct {
|
|||||||
session *SessionResponse
|
session *SessionResponse
|
||||||
sessionExpiry time.Time
|
sessionExpiry time.Time
|
||||||
sessionMutex sync.Mutex
|
sessionMutex sync.Mutex
|
||||||
|
|
||||||
// Cache-Schlüssel für diese Instanz
|
|
||||||
cacheKey string
|
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 int `json:"id"`
|
ID int `json:"id"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -99,10 +90,8 @@ type CameraData struct {
|
|||||||
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"`
|
||||||
@@ -164,8 +153,7 @@ const (
|
|||||||
sessionValidTime = 12 * time.Hour
|
sessionValidTime = 12 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRingRestClient creates a new Ring client instance with caching
|
func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) {
|
||||||
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
|
|
||||||
var cacheKey string
|
var cacheKey string
|
||||||
|
|
||||||
// Create cache key based on auth data
|
// Create cache key based on auth data
|
||||||
@@ -195,7 +183,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &RingRestClient{
|
client := &RingApi{
|
||||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||||
onTokenRefresh: onTokenRefresh,
|
onTokenRefresh: onTokenRefresh,
|
||||||
hardwareID: generateHardwareID(),
|
hardwareID: generateHardwareID(),
|
||||||
@@ -220,271 +208,23 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest
|
|||||||
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 session
|
|
||||||
if err := c.ensureSession(); err != nil {
|
|
||||||
return nil, fmt.Errorf("session validation failed: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bodyReader io.Reader
|
func DeviceAPI(path string) string {
|
||||||
if body != nil {
|
return deviceAPIBaseURL + path
|
||||||
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
|
func CommandsAPI(path string) string {
|
||||||
req, err := http.NewRequest(method, url, bodyReader)
|
return commandsAPIBaseURL + path
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers
|
func AppAPI(path string) string {
|
||||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
return appAPIBaseURL + path
|
||||||
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
|
func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureSession makes sure we have a valid session
|
|
||||||
func (c *RingRestClient) 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureAuth ensures we have a valid auth token with expiration tracking
|
|
||||||
func (c *RingRestClient) 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAuth makes an authentication request to the Ring API
|
|
||||||
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 == "" {
|
||||||
@@ -594,46 +334,7 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse,
|
|||||||
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)
|
||||||
@@ -685,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)
|
||||||
@@ -699,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"))
|
||||||
|
|||||||
+77
-258
@@ -6,103 +6,24 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
"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
|
||||||
cameraID int
|
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
|
||||||
@@ -114,55 +35,38 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
camID, err := strconv.Atoi(cameraID)
|
client := &Client{
|
||||||
|
dialogID: uuid.NewString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.cameraID, err = strconv.Atoi(cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ring: invalid camera_id: %w", err)
|
return nil, fmt.Errorf("ring: invalid camera_id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create base client
|
// Snapshot Flow
|
||||||
client := &Client{
|
|
||||||
api: ringAPI,
|
|
||||||
cameraID: camID,
|
|
||||||
dialogID: uuid.NewString(),
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if snapshot request
|
|
||||||
if isSnapshot {
|
if isSnapshot {
|
||||||
client.prod = NewSnapshotProducer(ringAPI, cameraID)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,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) {
|
||||||
@@ -230,8 +145,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,13 +157,16 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
case pion.PeerConnectionStateConnecting:
|
case pion.PeerConnectionStateConnecting:
|
||||||
break
|
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{
|
||||||
{
|
{
|
||||||
@@ -290,108 +208,69 @@ 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:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if pc.ConnectionState() == pion.PeerConnectionStateConnected {
|
|
||||||
if err := c.sendSessionMessage("ping", nil); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) startMessageLoop(connState *core.Waiter) {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// will be closed when conn will be closed
|
|
||||||
defer func() {
|
|
||||||
connState.Done(err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
var res BaseMessage
|
|
||||||
if err = c.ws.ReadJSON(&res); err != nil {
|
|
||||||
select {
|
|
||||||
case <-c.done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if "doorbot_id" is present
|
// check if "doorbot_id" is present
|
||||||
if _, ok := res.Body["doorbot_id"]; !ok {
|
if _, ok := msg.Body["doorbot_id"]; !ok {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the message is from the correct doorbot
|
// check if the message is from the correct doorbot
|
||||||
doorbotID := res.Body["doorbot_id"].(float64)
|
doorbotID := msg.Body["doorbot_id"].(float64)
|
||||||
if int(doorbotID) != c.cameraID {
|
if int(doorbotID) != c.cameraID {
|
||||||
continue
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the message is from the correct session
|
// check if the message is from the correct session
|
||||||
if res.Method == "session_created" || res.Method == "session_started" {
|
if _, ok := msg.Body["session_id"]; ok {
|
||||||
if _, ok := res.Body["session_id"]; ok && c.sessionID == "" {
|
if msg.Body["session_id"].(string) != c.wsClient.sessionID {
|
||||||
c.sessionID = res.Body["session_id"].(string)
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := res.Body["session_id"]; ok {
|
switch msg.Method {
|
||||||
if res.Body["session_id"].(string) != c.sessionID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawMsg, _ := json.Marshal(res)
|
|
||||||
|
|
||||||
switch res.Method {
|
|
||||||
case "sdp":
|
case "sdp":
|
||||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||||
// Get answer
|
// Get answer
|
||||||
var msg AnswerMessage
|
var msg AnswerMessage
|
||||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||||
c.Stop()
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = prod.SetAnswer(msg.Body.SDP); err != nil {
|
|
||||||
|
if err := prod.SetAnswer(msg.Body.SDP); err != nil {
|
||||||
c.Stop()
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = c.activateSession(); err != nil {
|
|
||||||
|
if err := c.wsClient.activateSession(); err != nil {
|
||||||
c.Stop()
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,77 +279,31 @@ func (c *Client) startMessageLoop(connState *core.Waiter) {
|
|||||||
|
|
||||||
case "ice":
|
case "ice":
|
||||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||||
// Continue to receiving candidates
|
|
||||||
var msg IceCandidateMessage
|
var msg IceCandidateMessage
|
||||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// check for empty ICE candidate
|
// Skip empty candidates
|
||||||
if msg.Body.Ice == "" {
|
if msg.Body.Ice == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = prod.AddCandidate(msg.Body.Ice); err != nil {
|
if err := prod.AddCandidate(msg.Body.Ice); err != nil {
|
||||||
c.Stop()
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "close":
|
case "close":
|
||||||
c.Stop()
|
c.Stop()
|
||||||
return
|
c.connected.Done(errors.New("ring: close"))
|
||||||
|
|
||||||
case "pong":
|
case "pong":
|
||||||
// Ignore
|
// Ignore
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) activateSession() error {
|
|
||||||
if err := c.sendSessionMessage("activate_session", nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
streamPayload := map[string]interface{}{
|
|
||||||
"audio_enabled": true,
|
|
||||||
"video_enabled": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error {
|
|
||||||
c.wsMutex.Lock()
|
|
||||||
defer c.wsMutex.Unlock()
|
|
||||||
|
|
||||||
if body == nil {
|
|
||||||
body = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
body["doorbot_id"] = c.cameraID
|
|
||||||
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()
|
||||||
@@ -487,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)
|
||||||
}
|
}
|
||||||
@@ -500,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
type SnapshotProducer struct {
|
type SnapshotProducer struct {
|
||||||
core.Connection
|
core.Connection
|
||||||
|
|
||||||
client *RingRestClient
|
client *RingApi
|
||||||
cameraID string
|
cameraID int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProducer {
|
func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer {
|
||||||
return &SnapshotProducer{
|
return &SnapshotProducer{
|
||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
@@ -41,8 +41,7 @@ func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProdu
|
|||||||
}
|
}
|
||||||
|
|
||||||
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/%s", p.cameraID), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
package ring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionBody struct {
|
||||||
|
DoorbotID int `json:"doorbot_id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnswerMessage struct {
|
||||||
|
Method string `json:"method"` // "sdp"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
SDP string `json:"sdp"`
|
||||||
|
Type string `json:"type"` // "answer"
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IceCandidateMessage struct {
|
||||||
|
Method string `json:"method"` // "ice"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
Ice string `json:"ice"`
|
||||||
|
MLineIndex int `json:"mlineindex"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionMessage struct {
|
||||||
|
Method string `json:"method"` // "session_created" or "session_started"
|
||||||
|
Body SessionBody `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PongMessage struct {
|
||||||
|
Method string `json:"method"` // "pong"
|
||||||
|
Body SessionBody `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationMessage struct {
|
||||||
|
Method string `json:"method"` // "notification"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
IsOK bool `json:"is_ok"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamInfoMessage struct {
|
||||||
|
Method string `json:"method"` // "stream_info"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
Transcoding bool `json:"transcoding"`
|
||||||
|
TranscodingReason string `json:"transcoding_reason"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloseRequest struct {
|
||||||
|
Method string `json:"method"` // "close"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
Reason struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"reason"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSMessage struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Body map[string]any `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSClient struct {
|
||||||
|
ws *websocket.Conn
|
||||||
|
api *RingApi
|
||||||
|
wsMutex sync.Mutex
|
||||||
|
cameraID int
|
||||||
|
dialogID string
|
||||||
|
sessionID string
|
||||||
|
|
||||||
|
onMessage func(msg WSMessage)
|
||||||
|
onError func(err error)
|
||||||
|
onClose func()
|
||||||
|
|
||||||
|
closed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
CloseReasonNormalClose = 0
|
||||||
|
CloseReasonAuthenticationFailed = 5
|
||||||
|
CloseReasonTimeout = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) {
|
||||||
|
client := &WSClient{
|
||||||
|
api: api,
|
||||||
|
cameraID: cameraID,
|
||||||
|
dialogID: uuid.NewString(),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, err := client.api.GetSocketTicket()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
|
||||||
|
uuid.NewString(), url.QueryEscape(ticket.Ticket))
|
||||||
|
|
||||||
|
httpHeader := http.Header{}
|
||||||
|
httpHeader.Set("User-Agent", "android:com.ringapp")
|
||||||
|
|
||||||
|
client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.ws.SetCloseHandler(func(code int, text string) error {
|
||||||
|
client.onWsClose()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
go client.startPingLoop()
|
||||||
|
go client.startMessageLoop()
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) Close() error {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
close(c.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
closePayload := map[string]interface{}{
|
||||||
|
"reason": map[string]interface{}{
|
||||||
|
"code": CloseReasonNormalClose,
|
||||||
|
"text": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.sendSessionMessage("close", closePayload)
|
||||||
|
|
||||||
|
return c.ws.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) startPingLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.sendSessionMessage("ping", nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) startMessageLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
var res WSMessage
|
||||||
|
if err := c.ws.ReadJSON(&res); err != nil {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
// Ignore error if closed
|
||||||
|
default:
|
||||||
|
c.onWsError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.onWsMessage(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) activateSession() error {
|
||||||
|
if err := c.sendSessionMessage("activate_session", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
streamPayload := map[string]interface{}{
|
||||||
|
"audio_enabled": true,
|
||||||
|
"video_enabled": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.wsMutex.Lock()
|
||||||
|
defer c.wsMutex.Unlock()
|
||||||
|
|
||||||
|
if payload == nil {
|
||||||
|
payload = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["doorbot_id"] = c.cameraID
|
||||||
|
if c.sessionID != "" {
|
||||||
|
payload["session_id"] = c.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"method": method,
|
||||||
|
"dialog_id": c.dialogID,
|
||||||
|
"body": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawMsg, _ := json.Marshal(msg)
|
||||||
|
// fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg))
|
||||||
|
|
||||||
|
if err := c.ws.WriteJSON(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) onWsMessage(msg WSMessage) {
|
||||||
|
if c.onMessage != nil {
|
||||||
|
c.onMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) onWsError(err error) {
|
||||||
|
if c.onError != nil {
|
||||||
|
c.onError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) onWsClose() {
|
||||||
|
if c.onClose != nil {
|
||||||
|
c.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-7
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user