mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-05 16:26:50 +08:00
Add Nest source for WebRTC cameras
This commit is contained in:
55
internal/nest/init.go
Normal file
55
internal/nest/init.go
Normal file
@@ -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)
|
||||||
|
}
|
2
main.go
2
main.go
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
||||||
"github.com/AlexxIT/go2rtc/internal/mp4"
|
"github.com/AlexxIT/go2rtc/internal/mp4"
|
||||||
"github.com/AlexxIT/go2rtc/internal/mpegts"
|
"github.com/AlexxIT/go2rtc/internal/mpegts"
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/nest"
|
||||||
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
||||||
"github.com/AlexxIT/go2rtc/internal/onvif"
|
"github.com/AlexxIT/go2rtc/internal/onvif"
|
||||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||||
@@ -51,6 +52,7 @@ func main() {
|
|||||||
isapi.Init()
|
isapi.Init()
|
||||||
mpegts.Init()
|
mpegts.Init()
|
||||||
roborock.Init()
|
roborock.Init()
|
||||||
|
nest.Init()
|
||||||
|
|
||||||
srtp.Init()
|
srtp.Init()
|
||||||
homekit.Init()
|
homekit.Init()
|
||||||
|
205
pkg/nest/api.go
Normal file
205
pkg/nest/api.go
Normal file
@@ -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"`
|
||||||
|
}
|
101
pkg/nest/client.go
Normal file
101
pkg/nest/client.go
Normal file
@@ -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()
|
||||||
|
}
|
29
www/add.html
29
www/add.html
@@ -197,6 +197,35 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="nest">Google Nest</button>
|
||||||
|
<div class="module">
|
||||||
|
<form id="nest-form" style="margin-bottom: 10px">
|
||||||
|
<input type="text" name="client_id" placeholder="client_id">
|
||||||
|
<input type="text" name="client_secret" placeholder="client_secret">
|
||||||
|
<input type="text" name="refresh_token" placeholder="refresh_token">
|
||||||
|
<input type="text" name="project_id" placeholder="project_id">
|
||||||
|
<input type="submit" value="Login">
|
||||||
|
</form>
|
||||||
|
<table id="nest-table">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('nest').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'block'
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault()
|
||||||
|
|
||||||
|
const query = new URLSearchParams(new FormData(ev.target))
|
||||||
|
const url = new URL('api/nest?' + query.toString(), location.href)
|
||||||
|
|
||||||
|
const r = await fetch(url, {cache: 'no-cache'})
|
||||||
|
await getStreams(r, 'nest-table')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="hass">Home Assistant</button>
|
<button id="hass">Home Assistant</button>
|
||||||
<div class="module">
|
<div class="module">
|
||||||
<table id="hass-table"></table>
|
<table id="hass-table"></table>
|
||||||
|
Reference in New Issue
Block a user