mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-09-26 20:31:11 +08:00
initial ring implementation
This commit is contained in:
47
internal/ring/init.go
Normal file
47
internal/ring/init.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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()
|
||||
refreshToken := query.Get("refresh_token")
|
||||
|
||||
ringAPI, err := ring.NewRingRestClient(ring.RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := ringAPI.FetchRingDevices()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var items []*api.Source
|
||||
|
||||
for _, camera := range devices.AllCameras {
|
||||
query.Set("device_id", camera.DeviceID)
|
||||
|
||||
items = append(items, &api.Source{
|
||||
Name: camera.Description, URL: "ring:?" + query.Encode(),
|
||||
})
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
}
|
2
main.go
2
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
|
||||
|
416
pkg/ring/api.go
Normal file
416
pkg/ring/api.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RefreshTokenAuth struct {
|
||||
RefreshToken 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"
|
||||
}
|
||||
|
||||
// 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
|
||||
auth RefreshTokenAuth
|
||||
onTokenRefresh func(string) // Callback when refresh token is updated
|
||||
}
|
||||
|
||||
// CameraKind represents the different types of Ring cameras
|
||||
type CameraKind string
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// RingDeviceType represents different types of Ring devices
|
||||
type RingDeviceType string
|
||||
|
||||
const (
|
||||
IntercomHandsetAudio RingDeviceType = "intercom_handset_audio"
|
||||
OnvifCameraType RingDeviceType = "onvif_camera"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 (
|
||||
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 RefreshTokenAuth, onTokenRefresh func(string)) (*RingRestClient, error) {
|
||||
client := &RingRestClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
},
|
||||
auth: auth,
|
||||
onTokenRefresh: onTokenRefresh,
|
||||
hardwareID: generateHardwareID(),
|
||||
}
|
||||
|
||||
// check if refresh token is provided
|
||||
if auth.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
|
||||
if config, err := parseAuthConfig(auth.RefreshToken); err == nil {
|
||||
client.authConfig = config
|
||||
client.hardwareID = config.HID
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
551
pkg/ring/client.go
Normal file
551
pkg/ring/client.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"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"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *webrtc.Conn
|
||||
ws *websocket.Conn
|
||||
api *RingRestClient
|
||||
camera *CameraData
|
||||
dialogID string
|
||||
sessionID string
|
||||
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. Create Ring Rest API client
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
encodedToken := query.Get("refresh_token")
|
||||
deviceID := query.Get("device_id")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
println("Connecting to Ring WebSocket")
|
||||
println("Refresh Token: ", refreshToken)
|
||||
println("Device ID: ", deviceID)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 2. Connect to signaling server
|
||||
ticket, err := ringAPI.GetSocketTicket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
println("WebSocket Ticket: ", ticket.Ticket)
|
||||
println("WebSocket ResponseTimestamp: ", ticket.ResponseTimestamp)
|
||||
|
||||
// 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))
|
||||
|
||||
println("WebSocket URL: ", wsURL)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, map[string][]string{
|
||||
"User-Agent": {"android:com.ringapp"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
println("WebSocket handshake completed successfully")
|
||||
|
||||
// 3. Create Peer Connection
|
||||
println("Creating 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 {
|
||||
println("Failed to create WebRTC API")
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc, err := api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
println("Failed to create Peer Connection")
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
println("Peer Connection created")
|
||||
|
||||
// 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 := &Client{
|
||||
ws: conn,
|
||||
api: ringAPI,
|
||||
camera: camera,
|
||||
dialogID: uuid.NewString(),
|
||||
conn: prod,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
_ = sendOffer.Wait()
|
||||
|
||||
iceCandidate := msg.ToJSON()
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 4. Create offer
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
println("Failed to create offer")
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
println("Offer created")
|
||||
println(offer)
|
||||
|
||||
// 5. 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 {
|
||||
println("Failed to send live_view message")
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done(nil)
|
||||
|
||||
// Ring expects a ping message every 5 seconds
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if pc.ConnectionState() == pion.PeerConnectionStateConnected {
|
||||
if err := client.sendSessionMessage("ping", nil); err != nil {
|
||||
println("Failed to send ping:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
|
||||
// will be closed when conn will be closed
|
||||
defer func() {
|
||||
connState.Done(err)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
default:
|
||||
var res BaseMessage
|
||||
if err = conn.ReadJSON(&res); err != nil {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
println("WebSocket closed normally")
|
||||
} else {
|
||||
println("Failed to read JSON message:", err)
|
||||
client.Stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
println("Received message:", res.Method)
|
||||
println("Message body:", bodyStr)
|
||||
|
||||
// check if "doorbot_id" is present and matches the camera ID
|
||||
if _, ok := res.Body["doorbot_id"]; !ok {
|
||||
println("Received message without doorbot_id")
|
||||
continue
|
||||
}
|
||||
|
||||
doorbotID := res.Body["doorbot_id"].(float64)
|
||||
if doorbotID != float64(client.camera.ID) {
|
||||
println("Received message from unknown doorbot:", doorbotID)
|
||||
continue
|
||||
}
|
||||
|
||||
if res.Method == "session_created" || res.Method == "session_started" {
|
||||
if _, ok := res.Body["session_id"]; ok && client.sessionID == "" {
|
||||
client.sessionID = res.Body["session_id"].(string)
|
||||
println("Session established:", client.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := res.Body["session_id"]; ok {
|
||||
if res.Body["session_id"].(string) != client.sessionID {
|
||||
println("Received message with wrong session ID")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rawMsg, _ := json.Marshal(res)
|
||||
|
||||
switch res.Method {
|
||||
case "sdp":
|
||||
// 6. Get answer
|
||||
var msg AnswerMessage
|
||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
||||
println("Failed to parse SDP message:", err)
|
||||
client.Stop()
|
||||
return
|
||||
}
|
||||
if err = prod.SetAnswer(msg.Body.SDP); err != nil {
|
||||
println("Failed to set answer:", err)
|
||||
client.Stop()
|
||||
return
|
||||
}
|
||||
if err = client.activateSession(); err != nil {
|
||||
println("Failed to activate session:", err)
|
||||
client.Stop()
|
||||
return
|
||||
}
|
||||
|
||||
case "ice":
|
||||
// 7. Continue to receiving candidates
|
||||
var msg IceCandidateMessage
|
||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
||||
println("Failed to parse ICE message:", err)
|
||||
client.Stop()
|
||||
return
|
||||
}
|
||||
|
||||
if err = prod.AddCandidate(msg.Body.Ice); err != nil {
|
||||
client.Stop()
|
||||
return
|
||||
}
|
||||
|
||||
case "close":
|
||||
client.Stop()
|
||||
return
|
||||
|
||||
case "pong":
|
||||
// Ignore
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err = connState.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) activateSession() error {
|
||||
println("Activating session")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
println("Session activated")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error {
|
||||
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,
|
||||
}
|
||||
|
||||
println("Sending session message:", method)
|
||||
|
||||
if err := c.ws.WriteJSON(msg); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send JSON message")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
println("Getting medias")
|
||||
return c.conn.GetMedias()
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
println("Getting track")
|
||||
return c.conn.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
println("Adding track")
|
||||
return c.conn.AddTrack(media, codec, track)
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
println("Starting client")
|
||||
return c.conn.Start()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
return nil
|
||||
default:
|
||||
println("Stopping client")
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Stop()
|
||||
c.conn = 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) {
|
||||
return c.conn.MarshalJSON()
|
||||
}
|
25
www/add.html
25
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}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
||||
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td>${row[k]}</td>` : html, '<tr>') + '</tr>';
|
||||
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td style="word-break: break-word;white-space: normal;">${row[k]}</td>` : html, '<tr>') + '</tr>';
|
||||
|
||||
const thead = th(data.sources[0]);
|
||||
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
||||
@@ -218,6 +218,29 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<button id="ring">Ring</button>
|
||||
<div class="module">
|
||||
<form id="ring-form" style="margin-bottom: 10px">
|
||||
<input type="text" name="refresh_token" placeholder="refresh_token">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
<table id="ring-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('ring').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('ring-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/ring?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
await getSources('ring-table', r);
|
||||
});
|
||||
</script>
|
||||
|
||||
<button id="gopro">GoPro</button>
|
||||
<div class="module">
|
||||
|
Reference in New Issue
Block a user