diff --git a/internal/nest/init.go b/internal/nest/init.go new file mode 100644 index 00000000..e48224fb --- /dev/null +++ b/internal/nest/init.go @@ -0,0 +1,55 @@ +package nest + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/nest" + "net/http" +) + +func Init() { + streams.HandleFunc("nest", streamNest) + + api.HandleFunc("api/nest", apiNest) +} + +func streamNest(url string) (core.Producer, error) { + client, err := nest.NewClient(url) + if err != nil { + return nil, err + } + return client, nil +} + +func apiNest(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + cliendID := query.Get("client_id") + cliendSecret := query.Get("client_secret") + refreshToken := query.Get("refresh_token") + projectID := query.Get("project_id") + + nestAPI, err := nest.NewAPI(cliendID, cliendSecret, refreshToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := nestAPI.GetDevices(projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []api.Stream + + for name, deviceID := range devices { + query.Set("device_id", deviceID) + + items = append(items, api.Stream{ + Name: name, URL: "nest:?" + query.Encode(), + }) + } + + api.ResponseStreams(w, items) +} diff --git a/main.go b/main.go index 973537cd..6255841d 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/mjpeg" "github.com/AlexxIT/go2rtc/internal/mp4" "github.com/AlexxIT/go2rtc/internal/mpegts" + "github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" "github.com/AlexxIT/go2rtc/internal/roborock" @@ -51,6 +52,7 @@ func main() { isapi.Init() mpegts.Init() roborock.Init() + nest.Init() srtp.Init() homekit.Init() diff --git a/pkg/nest/api.go b/pkg/nest/api.go new file mode 100644 index 00000000..9c7f4546 --- /dev/null +++ b/pkg/nest/api.go @@ -0,0 +1,205 @@ +package nest + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type API struct { + Token string + ExpiresAt time.Time +} + +type Auth struct { + AccessToken string +} + +var cache = map[string]*API{} +var cacheMu sync.Mutex + +func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) { + cacheMu.Lock() + defer cacheMu.Unlock() + + key := clientID + ":" + clientSecret + ":" + refreshToken + now := time.Now() + + if api := cache[key]; api != nil && now.Before(api.ExpiresAt) { + return api, nil + } + + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "client_secret": []string{clientSecret}, + "refresh_token": []string{refreshToken}, + } + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.PostForm("https://www.googleapis.com/oauth2/v4/token", data) + if err != nil { + return nil, err + } + if res.StatusCode != 200 { + return nil, errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + AccessToken string `json:"access_token"` + ExpiresIn time.Duration `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return nil, err + } + + api := &API{ + Token: resv.AccessToken, + ExpiresAt: now.Add(resv.ExpiresIn * time.Second), + } + + cache[key] = api + + return api, nil +} + +func (a *API) GetDevices(projectID string) (map[string]string, error) { + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices" + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Devices []Device + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return nil, err + } + + devices := map[string]string{} + + for _, device := range resv.Devices { + if len(device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols) == 0 { + continue + } + + if device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols[0] != "WEB_RTC" { + continue + } + + i := strings.LastIndexByte(device.Name, '/') + if i <= 0 { + continue + } + + name := device.Traits.SdmDevicesTraitsInfo.CustomName + devices[name] = device.Name[i+1:] + } + + return devices, nil +} + +func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) { + var reqv struct { + Command string `json:"command"` + Params struct { + Offer string `json:"offerSdp"` + } `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream" + reqv.Params.Offer = offer + + b, err := json.Marshal(reqv) + if err != nil { + return "", err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + projectID + "/devices/" + deviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return "", err + } + + if res.StatusCode != 200 { + return "", errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + Answer string `json:"answerSdp"` + ExpiresAt time.Time `json:"expiresAt"` + MediaSessionId string `json:"mediaSessionId"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return "", err + } + + return resv.Results.Answer, nil +} + +type Device struct { + Name string `json:"name"` + Type string `json:"type"` + //Assignee string `json:"assignee"` + Traits struct { + SdmDevicesTraitsInfo struct { + CustomName string `json:"customName"` + } `json:"sdm.devices.traits.Info"` + SdmDevicesTraitsCameraLiveStream struct { + VideoCodecs []string `json:"videoCodecs"` + AudioCodecs []string `json:"audioCodecs"` + SupportedProtocols []string `json:"supportedProtocols"` + } `json:"sdm.devices.traits.CameraLiveStream"` + //SdmDevicesTraitsCameraImage struct { + // MaxImageResolution struct { + // Width int `json:"width"` + // Height int `json:"height"` + // } `json:"maxImageResolution"` + //} `json:"sdm.devices.traits.CameraImage"` + //SdmDevicesTraitsCameraPerson struct { + //} `json:"sdm.devices.traits.CameraPerson"` + //SdmDevicesTraitsCameraMotion struct { + //} `json:"sdm.devices.traits.CameraMotion"` + //SdmDevicesTraitsDoorbellChime struct { + //} `json:"sdm.devices.traits.DoorbellChime"` + //SdmDevicesTraitsCameraClipPreview struct { + //} `json:"sdm.devices.traits.CameraClipPreview"` + } `json:"traits"` + //ParentRelations []struct { + // Parent string `json:"parent"` + // DisplayName string `json:"displayName"` + //} `json:"parentRelations"` +} diff --git a/pkg/nest/client.go b/pkg/nest/client.go new file mode 100644 index 00000000..5e8cad3a --- /dev/null +++ b/pkg/nest/client.go @@ -0,0 +1,101 @@ +package nest + +import ( + "errors" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v3" + "net/url" +) + +type Client struct { + conn *webrtc.Conn +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + cliendID := query.Get("client_id") + cliendSecret := query.Get("client_secret") + refreshToken := query.Get("refresh_token") + projectID := query.Get("project_id") + deviceID := query.Get("device_id") + + if cliendID == "" || cliendSecret == "" || refreshToken == "" || projectID == "" || deviceID == "" { + return nil, errors.New("nest: wrong query") + } + + nestAPI, err := NewAPI(cliendID, cliendSecret, refreshToken) + if err != nil { + return nil, err + } + + rtcAPI, err := webrtc.NewAPI("") + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := rtcAPI.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + conn := webrtc.NewConn(pc) + conn.Desc = "Nest" + conn.Mode = core.ModeActiveProducer + + // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: "app"}, // important for Nest + } + + // 3. Create offer with candidates + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 4. Exchange SDP via Hass + answer, err := nestAPI.ExchangeSDP(projectID, deviceID, offer) + if err != nil { + return nil, err + } + + // 5. Set answer with remote medias + if err = conn.SetAnswer(answer); err != nil { + return nil, err + } + + return &Client{conn: conn}, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + return c.conn.AddTrack(media, codec, track) +} + +func (c *Client) Start() error { + return c.conn.Start() +} + +func (c *Client) Stop() error { + return c.conn.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} diff --git a/www/add.html b/www/add.html index 06daa33d..0a99facb 100644 --- a/www/add.html +++ b/www/add.html @@ -197,6 +197,35 @@ + +