mirror of
				https://github.com/AlexxIT/go2rtc.git
				synced 2025-10-31 19:53:02 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			372 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			372 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package roborock
 | |
| 
 | |
| import (
 | |
| 	"crypto/md5"
 | |
| 	"encoding/base64"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"net/rpc"
 | |
| 	"net/url"
 | |
| 	"strconv"
 | |
| 	"sync"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/AlexxIT/go2rtc/pkg/core"
 | |
| 	"github.com/AlexxIT/go2rtc/pkg/roborock/iot"
 | |
| 	"github.com/AlexxIT/go2rtc/pkg/webrtc"
 | |
| 	pion "github.com/pion/webrtc/v3"
 | |
| )
 | |
| 
 | |
| type Client struct {
 | |
| 	core.Listener
 | |
| 
 | |
| 	url string
 | |
| 
 | |
| 	conn *webrtc.Conn
 | |
| 	iot  *rpc.Client
 | |
| 
 | |
| 	devKey   string
 | |
| 	pin      string
 | |
| 	devTopic string
 | |
| 
 | |
| 	audio       bool
 | |
| 	backchannel bool
 | |
| }
 | |
| 
 | |
| func NewClient(url string) *Client {
 | |
| 	return &Client{url: url}
 | |
| }
 | |
| 
 | |
| func (c *Client) Dial() error {
 | |
| 	u, err := url.Parse(c.url)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if c.iot, err = iot.Dial(c.url); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	c.pin = u.Query().Get("pin")
 | |
| 	if c.pin != "" {
 | |
| 		c.pin = fmt.Sprintf("%x", md5.Sum([]byte(c.pin)))
 | |
| 		return c.CheckHomesecPassword()
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (c *Client) Connect() error {
 | |
| 	// 1. Check if camera ready for connection
 | |
| 	for i := 0; ; i++ {
 | |
| 		clientID, err := c.GetHomesecConnectStatus()
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 		if clientID == "none" {
 | |
| 			break
 | |
| 		}
 | |
| 		if err = c.StopCameraPreview(clientID); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 		if i == 5 {
 | |
| 			return errors.New("camera not ready")
 | |
| 		}
 | |
| 		time.Sleep(time.Second)
 | |
| 	}
 | |
| 
 | |
| 	// 2. Start camera
 | |
| 	if err := c.StartCameraPreview(); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// 3. Get TURN config
 | |
| 	conf := pion.Configuration{}
 | |
| 
 | |
| 	if turn, _ := c.GetTurnServer(); turn != nil {
 | |
| 		conf.ICEServers = append(conf.ICEServers, *turn)
 | |
| 	}
 | |
| 
 | |
| 	// 4. Create Peer Connection
 | |
| 	api, err := webrtc.NewAPI()
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	pc, err := api.NewPeerConnection(conf)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	var connected = make(chan bool)
 | |
| 	var sendOffer sync.WaitGroup
 | |
| 
 | |
| 	c.conn = webrtc.NewConn(pc)
 | |
| 	c.conn.Desc = "Roborock"
 | |
| 	c.conn.Mode = core.ModeActiveProducer
 | |
| 	c.conn.Listen(func(msg any) {
 | |
| 		switch msg := msg.(type) {
 | |
| 		case *pion.ICECandidate:
 | |
| 			if msg != nil && msg.Component == 1 {
 | |
| 				sendOffer.Wait()
 | |
| 				_ = c.SendICEtoRobot(msg.ToJSON().Candidate, "0")
 | |
| 			}
 | |
| 		case pion.PeerConnectionState:
 | |
| 			if msg == pion.PeerConnectionStateConnecting {
 | |
| 				return
 | |
| 			}
 | |
| 			// unblocking write to channel
 | |
| 			select {
 | |
| 			case connected <- msg == pion.PeerConnectionStateConnected:
 | |
| 			default:
 | |
| 			}
 | |
| 		}
 | |
| 	})
 | |
| 
 | |
| 	// 5. Send Offer
 | |
| 	sendOffer.Add(1)
 | |
| 
 | |
| 	medias := []*core.Media{
 | |
| 		{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
 | |
| 		{Kind: core.KindAudio, Direction: core.DirectionSendRecv},
 | |
| 	}
 | |
| 
 | |
| 	if _, err = c.conn.CreateOffer(medias); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	offer := pc.LocalDescription()
 | |
| 	//log.Printf("[roborock] offer\n%s", offer.SDP)
 | |
| 	if err = c.SendSDPtoRobot(offer); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	sendOffer.Done()
 | |
| 
 | |
| 	// 6. Receive answer
 | |
| 	ts := time.Now().Add(time.Second * 5)
 | |
| 	for {
 | |
| 		time.Sleep(time.Second)
 | |
| 
 | |
| 		if desc, _ := c.GetDeviceSDP(); desc != nil {
 | |
| 			//log.Printf("[roborock] answer\n%s", desc.SDP)
 | |
| 			if err = c.conn.SetAnswer(desc.SDP); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 			break
 | |
| 		}
 | |
| 
 | |
| 		if time.Now().After(ts) {
 | |
| 			return errors.New("can't get device SDP")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	ticker := time.NewTicker(time.Second * 2)
 | |
| 	for {
 | |
| 		select {
 | |
| 		case <-ticker.C:
 | |
| 			// 7. Receive remote candidates
 | |
| 			if pc.ICEConnectionState() == pion.ICEConnectionStateCompleted {
 | |
| 				ticker.Stop()
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			if ice, _ := c.GetDeviceICE(); ice != nil {
 | |
| 				for _, candidate := range ice {
 | |
| 					_ = c.conn.AddCandidate(candidate)
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 		case ok := <-connected:
 | |
| 			// 8. Wait connected result (true or false)
 | |
| 			if !ok {
 | |
| 				return errors.New("can't connect")
 | |
| 			}
 | |
| 
 | |
| 			return nil
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (c *Client) CheckHomesecPassword() (err error) {
 | |
| 	var ok bool
 | |
| 
 | |
| 	params := `{"password":"` + c.pin + `"}`
 | |
| 	if err = c.iot.Call("check_homesec_password", params, &ok); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if !ok {
 | |
| 		return errors.New("wrong pin code")
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (c *Client) GetHomesecConnectStatus() (clientID string, err error) {
 | |
| 	var res []byte
 | |
| 
 | |
| 	if err = c.iot.Call("get_homesec_connect_status", nil, &res); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	var v struct {
 | |
| 		Status   int    `json:"status"`
 | |
| 		ClientID string `json:"client_id"`
 | |
| 	}
 | |
| 	if err = json.Unmarshal(res, &v); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	return v.ClientID, nil
 | |
| }
 | |
| 
 | |
| func (c *Client) StartCameraPreview() error {
 | |
| 	params := `{"client_id":"676f32727463","quality":"HD","password":"` + c.pin + `"}`
 | |
| 	return c.Request("start_camera_preview", params)
 | |
| }
 | |
| 
 | |
| func (c *Client) StopCameraPreview(clientID string) error {
 | |
| 	params := `{"client_id":"` + clientID + `"}`
 | |
| 	return c.Request("stop_camera_preview", params)
 | |
| }
 | |
| 
 | |
| func (c *Client) GetTurnServer() (turn *pion.ICEServer, err error) {
 | |
| 	var res []byte
 | |
| 
 | |
| 	if err = c.iot.Call("get_turn_server", nil, &res); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	var v struct {
 | |
| 		URL  string `json:"url"`
 | |
| 		User string `json:"user"`
 | |
| 		Pwd  string `json:"pwd"`
 | |
| 	}
 | |
| 	if err = json.Unmarshal(res, &v); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	turn = &pion.ICEServer{
 | |
| 		URLs:       []string{v.URL},
 | |
| 		Username:   v.User,
 | |
| 		Credential: v.Pwd,
 | |
| 	}
 | |
| 
 | |
| 	return
 | |
| }
 | |
| 
 | |
| func (c *Client) SendSDPtoRobot(offer *pion.SessionDescription) (err error) {
 | |
| 	b, err := json.Marshal(offer)
 | |
| 	if err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	params := `{"app_sdp":"` + base64.StdEncoding.EncodeToString(b) + `"}`
 | |
| 	return c.iot.Call("send_sdp_to_robot", params, nil)
 | |
| }
 | |
| 
 | |
| func (c *Client) SendICEtoRobot(candidate string, mid string) (err error) {
 | |
| 	b := []byte(`{"candidate":"` + candidate + `","sdpMLineIndex":` + mid + `,"sdpMid":"` + mid + `"}`)
 | |
| 
 | |
| 	params := `{"app_ice":"` + base64.StdEncoding.EncodeToString(b) + `"}`
 | |
| 	return c.iot.Call("send_ice_to_robot", params, nil)
 | |
| }
 | |
| 
 | |
| func (c *Client) GetDeviceSDP() (sd *pion.SessionDescription, err error) {
 | |
| 	var res []byte
 | |
| 
 | |
| 	if err = c.iot.Call("get_device_sdp", nil, &res); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if string(res) == `{"dev_sdp":"retry"}` {
 | |
| 		return nil, nil
 | |
| 	}
 | |
| 
 | |
| 	var v struct {
 | |
| 		SDP []byte `json:"dev_sdp"`
 | |
| 	}
 | |
| 	if err = json.Unmarshal(res, &v); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	sd = &pion.SessionDescription{}
 | |
| 	if err = json.Unmarshal(v.SDP, sd); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return
 | |
| }
 | |
| 
 | |
| func (c *Client) GetDeviceICE() (ice []string, err error) {
 | |
| 	var res []byte
 | |
| 
 | |
| 	if err = c.iot.Call("get_device_ice", nil, &res); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if string(res) == `{"dev_ice":"retry"}` {
 | |
| 		return nil, nil
 | |
| 	}
 | |
| 
 | |
| 	var v struct {
 | |
| 		ICE [][]byte `json:"dev_ice"`
 | |
| 	}
 | |
| 	if err = json.Unmarshal(res, &v); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	for _, b := range v.ICE {
 | |
| 		init := pion.ICECandidateInit{}
 | |
| 		if err = json.Unmarshal(b, &init); err != nil {
 | |
| 			return
 | |
| 		}
 | |
| 		ice = append(ice, init.Candidate)
 | |
| 	}
 | |
| 
 | |
| 	return
 | |
| }
 | |
| 
 | |
| func (c *Client) StartVoiceChat() error {
 | |
| 	// record - audio from robot, play - audio to robot?
 | |
| 	params := fmt.Sprintf(`{"record":%t,"play":%t}`, c.audio, c.backchannel)
 | |
| 	return c.Request("start_voice_chat", params)
 | |
| }
 | |
| 
 | |
| func (c *Client) SwitchVideoQuality(hd bool) error {
 | |
| 	if hd {
 | |
| 		return c.Request("switch_video_quality", `{"quality":"HD"}`)
 | |
| 	} else {
 | |
| 		return c.Request("switch_video_quality", `{"quality":"SD"}`)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (c *Client) SetVoiceChatVolume(volume int) error {
 | |
| 	params := `{"volume":` + strconv.Itoa(volume) + `}`
 | |
| 	return c.Request("set_voice_chat_volume", params)
 | |
| }
 | |
| 
 | |
| func (c *Client) EnableHomesecVoice(enable bool) error {
 | |
| 	if enable {
 | |
| 		return c.Request("enable_homesec_voice", `{"enable":true}`)
 | |
| 	} else {
 | |
| 		return c.Request("enable_homesec_voice", `{"enable":false}`)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (c *Client) Request(method string, args any) (err error) {
 | |
| 	var reply string
 | |
| 
 | |
| 	if err = c.iot.Call(method, args, &reply); err != nil {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if reply != `["ok"]` {
 | |
| 		return errors.New(reply)
 | |
| 	}
 | |
| 
 | |
| 	return
 | |
| }
 | 
