install go2rtc on bob

This commit is contained in:
2026-04-04 19:36:14 +02:00
parent f0b56e63d1
commit ccf88187b8
537 changed files with 69213 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
package roborock
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
type UserInfo struct {
Token string `json:"token"`
IoT struct {
User string `json:"u"`
Pass string `json:"s"`
Hash string `json:"h"`
Domain string `json:"k"`
URL struct {
API string `json:"a"`
MQTT string `json:"m"`
} `json:"r"`
} `json:"rriot"`
}
func GetBaseURL(username string) (string, error) {
u := "https://euiot.roborock.com/api/v1/getUrlByEmail?email=" + url.QueryEscape(username)
req, err := http.NewRequest("POST", u, nil)
if err != nil {
return "", err
}
client := http.Client{Timeout: time.Second * 5000}
res, err := client.Do(req)
var v struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err = json.NewDecoder(res.Body).Decode(&v); err != nil {
return "", err
}
if v.Code != 200 {
return "", fmt.Errorf("%d: %s", v.Code, v.Msg)
}
return v.Data.URL, nil
}
func Login(baseURL, username, password string) (*UserInfo, error) {
u := baseURL + "/api/v1/login?username=" + url.QueryEscape(username) +
"&password=" + url.QueryEscape(password) + "&needtwostepauth=false"
req, err := http.NewRequest("POST", u, nil)
if err != nil {
return nil, err
}
clientID := core.RandString(16, 64)
clientID = base64.StdEncoding.EncodeToString([]byte(clientID))
req.Header.Set("header_clientid", clientID)
client := http.Client{Timeout: time.Second * 5000}
res, err := client.Do(req)
var v struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data UserInfo `json:"data"`
}
if err = json.NewDecoder(res.Body).Decode(&v); err != nil {
return nil, err
}
if v.Code != 200 {
return nil, fmt.Errorf("%d: %s", v.Code, v.Msg)
}
return &v.Data, nil
}
func GetHomeID(baseURL, token string) (int, error) {
req, err := http.NewRequest("GET", baseURL+"/api/v1/getHomeDetail", nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", token)
client := http.Client{Timeout: time.Second * 5000}
res, err := client.Do(req)
if err != nil {
return 0, err
}
var v struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
HomeID int `json:"rrHomeId"`
} `json:"data"`
}
if err = json.NewDecoder(res.Body).Decode(&v); err != nil {
return 0, err
}
if v.Code != 200 {
return 0, fmt.Errorf("%d: %s", v.Code, v.Msg)
}
return v.Data.HomeID, nil
}
type DeviceInfo struct {
DID string `json:"duid"`
Name string `json:"name"`
Key string `json:"localKey"`
}
func GetDevices(ui *UserInfo, homeID int) ([]DeviceInfo, error) {
nonce := core.RandString(6, 64)
ts := time.Now().Unix()
path := "/user/homes/" + strconv.Itoa(homeID)
mac := fmt.Sprintf(
"%s:%s:%s:%d:%x::", ui.IoT.User, ui.IoT.Pass, nonce, ts, md5.Sum([]byte(path)),
)
hash := hmac.New(sha256.New, []byte(ui.IoT.Hash))
hash.Write([]byte(mac))
mac = base64.StdEncoding.EncodeToString(hash.Sum(nil))
auth := fmt.Sprintf(
`Hawk id="%s", s="%s", ts="%d", nonce="%s", mac="%s"`,
ui.IoT.User, ui.IoT.Pass, ts, nonce, mac,
)
req, err := http.NewRequest("GET", ui.IoT.URL.API+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
client := http.Client{Timeout: time.Second * 5000}
res, err := client.Do(req)
if err != nil {
return nil, err
}
var v struct {
Result struct {
Devices []DeviceInfo `json:"devices"`
} `json:"result"`
}
if err = json.NewDecoder(res.Body).Decode(&v); err != nil {
return nil, err
}
return v.Result.Devices, nil
}
@@ -0,0 +1,381 @@
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/v4"
)
// Deprecated: should be rewritten to core.Connection
type Client struct {
core.Listener
url string
conn *webrtc.Conn
iot *rpc.Client
devKey string
pin string
devTopic string
audio bool
backchannel bool
}
func Dial(rawURL string) (*Client, error) {
client := &Client{url: rawURL}
if err := client.Dial(); err != nil {
return nil, err
}
if err := client.Connect(); err != nil {
return nil, err
}
return client, nil
}
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.FormatName = "roborock"
c.conn.Mode = core.ModeActiveProducer
c.conn.Protocol = "mqtt"
c.conn.URL = c.url
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
}
@@ -0,0 +1,173 @@
package iot
import (
"crypto/md5"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/rpc"
"net/url"
"time"
"github.com/AlexxIT/go2rtc/pkg/mqtt"
)
type Codec struct {
mqtt *mqtt.Client
devTopic string
devKey string
body json.RawMessage
}
type dps struct {
Dps struct {
Req string `json:"101,omitempty"`
Res string `json:"102,omitempty"`
} `json:"dps"`
T uint32 `json:"t"`
}
type response struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result"`
Error struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func (c *Codec) WriteRequest(r *rpc.Request, v any) error {
if v == nil {
v = "[]"
}
ts := uint32(time.Now().Unix())
msg := dps{T: ts}
msg.Dps.Req = fmt.Sprintf(
`{"id":%d,"method":"%s","params":%s}`, r.Seq, r.ServiceMethod, v,
)
payload, err := json.Marshal(msg)
if err != nil {
return err
}
//log.Printf("[roborock] send: %s", payload)
payload = c.Encrypt(payload, ts, ts, ts)
return c.mqtt.Publish("rr/m/i/"+c.devTopic, payload)
}
func (c *Codec) ReadResponseHeader(r *rpc.Response) error {
for {
// receive any message from MQTT
_, payload, err := c.mqtt.Read()
if err != nil {
return err
}
// skip if it is not PUBLISH message
if payload == nil {
continue
}
// decrypt MQTT PUBLISH payload
if payload, err = c.Decrypt(payload); err != nil {
continue
}
// skip if we can't decrypt this payload (ex. binary payload)
if payload == nil {
continue
}
//log.Printf("[roborock] recv %s", payload)
// get content from response payload:
// {"t":1676871268,"dps":{"102":"{\"id\":315003,\"result\":[\"ok\"]}"}}
var msg dps
if err = json.Unmarshal(payload, &msg); err != nil {
continue
}
var res response
if err = json.Unmarshal([]byte(msg.Dps.Res), &res); err != nil {
continue
}
r.Seq = res.ID
if res.Error.Code != 0 {
r.Error = res.Error.Message
} else {
c.body = res.Result
}
return nil
}
}
func (c *Codec) ReadResponseBody(v any) error {
switch vv := v.(type) {
case *[]byte:
*vv = c.body
case *string:
*vv = string(c.body)
case *bool:
*vv = string(c.body) == `["ok"]`
}
return nil
}
func (c *Codec) Close() error {
return c.mqtt.Close()
}
func Dial(rawURL string) (*rpc.Client, error) {
link, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
// dial to MQTT
conn, err := net.DialTimeout("tcp", link.Host, time.Second*5)
if err != nil {
return nil, err
}
// process MQTT SSL
conf := &tls.Config{ServerName: link.Hostname()}
sconn := tls.Client(conn, conf)
if err = sconn.Handshake(); err != nil {
return nil, err
}
query := link.Query()
// send MQTT login
uk := md5.Sum([]byte(query.Get("u") + ":" + query.Get("k")))
sk := md5.Sum([]byte(query.Get("s") + ":" + query.Get("k")))
user := hex.EncodeToString(uk[1:5])
pass := hex.EncodeToString(sk[8:])
c := &Codec{
mqtt: mqtt.NewClient(sconn),
devKey: query.Get("key"),
devTopic: query.Get("u") + "/" + user + "/" + query.Get("did"),
}
if err = c.mqtt.Connect("com.roborock.smart:mbrriot", user, pass); err != nil {
return nil, err
}
// subscribe on device topic
if err = c.mqtt.Subscribe("rr/m/o/" + c.devTopic); err != nil {
return nil, err
}
return rpc.NewClientWithCodec(c), nil
}
@@ -0,0 +1,115 @@
package iot
import (
"crypto/aes"
"crypto/md5"
"encoding/binary"
"errors"
"hash/crc32"
)
// key - convert timestamp to key
func (c *Codec) key(timestamp uint32) []byte {
const salt = "TXdfu$jyZ#TZHsg4"
key := md5.Sum([]byte(encodeTimestamp(timestamp) + c.devKey + salt))
return key[:]
}
func (c *Codec) Decrypt(cipherText []byte) ([]byte, error) {
if len(cipherText) < 32 || string(cipherText[:3]) != "1.0" {
return nil, errors.New("wrong message prefix")
}
i := len(cipherText) - 4
if binary.BigEndian.Uint32(cipherText[i:]) != crc32.ChecksumIEEE(cipherText[:i]) {
return nil, errors.New("wrong message checksum")
}
if proto := binary.BigEndian.Uint16(cipherText[15:]); proto != 102 {
return nil, nil
}
timestamp := binary.BigEndian.Uint32(cipherText[11:])
return decryptECB(cipherText[19:i], c.key(timestamp)), nil
}
func (c *Codec) Encrypt(plainText []byte, seq, random, timestamp uint32) []byte {
const proto = 101
cipherText := encryptECB(plainText, c.key(timestamp))
size := uint16(len(cipherText))
msg := make([]byte, 23+size)
copy(msg, "1.0")
binary.BigEndian.PutUint32(msg[3:], seq)
binary.BigEndian.PutUint32(msg[7:], random)
binary.BigEndian.PutUint32(msg[11:], timestamp)
binary.BigEndian.PutUint16(msg[15:], proto)
binary.BigEndian.PutUint16(msg[17:], size)
copy(msg[19:], cipherText)
crc := crc32.ChecksumIEEE(msg[:19+size])
binary.BigEndian.PutUint32(msg[19+size:], crc)
return msg
}
func encodeTimestamp(i uint32) string {
const hextable = "0123456789abcdef"
b := []byte{
hextable[i>>8&0xF], hextable[i>>4&0xF],
hextable[i>>16&0xF], hextable[i&0xF],
hextable[i>>24&0xF], hextable[i>>20&0xF],
hextable[i>>28&0xF], hextable[i>>12&0xF],
}
return string(b)
}
func pad(plainText []byte, blockSize int) []byte {
b0 := byte(blockSize - len(plainText)%blockSize)
for i := byte(0); i < b0; i++ {
plainText = append(plainText, b0)
}
return plainText
}
func unpad(paddedText []byte) []byte {
padSize := int(paddedText[len(paddedText)-1])
return paddedText[:len(paddedText)-padSize]
}
func encryptECB(plainText, key []byte) []byte {
block, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
blockSize := block.BlockSize()
plainText = pad(plainText, blockSize)
cipherText := plainText
for len(plainText) > 0 {
block.Encrypt(plainText, plainText)
plainText = plainText[blockSize:]
}
return cipherText
}
func decryptECB(cipherText, key []byte) []byte {
block, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
blockSize := block.BlockSize()
paddedText := cipherText
for len(cipherText) > 0 {
block.Decrypt(cipherText, cipherText)
cipherText = cipherText[blockSize:]
}
return unpad(paddedText)
}
@@ -0,0 +1,46 @@
package roborock
import (
"github.com/AlexxIT/go2rtc/pkg/core"
)
func (c *Client) GetMedias() []*core.Media {
return c.conn.GetMedias()
}
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
if media.Kind == core.KindAudio {
c.audio = true
}
return c.conn.GetTrack(media, codec)
}
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
c.backchannel = true
return c.conn.AddTrack(media, codec, track)
}
func (c *Client) Start() error {
if c.audio || c.backchannel {
if err := c.StartVoiceChat(); err != nil {
return err
}
if c.backchannel {
if err := c.SetVoiceChatVolume(80); err != nil {
return err
}
}
}
return c.conn.Start()
}
func (c *Client) Stop() error {
_ = c.iot.Close()
return c.conn.Stop()
}
func (c *Client) MarshalJSON() ([]byte, error) {
return c.conn.MarshalJSON()
}