diff --git a/cmd/hass/hass.go b/cmd/hass/hass.go index c220b4d5..38c34aec 100644 --- a/cmd/hass/hass.go +++ b/cmd/hass/hass.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/roborock" "github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/rs/zerolog" @@ -26,13 +27,13 @@ func Init() { // support load cameras from Hass config file filename := path.Join(conf.Mod.Config, ".storage/core.config_entries") - data, err := os.ReadFile(filename) + b, err := os.ReadFile(filename) if err != nil { return } storage := new(entries) - if err = json.Unmarshal(data, storage); err != nil { + if err = json.Unmarshal(b, storage); err != nil { return } @@ -48,22 +49,37 @@ func Init() { for _, entrie := range storage.Data.Entries { switch entrie.Domain { case "generic": - if entrie.Options.StreamSource == "" { + var options struct { + StreamSource string `json:"stream_source"` + } + if err = json.Unmarshal(entrie.Data, &options); err != nil { continue } - urls[entrie.Title] = entrie.Options.StreamSource + urls[entrie.Title] = options.StreamSource case "homekit_controller": - if entrie.Data.ClientID == "" { + var data struct { + ClientID string `json:"iOSPairingId"` + ClientPrivate string `json:"iOSDeviceLTSK"` + ClientPublic string `json:"iOSDeviceLTPK"` + DeviceID string `json:"AccessoryPairingID"` + DevicePublic string `json:"AccessoryLTPK"` + DeviceHost string `json:"AccessoryIP"` + DevicePort uint16 `json:"AccessoryPort"` + } + if err = json.Unmarshal(entrie.Data, &data); err != nil { continue } urls[entrie.Title] = fmt.Sprintf( "homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s", - entrie.Data.DeviceHost, entrie.Data.DevicePort, - entrie.Data.ClientID, entrie.Data.ClientPrivate, entrie.Data.ClientPublic, - entrie.Data.DeviceID, entrie.Data.DevicePublic, + data.DeviceHost, data.DevicePort, + data.ClientID, data.ClientPrivate, data.ClientPublic, + data.DeviceID, data.DevicePublic, ) + case "roborock": + _ = json.Unmarshal(entrie.Data, &roborock.Auth) + default: continue } @@ -78,20 +94,10 @@ var log zerolog.Logger type entries struct { Data struct { Entries []struct { - Title string `json:"title"` - Domain string `json:"domain"` - Data struct { - ClientID string `json:"iOSPairingId"` - ClientPrivate string `json:"iOSDeviceLTSK"` - ClientPublic string `json:"iOSDeviceLTPK"` - DeviceID string `json:"AccessoryPairingID"` - DevicePublic string `json:"AccessoryLTPK"` - DeviceHost string `json:"AccessoryIP"` - DevicePort uint16 `json:"AccessoryPort"` - } `json:"data"` - Options struct { - StreamSource string `json:"stream_source"` - } + Title string `json:"title"` + Domain string `json:"domain"` + Data json.RawMessage `json:"data"` + Options json.RawMessage `json:"options"` } `json:"entries"` } `json:"data"` } diff --git a/cmd/roborock/roborock.go b/cmd/roborock/roborock.go new file mode 100644 index 00000000..1dcc8355 --- /dev/null +++ b/cmd/roborock/roborock.go @@ -0,0 +1,120 @@ +package roborock + +import ( + "encoding/json" + "fmt" + "github.com/AlexxIT/go2rtc/cmd/api" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/roborock" + "net/http" +) + +func Init() { + streams.HandleFunc("roborock", handle) + + api.HandleFunc("api/roborock", apiHandle) +} + +func handle(url string) (core.Producer, error) { + conn := roborock.NewClient(url) + if err := conn.Dial(); err != nil { + return nil, err + } + if err := conn.Connect(); err != nil { + return nil, err + } + return conn, nil +} + +var Auth struct { + UserData *roborock.UserInfo `json:"user_data"` + BaseURL string `json:"base_url"` +} + +func apiHandle(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + if Auth.UserData == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + case "POST": + if err := r.ParseMultipartForm(1024); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + if username == "" || password == "" { + http.Error(w, "empty username or password", http.StatusBadRequest) + return + } + + base, err := roborock.GetBaseURL(username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + ui, err := roborock.Login(base, username, password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + Auth.BaseURL = base + Auth.UserData = ui + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + + homeID, err := roborock.GetHomeID(Auth.BaseURL, Auth.UserData.Token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := roborock.GetDevices(Auth.UserData, homeID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if len(devices) == 0 { + http.Error(w, "no devices in the account", http.StatusNotFound) + return + } + + var response struct { + Devices []struct { + Name string `json:"name"` + Source string `json:"source"` + } `json:"devices"` + } + + for _, device := range devices { + source := fmt.Sprintf( + "roborock://%s?u=%s&s=%s&k=%s&did=%s&key=%s&pin=", + Auth.UserData.IoT.URL.MQTT[6:], + Auth.UserData.IoT.User, Auth.UserData.IoT.Pass, Auth.UserData.IoT.Domain, + device.DID, device.Key, + ) + + response.Devices = append(response.Devices, struct { + Name string `json:"name"` + Source string `json:"source"` + }{ + Name: device.Name, + Source: source, + }) + } + + if err = json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/cmd/webtorrent/init.go b/cmd/webtorrent/init.go index 13c7dfd0..bc250ddd 100644 --- a/cmd/webtorrent/init.go +++ b/cmd/webtorrent/init.go @@ -118,8 +118,8 @@ func apiHandle(w http.ResponseWriter, r *http.Request) { } // create new random share - share = core.RandString(16) - pwd := core.RandString(16) + share = core.RandString(10, 62) + pwd := core.RandString(10, 62) srv.AddShare(share, pwd, src) if shares == nil { diff --git a/main.go b/main.go index 2dbea5db..78dbc593 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/AlexxIT/go2rtc/cmd/mp4" "github.com/AlexxIT/go2rtc/cmd/mpegts" "github.com/AlexxIT/go2rtc/cmd/ngrok" + "github.com/AlexxIT/go2rtc/cmd/roborock" "github.com/AlexxIT/go2rtc/cmd/rtmp" "github.com/AlexxIT/go2rtc/cmd/rtsp" "github.com/AlexxIT/go2rtc/cmd/srtp" @@ -47,6 +48,7 @@ func main() { tapo.Init() isapi.Init() mpegts.Init() + roborock.Init() srtp.Init() homekit.Init() diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 69dedb20..060894df 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -8,16 +8,16 @@ import ( "strings" ) -const digits = "0123456789abcdefghijklmnopqrstuvwxyz" -const maxSize = byte(len(digits)) +const symbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_" -func RandString(size byte) string { +// RandString base10 - numbers, base16 - hex, base36 - digits+letters, base64 - URL safe symbols +func RandString(size, base byte) string { b := make([]byte, size) if _, err := cryptorand.Read(b); err != nil { panic(err) } for i := byte(0); i < size; i++ { - b[i] = digits[b[i]%maxSize] + b[i] = symbols[b[i]%base] } return string(b) } diff --git a/pkg/mqtt/client.go b/pkg/mqtt/client.go new file mode 100644 index 00000000..8874d087 --- /dev/null +++ b/pkg/mqtt/client.go @@ -0,0 +1,112 @@ +package mqtt + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +const Timeout = time.Second * 5 + +type Client struct { + conn net.Conn + mid uint16 +} + +func NewClient(conn net.Conn) *Client { + return &Client{conn: conn, mid: 2} +} + +func (c *Client) Connect(clientID, username, password string) (err error) { + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + msg := NewConnect(clientID, username, password) + if _, err = c.conn.Write(msg.b); err != nil { + return + } + + b := make([]byte, 4) + if _, err = io.ReadFull(c.conn, b); err != nil { + return + } + + if !bytes.Equal(b, []byte{CONNACK, 2, 0, 0}) { + return errors.New("wrong login") + } + + return +} + +func (c *Client) Subscribe(topic string) (err error) { + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + c.mid++ + msg := NewSubscribe(c.mid, topic, 1) + _, err = c.conn.Write(msg.b) + return +} + +func (c *Client) Publish(topic string, payload []byte) (err error) { + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + c.mid++ + msg := NewPublishQOS1(c.mid, topic, payload) + _, err = c.conn.Write(msg.b) + return +} + +func (c *Client) Read() (string, []byte, error) { + if err := c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return "", nil, err + } + + b := make([]byte, 1) + if _, err := io.ReadFull(c.conn, b); err != nil { + return "", nil, err + } + + size, err := ReadLen(c.conn) + if err != nil { + return "", nil, err + } + + b0 := b[0] + b = make([]byte, size) + if _, err = io.ReadFull(c.conn, b); err != nil { + return "", nil, err + } + + if b0&0xF0 != PUBLISH { + return "", nil, nil + } + + i := binary.BigEndian.Uint16(b) + if uint32(i) > size { + return "", nil, errors.New("wrong topic size") + } + + b = b[2:] + + if qos := (b0 >> 1) & 0b11; qos == 0 { + return string(b[:i]), b[i:], nil + } + + // response with packet ID + _, _ = c.conn.Write([]byte{PUBACK, 2, b[i], b[i+1]}) + + return string(b[2:i]), b[i+2:], nil +} + +func (c *Client) Close() error { + // TODO: Teardown + return c.conn.Close() +} diff --git a/pkg/mqtt/message.go b/pkg/mqtt/message.go new file mode 100644 index 00000000..e3d34214 --- /dev/null +++ b/pkg/mqtt/message.go @@ -0,0 +1,122 @@ +package mqtt + +import ( + "io" +) + +type Message struct { + b []byte +} + +// https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html +const ( + CONNECT = 0x10 + CONNACK = 0x20 + PUBLISH = 0x30 + PUBACK = 0x40 + SUBSCRIBE = 0x82 + SUBACK = 0x90 + QOS1 = 0x02 +) + +func (m *Message) WriteByte(b byte) { + m.b = append(m.b, b) +} + +func (m *Message) WriteBytes(b []byte) { + m.b = append(m.b, b...) +} + +func (m *Message) WriteUint16(i uint16) { + m.b = append(m.b, byte(i>>8), byte(i)) +} + +func (m *Message) WriteLen(i int) { + for i > 0 { + b := byte(i % 128) + if i /= 128; i > 0 { + b |= 0x80 + } + m.WriteByte(b) + } +} + +func (m *Message) WriteString(s string) { + m.WriteUint16(uint16(len(s))) + m.b = append(m.b, s...) +} + +func (m *Message) Bytes() []byte { + return m.b +} + +const ( + flagCleanStart = 0x02 + flagUsername = 0x80 + flagPassword = 0x40 +) + +func NewConnect(clientID, username, password string) *Message { + m := &Message{} + m.WriteByte(CONNECT) + m.WriteLen(16 + len(clientID) + len(username) + len(password)) + + m.WriteString("MQTT") + m.WriteByte(4) // MQTT version + m.WriteByte(flagCleanStart | flagUsername | flagPassword) + m.WriteUint16(30) // keepalive + + m.WriteString(clientID) + m.WriteString(username) + m.WriteString(password) + return m +} + +func NewSubscribe(mid uint16, topic string, qos byte) *Message { + m := &Message{} + m.WriteByte(SUBSCRIBE) + m.WriteLen(5 + len(topic)) + + m.WriteUint16(mid) + m.WriteString(topic) + m.WriteByte(qos) + return m +} + +func NewPublish(topic string, payload []byte) *Message { + m := &Message{} + m.WriteByte(PUBLISH) + m.WriteLen(2 + len(topic) + len(payload)) + + m.WriteString(topic) + m.WriteBytes(payload) + return m +} + +func NewPublishQOS1(mid uint16, topic string, payload []byte) *Message { + m := &Message{} + m.WriteByte(PUBLISH | QOS1) + m.WriteLen(4 + len(topic) + len(payload)) + + m.WriteString(topic) + m.WriteUint16(mid) + m.WriteBytes(payload) + return m +} + +func ReadLen(r io.Reader) (uint32, error) { + var i uint32 + var shift byte + + b := []byte{0x80} + for b[0]&0x80 != 0 { + if _, err := r.Read(b); err != nil { + return 0, err + } + + i += uint32(b[0]&0x7F) << shift + shift += 7 + } + + return i, nil +} diff --git a/pkg/roborock/api.go b/pkg/roborock/api.go new file mode 100644 index 00000000..259be645 --- /dev/null +++ b/pkg/roborock/api.go @@ -0,0 +1,166 @@ +package roborock + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/AlexxIT/go2rtc/pkg/core" + "net/http" + "net/url" + "strconv" + "time" +) + +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 +} diff --git a/pkg/roborock/client.go b/pkg/roborock/client.go new file mode 100644 index 00000000..0a3fe5b8 --- /dev/null +++ b/pkg/roborock/client.go @@ -0,0 +1,365 @@ +package roborock + +import ( + "crypto/md5" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "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" + "log" + "net/rpc" + "net/url" + "strconv" + "sync" + "time" +) + +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() (err error) { + u, err := url.Parse(c.url) + if err != nil { + return + } + + c.iot, err = iot.Dial(c.url) + + 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: + // 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 + } + + log.Printf("[roborock] offer\n%s", pc.LocalDescription().SDP) + if err = c.SendSDPtoRobot(pc.LocalDescription()); 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 +} diff --git a/pkg/roborock/iot/client.go b/pkg/roborock/iot/client.go new file mode 100644 index 00000000..8773455d --- /dev/null +++ b/pkg/roborock/iot/client.go @@ -0,0 +1,173 @@ +package iot + +import ( + "crypto/md5" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/AlexxIT/go2rtc/pkg/mqtt" + "github.com/rs/zerolog/log" + "net" + "net/rpc" + "net/url" + "time" +) + +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 +} diff --git a/pkg/roborock/iot/crypto.go b/pkg/roborock/iot/crypto.go new file mode 100644 index 00000000..7a4c641e --- /dev/null +++ b/pkg/roborock/iot/crypto.go @@ -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) +} diff --git a/pkg/roborock/producer.go b/pkg/roborock/producer.go new file mode 100644 index 00000000..c0dbe87c --- /dev/null +++ b/pkg/roborock/producer.go @@ -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() +} diff --git a/pkg/webtorrent/client.go b/pkg/webtorrent/client.go index ac35685e..de6b21c7 100644 --- a/pkg/webtorrent/client.go +++ b/pkg/webtorrent/client.go @@ -49,7 +49,7 @@ func NewClient(tracker, share, pwd string, pc *pion.PeerConnection) (*webrtc.Con // 5. Send offer msg := fmt.Sprintf( `{"action":"announce","info_hash":"%s","peer_id":"%s","offers":[{"offer_id":"%s","offer":{"type":"offer","sdp":"%s"}}],"numwant":1}`, - InfoHash(share), core.RandString(16), nonce, base64.StdEncoding.EncodeToString(enc), + InfoHash(share), core.RandString(16, 36), nonce, base64.StdEncoding.EncodeToString(enc), ) if err = ws.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { return nil, err diff --git a/pkg/webtorrent/server.go b/pkg/webtorrent/server.go index 46e450aa..e9e3a6b8 100644 --- a/pkg/webtorrent/server.go +++ b/pkg/webtorrent/server.go @@ -80,7 +80,7 @@ func (s *Server) Serve() error { continue } - peerID := core.RandString(16) + peerID := core.RandString(16, 36) // instant run announce worker s.announce = core.NewWorker(0, func() time.Duration {