diff --git a/internal/ring/init.go b/internal/ring/init.go new file mode 100644 index 00000000..bc49178b --- /dev/null +++ b/internal/ring/init.go @@ -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) +} diff --git a/main.go b/main.go index db8de9f4..db3983cc 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" + "github.com/AlexxIT/go2rtc/internal/ring" "github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/rtmp" "github.com/AlexxIT/go2rtc/internal/rtsp" @@ -80,6 +81,7 @@ func main() { mpegts.Init() // mpegts passive source roborock.Init() // roborock source homekit.Init() // homekit source + ring.Init() // ring source nest.Init() // nest source bubble.Init() // bubble source expr.Init() // expr source diff --git a/pkg/ring/api.go b/pkg/ring/api.go new file mode 100644 index 00000000..e025e031 --- /dev/null +++ b/pkg/ring/api.go @@ -0,0 +1,545 @@ +package ring + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "time" +) + +type RefreshTokenAuth struct { + RefreshToken string +} + +type EmailAuth struct { + Email string + Password string +} + +// AuthConfig represents the decoded refresh token data +type AuthConfig struct { + RT string `json:"rt"` // Refresh Token + HID string `json:"hid"` // Hardware ID +} + +// AuthTokenResponse represents the response from the authentication endpoint +type AuthTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` // Always "client" + TokenType string `json:"token_type"` // Always "Bearer" +} + +type Auth2faResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + TSVState string `json:"tsv_state"` + Phone string `json:"phone"` + NextTimeInSecs int `json:"next_time_in_secs"` +} + +// SocketTicketRequest represents the request to get a socket ticket +type SocketTicketResponse struct { + Ticket string `json:"ticket"` + ResponseTimestamp int64 `json:"response_timestamp"` +} + +// RingRestClient handles authentication and requests to Ring API +type RingRestClient struct { + httpClient *http.Client + authConfig *AuthConfig + hardwareID string + authToken *AuthTokenResponse + Using2FA bool + PromptFor2FA string + RefreshToken string + auth interface{} // EmailAuth or RefreshTokenAuth + onTokenRefresh func(string) +} + +// CameraKind represents the different types of Ring cameras +type CameraKind string + +// CameraData contains common fields for all camera types +type CameraData struct { + ID float64 `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` +} + +// RingDeviceType represents different types of Ring devices +type RingDeviceType string + +// RingDevicesResponse represents the response from the Ring API +type RingDevicesResponse struct { + Doorbots []CameraData `json:"doorbots"` + AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` + StickupCams []CameraData `json:"stickup_cams"` + AllCameras []CameraData `json:"all_cameras"` + Chimes []CameraData `json:"chimes"` + Other []map[string]interface{} `json:"other"` +} + +const ( + Doorbot CameraKind = "doorbot" + Doorbell CameraKind = "doorbell" + DoorbellV3 CameraKind = "doorbell_v3" + DoorbellV4 CameraKind = "doorbell_v4" + DoorbellV5 CameraKind = "doorbell_v5" + DoorbellOyster CameraKind = "doorbell_oyster" + DoorbellPortal CameraKind = "doorbell_portal" + DoorbellScallop CameraKind = "doorbell_scallop" + DoorbellScallopLite CameraKind = "doorbell_scallop_lite" + DoorbellGraham CameraKind = "doorbell_graham_cracker" + LpdV1 CameraKind = "lpd_v1" + LpdV2 CameraKind = "lpd_v2" + LpdV4 CameraKind = "lpd_v4" + JboxV1 CameraKind = "jbox_v1" + StickupCam CameraKind = "stickup_cam" + StickupCamV3 CameraKind = "stickup_cam_v3" + StickupCamElite CameraKind = "stickup_cam_elite" + StickupCamLongfin CameraKind = "stickup_cam_longfin" + StickupCamLunar CameraKind = "stickup_cam_lunar" + SpotlightV2 CameraKind = "spotlightw_v2" + HpCamV1 CameraKind = "hp_cam_v1" + HpCamV2 CameraKind = "hp_cam_v2" + StickupCamV4 CameraKind = "stickup_cam_v4" + FloodlightV1 CameraKind = "floodlight_v1" + FloodlightV2 CameraKind = "floodlight_v2" + FloodlightPro CameraKind = "floodlight_pro" + CocoaCamera CameraKind = "cocoa_camera" + CocoaDoorbell CameraKind = "cocoa_doorbell" + CocoaFloodlight CameraKind = "cocoa_floodlight" + CocoaSpotlight CameraKind = "cocoa_spotlight" + StickupCamMini CameraKind = "stickup_cam_mini" + OnvifCamera CameraKind = "onvif_camera" +) + +const ( + IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" + OnvifCameraType RingDeviceType = "onvif_camera" +) + +const ( + clientAPIBaseURL = "https://api.ring.com/clients_api/" + deviceAPIBaseURL = "https://api.ring.com/devices/v1/" + commandsAPIBaseURL = "https://api.ring.com/commands/v1/" + appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" + oauthURL = "https://oauth.ring.com/oauth/token" + apiVersion = 11 + defaultTimeout = 20 * time.Second + maxRetries = 3 +) + +// NewRingRestClient creates a new Ring client instance +func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { + client := &RingRestClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + } + + switch a := auth.(type) { + case RefreshTokenAuth: + if a.RefreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + + config, err := parseAuthConfig(a.RefreshToken) + if err != nil { + return nil, fmt.Errorf("failed to parse refresh token: %w", err) + } + + client.authConfig = config + client.hardwareID = config.HID + client.RefreshToken = a.RefreshToken + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + default: + return nil, fmt.Errorf("invalid auth type") + } + + return client, nil +} + +// Request makes an authenticated request to the Ring API +func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { + // Ensure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + // Create request + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + // Make request with retries + var resp *http.Response + var responseBody []byte + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = c.httpClient.Do(req) + if err != nil { + if attempt == maxRetries { + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) + } + time.Sleep(5 * time.Second) + continue + } + defer resp.Body.Close() + + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Handle 401 by refreshing auth and retrying + if resp.StatusCode == http.StatusUnauthorized { + c.authToken = nil // Force token refresh + if attempt == maxRetries { + return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) + } + if err := c.ensureAuth(); err != nil { + return nil, fmt.Errorf("failed to refresh authentication: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + continue + } + + // Handle other error status codes + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + break + } + + return responseBody, nil +} + +// ensureAuth ensures we have a valid auth token +func (c *RingRestClient) ensureAuth() error { + if c.authToken != nil { + return nil + } + + var grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + + // Add common fields + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + // Make auth request + body, err := json.Marshal(grantData) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return fmt.Errorf("2FA required. Please see documentation for handling 2FA") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return fmt.Errorf("failed to decode auth response: %w", err) + } + + // Update auth config and refresh token + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + return nil +} + +// getAuth makes an authentication request to the Ring API +func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { + var grantData map[string]string + + if c.authConfig != nil && twoFactorAuthCode == "" { + grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + } else { + authEmail, ok := c.auth.(EmailAuth) + if !ok { + return nil, fmt.Errorf("invalid auth type for email authentication") + } + grantData = map[string]string{ + "grant_type": "password", + "username": authEmail.Email, + "password": authEmail.Password, + } + } + + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + body, err := json.Marshal(grantData) + if err != nil { + return nil, fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + if twoFactorAuthCode != "" { + req.Header.Set("2fa-code", twoFactorAuthCode) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle 2FA Responses + if resp.StatusCode == http.StatusPreconditionFailed || + (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { + + var tfaResp Auth2faResponse + if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { + return nil, err + } + + c.Using2FA = true + if resp.StatusCode == http.StatusBadRequest { + c.PromptFor2FA = "Invalid 2fa code entered. Please try again." + return nil, fmt.Errorf("invalid 2FA code") + } + + if tfaResp.TSVState != "" { + prompt := "from your authenticator app" + if tfaResp.TSVState != "totp" { + prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) + } + c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) + } else { + c.PromptFor2FA = "Please enter the code sent to your text/email" + } + + return nil, fmt.Errorf("2FA required") + } + + // Handle errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return nil, fmt.Errorf("failed to decode auth response: %w", err) + } + + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + c.RefreshToken = encodeAuthConfig(c.authConfig) + if c.onTokenRefresh != nil { + c.onTokenRefresh(c.RefreshToken) + } + + return c.authToken, nil +} + +// Helper functions for auth config encoding/decoding +func parseAuthConfig(refreshToken string) (*AuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(refreshToken) + if err != nil { + return nil, err + } + + var config AuthConfig + if err := json.Unmarshal(decoded, &config); err != nil { + // Handle legacy format where refresh token is the raw token + return &AuthConfig{RT: refreshToken}, nil + } + + return &config, nil +} + +func encodeAuthConfig(config *AuthConfig) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + +// API URL helpers +func ClientAPI(path string) string { + return clientAPIBaseURL + path +} + +func DeviceAPI(path string) string { + return deviceAPIBaseURL + path +} + +func CommandsAPI(path string) string { + return commandsAPIBaseURL + path +} + +func AppAPI(path string) string { + return appAPIBaseURL + path +} + +// FetchRingDevices gets all Ring devices and categorizes them +func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { + response, err := c.Request("GET", ClientAPI("ring_devices"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch ring devices: %w", err) + } + + var devices RingDevicesResponse + if err := json.Unmarshal(response, &devices); err != nil { + return nil, fmt.Errorf("failed to unmarshal devices response: %w", err) + } + + // Process "other" devices + var onvifCameras []CameraData + var intercoms []CameraData + + for _, device := range devices.Other { + kind, ok := device["kind"].(string) + if !ok { + continue + } + + switch RingDeviceType(kind) { + case OnvifCameraType: + var camera CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &camera); err == nil { + onvifCameras = append(onvifCameras, camera) + } + } + case IntercomHandsetAudio: + var intercom CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &intercom); err == nil { + intercoms = append(intercoms, intercom) + } + } + } + } + + // Combine all cameras into AllCameras slice + allCameras := make([]CameraData, 0) + allCameras = append(allCameras, interfaceSlice(devices.Doorbots)...) + allCameras = append(allCameras, interfaceSlice(devices.StickupCams)...) + allCameras = append(allCameras, interfaceSlice(devices.AuthorizedDoorbots)...) + allCameras = append(allCameras, interfaceSlice(onvifCameras)...) + allCameras = append(allCameras, interfaceSlice(intercoms)...) + + devices.AllCameras = allCameras + + return &devices, nil +} + +func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { + response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) + } + + var ticket SocketTicketResponse + if err := json.Unmarshal(response, &ticket); err != nil { + return nil, fmt.Errorf("failed to unmarshal socket ticket response: %w", err) + } + + return &ticket, nil +} + +func generateHardwareID() string { + h := sha256.New() + h.Write([]byte("ring-client-go2rtc")) + return hex.EncodeToString(h.Sum(nil)[:16]) +} + +func interfaceSlice(slice interface{}) []CameraData { + s := reflect.ValueOf(slice) + if s.Kind() != reflect.Slice { + return nil + } + + ret := make([]CameraData, s.Len()) + for i := 0; i < s.Len(); i++ { + if camera, ok := s.Index(i).Interface().(CameraData); ok { + ret[i] = camera + } + } + return ret +} \ No newline at end of file diff --git a/pkg/ring/client.go b/pkg/ring/client.go new file mode 100644 index 00000000..c432ecf9 --- /dev/null +++ b/pkg/ring/client.go @@ -0,0 +1,542 @@ +package ring + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v3" +) + +type Client struct { + api *RingRestClient + ws *websocket.Conn + prod core.Producer + camera *CameraData + dialogID string + sessionID string + wsMutex sync.Mutex + done chan struct{} +} + +type SessionBody struct { + DoorbotID int `json:"doorbot_id"` + SessionID string `json:"session_id"` +} + +type AnswerMessage struct { + Method string `json:"method"` // "sdp" + Body struct { + SessionBody + SDP string `json:"sdp"` + Type string `json:"type"` // "answer" + } `json:"body"` +} + +type IceCandidateMessage struct { + Method string `json:"method"` // "ice" + Body struct { + SessionBody + Ice string `json:"ice"` + MLineIndex int `json:"mlineindex"` + } `json:"body"` +} + +type SessionMessage struct { + Method string `json:"method"` // "session_created" or "session_started" + Body SessionBody `json:"body"` +} + +type PongMessage struct { + Method string `json:"method"` // "pong" + Body SessionBody `json:"body"` +} + +type NotificationMessage struct { + Method string `json:"method"` // "notification" + Body struct { + SessionBody + IsOK bool `json:"is_ok"` + Text string `json:"text"` + } `json:"body"` +} + +type StreamInfoMessage struct { + Method string `json:"method"` // "stream_info" + Body struct { + SessionBody + Transcoding bool `json:"transcoding"` + TranscodingReason string `json:"transcoding_reason"` + } `json:"body"` +} + +type CloseMessage struct { + Method string `json:"method"` // "close" + Body struct { + SessionBody + Reason struct { + Code int `json:"code"` + Text string `json:"text"` + } `json:"reason"` + } `json:"body"` +} + +type BaseMessage struct { + Method string `json:"method"` + Body map[string]any `json:"body"` +} + +// Close reason codes +const ( + CloseReasonNormalClose = 0 + CloseReasonAuthenticationFailed = 5 + CloseReasonTimeout = 6 +) + +func Dial(rawURL string) (*Client, error) { + // 1. Parse URL and validate basic params + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + encodedToken := query.Get("refresh_token") + deviceID := query.Get("device_id") + _, isSnapshot := query["snapshot"] + + if encodedToken == "" || deviceID == "" { + return nil, errors.New("ring: wrong query") + } + + // URL-decode the refresh token + refreshToken, err := url.QueryUnescape(encodedToken) + if err != nil { + return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) + } + + // Initialize Ring API client + ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) + if err != nil { + return nil, err + } + + // Get camera details + devices, err := ringAPI.FetchRingDevices() + if err != nil { + return nil, err + } + + var camera *CameraData + for _, cam := range devices.AllCameras { + if fmt.Sprint(cam.DeviceID) == deviceID { + camera = &cam + break + } + } + if camera == nil { + return nil, errors.New("ring: camera not found") + } + + // Create base client + client := &Client{ + api: ringAPI, + camera: camera, + dialogID: uuid.NewString(), + done: make(chan struct{}), + } + + // Check if snapshot request + if isSnapshot { + client.prod = NewSnapshotProducer(ringAPI, camera) + return client, nil + } + + // If not snapshot, continue with WebRTC setup + ticket, err := ringAPI.GetSocketTicket() + if err != nil { + return nil, err + } + + // Create WebSocket connection + wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", + uuid.NewString(), url.QueryEscape(ticket.Ticket)) + + client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{ + "User-Agent": {"android:com.ringapp"}, + }) + if err != nil { + return nil, err + } + + // Create Peer Connection + conf := pion.Configuration{ + ICEServers: []pion.ICEServer{ + {URLs: []string{ + "stun:stun.kinesisvideo.us-east-1.amazonaws.com:443", + "stun:stun.kinesisvideo.us-east-2.amazonaws.com:443", + "stun:stun.kinesisvideo.us-west-2.amazonaws.com:443", + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + "stun:stun3.l.google.com:19302", + "stun:stun4.l.google.com:19302", + }}, + }, + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyBalanced, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.ws.Close() + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + client.ws.Close() + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + + prod := webrtc.NewConn(pc) + prod.FormatName = "ring/webrtc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL + + client.prod = prod + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + + iceCandidate := msg.ToJSON() + + // skip empty ICE candidates + if iceCandidate.Candidate == "" { + return + } + + icePayload := map[string]interface{}{ + "ice": iceCandidate.Candidate, + "mlineindex": iceCandidate.SDPMLineIndex, + } + + if err = client.sendSessionMessage("ice", icePayload); err != nil { + connState.Done(err) + return + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("ring: " + msg.String())) + } + } + }) + + // Setup media configuration + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendRecv, + Codecs: []*core.Codec{ + { + Name: "opus", + ClockRate: 48000, + Channels: 2, + }, + }, + }, + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "H264", + ClockRate: 90000, + }, + }, + }, + } + + // Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + client.Stop() + return nil, err + } + + // Send offer + offerPayload := map[string]interface{}{ + "stream_options": map[string]bool{ + "audio_enabled": true, + "video_enabled": true, + }, + "sdp": offer, + } + + if err = client.sendSessionMessage("live_view", offerPayload); err != nil { + client.Stop() + return nil, err + } + + sendOffer.Done(nil) + + // Ring expects a ping message every 5 seconds + go client.startPingLoop(pc) + go client.startMessageLoop(&connState) + + if err = connState.Wait(); err != nil { + return nil, err + } + + return client, nil +} + +func (c *Client) startPingLoop(pc *pion.PeerConnection) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + if pc.ConnectionState() == pion.PeerConnectionStateConnected { + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } + } +} + +func (c *Client) startMessageLoop(connState *core.Waiter) { + var err error + + // will be closed when conn will be closed + defer func() { + connState.Done(err) + }() + + for { + select { + case <-c.done: + return + default: + var res BaseMessage + if err = c.ws.ReadJSON(&res); err != nil { + select { + case <-c.done: + return + default: + } + + c.Stop() + return + } + + // check if "doorbot_id" is present + if _, ok := res.Body["doorbot_id"]; !ok { + continue + } + + // check if the message is from the correct doorbot + doorbotID := res.Body["doorbot_id"].(float64) + if doorbotID != float64(c.camera.ID) { + continue + } + + // check if the message is from the correct session + if res.Method == "session_created" || res.Method == "session_started" { + if _, ok := res.Body["session_id"]; ok && c.sessionID == "" { + c.sessionID = res.Body["session_id"].(string) + } + } + + if _, ok := res.Body["session_id"]; ok { + if res.Body["session_id"].(string) != c.sessionID { + continue + } + } + + rawMsg, _ := json.Marshal(res) + + switch res.Method { + case "sdp": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Get answer + var msg AnswerMessage + if err = json.Unmarshal(rawMsg, &msg); err != nil { + c.Stop() + return + } + if err = prod.SetAnswer(msg.Body.SDP); err != nil { + c.Stop() + return + } + if err = c.activateSession(); err != nil { + c.Stop() + return + } + } + + case "ice": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Continue to receiving candidates + var msg IceCandidateMessage + if err = json.Unmarshal(rawMsg, &msg); err != nil { + break + } + + // check for empty ICE candidate + if msg.Body.Ice == "" { + break + } + + if err = prod.AddCandidate(msg.Body.Ice); err != nil { + c.Stop() + return + } + } + + case "close": + c.Stop() + return + + case "pong": + // Ignore + continue + } + } + } +} + +func (c *Client) activateSession() error { + if err := c.sendSessionMessage("activate_session", nil); err != nil { + return err + } + + streamPayload := map[string]interface{}{ + "audio_enabled": true, + "video_enabled": true, + } + + if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { + return err + } + + return nil +} + +func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { + c.wsMutex.Lock() + defer c.wsMutex.Unlock() + + if body == nil { + body = make(map[string]interface{}) + } + + body["doorbot_id"] = c.camera.ID + if c.sessionID != "" { + body["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": body, + } + + if err := c.ws.WriteJSON(msg); err != nil { + return err + } + + return nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.prod.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.prod.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + if media.Kind == core.KindAudio { + // Enable speaker + speakerPayload := map[string]interface{}{ + "stealth_mode": false, + } + _ = c.sendSessionMessage("camera_options", speakerPayload) + } + return webrtcProd.AddTrack(media, codec, track) + } + + return fmt.Errorf("add track not supported for snapshot") +} + +func (c *Client) Start() error { + return c.prod.Start() +} + +func (c *Client) Stop() error { + select { + case <-c.done: + return nil + default: + close(c.done) + } + + if c.prod != nil { + _ = c.prod.Stop() + c.prod = nil + } + + if c.ws != nil { + closePayload := map[string]interface{}{ + "reason": map[string]interface{}{ + "code": CloseReasonNormalClose, + "text": "", + }, + } + + _ = c.sendSessionMessage("close", closePayload) + _ = c.ws.Close() + c.ws = nil + } + + return nil +} + +func (c *Client) MarshalJSON() ([]byte, error) { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + return webrtcProd.MarshalJSON() + } + + return nil, errors.New("ring: can't marshal") +} \ No newline at end of file diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go new file mode 100644 index 00000000..bbf86e28 --- /dev/null +++ b/pkg/ring/snapshot.go @@ -0,0 +1,64 @@ +package ring + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type SnapshotProducer struct { + core.Connection + + client *RingRestClient + camera *CameraData +} + +func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { + return &SnapshotProducer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "ring/snapshot", + Protocol: "https", + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + client: client, + camera: camera, + } +} + +func (p *SnapshotProducer) Start() error { + // Fetch snapshot + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil) + if err != nil { + return fmt.Errorf("failed to get snapshot: %w", err) + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: response, + } + + // Send to all receivers + for _, receiver := range p.Receivers { + receiver.WriteRTP(pkt) + } + + return nil +} + +func (p *SnapshotProducer) Stop() error { + return p.Connection.Stop() +} \ No newline at end of file diff --git a/www/add.html b/www/add.html index 49e954d3..7dae63d4 100644 --- a/www/add.html +++ b/www/add.html @@ -35,7 +35,7 @@ function drawTable(table, data) { const cols = ['id', 'name', 'info', 'url', 'location']; const th = (row) => cols.reduce((html, k) => k in row ? `${html}${k}` : html, '') + ''; - const td = (row) => cols.reduce((html, k) => k in row ? `${html}${row[k]}` : html, '') + ''; + const td = (row) => cols.reduce((html, k) => k in row ? `${html}${row[k]}` : html, '') + ''; const thead = th(data.sources[0]); const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, ''); @@ -218,6 +218,55 @@ }); + +
+
+ + + + +
+
+ + +
+
+
+